From 21977e770158759886640795c54a01b792d27b5b Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:42:05 -0300 Subject: [PATCH 1/7] implement: workflow-seed-cmd --- cmd/seed/seed.go | 1 + cmd/seed/testdata/soc2_workflows.sample.json | 50 ++ cmd/seed/workflows.go | 508 +++++++++++++++++++ cmd/seed/workflows_test.go | 145 ++++++ 4 files changed, 704 insertions(+) create mode 100644 cmd/seed/testdata/soc2_workflows.sample.json create mode 100644 cmd/seed/workflows.go create mode 100644 cmd/seed/workflows_test.go diff --git a/cmd/seed/seed.go b/cmd/seed/seed.go index d4b32e45..f71d25fe 100644 --- a/cmd/seed/seed.go +++ b/cmd/seed/seed.go @@ -14,4 +14,5 @@ var ( func init() { RootCmd.AddCommand(newHeartbeatCMD()) RootCmd.AddCommand(newEvidenceCMD()) + RootCmd.AddCommand(newWorkflowsCMD()) } diff --git a/cmd/seed/testdata/soc2_workflows.sample.json b/cmd/seed/testdata/soc2_workflows.sample.json new file mode 100644 index 00000000..1f577f54 --- /dev/null +++ b/cmd/seed/testdata/soc2_workflows.sample.json @@ -0,0 +1,50 @@ +[ + { + "key": "tech-controls-governance", + "name": "Technology Controls Governance & Independent Review", + "description": "Technology Controls Governance & Independent Review for a simple cloud application. Recurring activity that evidences the mapped SOC 2 CCF controls.", + "version": "1.0", + "suggested-cadence": "annually", + "grace-period-days": 30, + "evidence-required": "Per-step document links, attestations, and review/approval records.", + "steps": [ + { "name": "Define control scope & objectives", "description": "Define control scope & objectives", "order": 0, "responsible-role": "Security Lead", "evidence-required": [ { "type": "document", "description": "Evidence for: Define control scope & objectives", "required": true } ], "estimated-duration": 60, "depends-on": [] }, + { "name": "Document deprovisioning requirements", "description": "Document deprovisioning requirements", "order": 1, "responsible-role": "Security Lead", "evidence-required": [ { "type": "document", "description": "Evidence for: Document deprovisioning requirements", "required": true } ], "estimated-duration": 60, "depends-on": [ "Define control scope & objectives" ] }, + { "name": "Assign administration responsibilities", "description": "Assign administration responsibilities", "order": 2, "responsible-role": "Security Lead", "evidence-required": [ { "type": "attestation", "description": "Evidence for: Assign administration responsibilities", "required": true } ], "estimated-duration": 60, "depends-on": [ "Document deprovisioning requirements" ] }, + { "name": "Periodic control review", "description": "Periodic control review", "order": 3, "responsible-role": "Security Lead", "evidence-required": [ { "type": "attestation", "description": "Evidence for: Periodic control review", "required": true } ], "estimated-duration": 60, "depends-on": [ "Assign administration responsibilities" ] }, + { "name": "Independent effectiveness review", "description": "Independent effectiveness review", "order": 4, "responsible-role": "Internal Audit", "evidence-required": [ { "type": "attestation", "description": "Evidence for: Independent effectiveness review", "required": true } ], "estimated-duration": 60, "depends-on": [ "Periodic control review" ] } + ], + "control-relationships": [ + { "control-id": "ctrl-cc5-2-002", "catalog-id": "0f9d8e10-363b-4a8f-ade5-f11c0b2b1202", "relationship-type": "satisfies", "strength": "primary", "is-active": true, "_title": "Technology control scope is defined" }, + { "control-id": "ctrl-cc5-2-003", "catalog-id": "0f9d8e10-363b-4a8f-ade5-f11c0b2b1202", "relationship-type": "satisfies", "strength": "primary", "is-active": true, "_title": "Technology control objectives are defined" }, + { "control-id": "ctrl-cc5-2-019", "catalog-id": "0f9d8e10-363b-4a8f-ade5-f11c0b2b1202", "relationship-type": "satisfies", "strength": "primary", "is-active": true, "_title": "Termination-triggered technology access removal is initiated by authorized sources" } + ], + "instances": [ + { "name": "Technology Controls Governance & Independent Review — ToDo Demo App", "description": "Technology Controls Governance & Independent Review implemented for ToDo Demo App.", "system-id": "f8c1a2b3-d4e5-6f7a-8b9c-0d1e2f3a4b5c", "cadence": "annually", "is-active": true, "grace-period-days": 30, "role-assignments": [ { "role-name": "Security Lead", "assigned-to-type": "group", "assigned-to-id": "security-team", "is-active": true } ] } + ] + }, + { + "key": "asset-disposal-media", + "name": "Asset Disposal, Media Sanitization & Handling", + "description": "Asset Disposal, Media Sanitization & Handling for a simple cloud application. Recurring activity that evidences the mapped SOC 2 CCF controls.", + "version": "1.0", + "suggested-cadence": "cron:0 0 9 1 1,7 *", + "grace-period-days": 14, + "evidence-required": "Per-step document links, attestations, and review/approval records.", + "steps": [ + { "name": "Define sanitization & destruction methods", "description": "Define sanitization & destruction methods", "order": 0, "responsible-role": "IT Operations", "evidence-required": [ { "type": "document", "description": "Evidence for: Define sanitization & destruction methods", "required": true } ], "estimated-duration": 60, "depends-on": [] }, + { "name": "Authorize destruction", "description": "Authorize destruction", "order": 1, "responsible-role": "IT Operations", "evidence-required": [ { "type": "workflow", "description": "Evidence for: Authorize destruction", "required": true } ], "estimated-duration": 60, "depends-on": [ "Define sanitization & destruction methods" ] }, + { "name": "Control custody until disposal", "description": "Control custody until disposal", "order": 2, "responsible-role": "IT Operations", "evidence-required": [ { "type": "workflow", "description": "Evidence for: Control custody until disposal", "required": true } ], "estimated-duration": 60, "depends-on": [ "Authorize destruction" ] }, + { "name": "Sanitize/destroy & record", "description": "Sanitize/destroy & record", "order": 3, "responsible-role": "IT Operations", "evidence-required": [ { "type": "log", "description": "Evidence for: Sanitize/destroy & record", "required": true } ], "estimated-duration": 60, "depends-on": [ "Control custody until disposal" ] }, + { "name": "Periodic disposal review", "description": "Periodic disposal review", "order": 4, "responsible-role": "IT Operations", "evidence-required": [ { "type": "attestation", "description": "Evidence for: Periodic disposal review", "required": true } ], "estimated-duration": 60, "depends-on": [ "Sanitize/destroy & record" ] } + ], + "control-relationships": [ + { "control-id": "ctrl-cc6-5-002", "catalog-id": "0f9d8e10-363b-4a8f-ade5-f11c0b2b1202", "relationship-type": "satisfies", "strength": "primary", "is-active": true, "_title": "Data recovery capability is diminished before protections are discontinued" }, + { "control-id": "ctrl-cc6-5-003", "catalog-id": "0f9d8e10-363b-4a8f-ade5-f11c0b2b1202", "relationship-type": "satisfies", "strength": "primary", "is-active": true, "_title": "Software recovery capability is diminished before protections are discontinued" }, + { "control-id": "ctrl-cc6-5-005", "catalog-id": "0f9d8e10-363b-4a8f-ade5-f11c0b2b1202", "relationship-type": "satisfies", "strength": "primary", "is-active": true, "_title": "Media sanitization is performed before reuse where reuse is allowed" } + ], + "instances": [ + { "name": "Asset Disposal, Media Sanitization & Handling — ToDo Demo App", "description": "Asset Disposal, Media Sanitization & Handling implemented for ToDo Demo App.", "system-id": "f8c1a2b3-d4e5-6f7a-8b9c-0d1e2f3a4b5c", "cadence": "cron:0 0 9 1 1,7 *", "is-active": true, "grace-period-days": 14, "role-assignments": [ { "role-name": "IT Operations", "assigned-to-type": "group", "assigned-to-id": "it-ops", "is-active": true } ] } + ] + } +] diff --git a/cmd/seed/workflows.go b/cmd/seed/workflows.go new file mode 100644 index 00000000..7651e8ae --- /dev/null +++ b/cmd/seed/workflows.go @@ -0,0 +1,508 @@ +package seed + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "os" + "strings" + "time" + + "github.com/compliance-framework/api/internal/config" + "github.com/compliance-framework/api/internal/service" + "github.com/compliance-framework/api/internal/service/relational" + "github.com/compliance-framework/api/internal/service/relational/workflows" + "github.com/google/uuid" + "github.com/spf13/cobra" + "go.uber.org/zap" + "gorm.io/datatypes" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type workflowSeedDefinition struct { + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + Version string `json:"version"` + SuggestedCadence string `json:"suggested-cadence"` + GracePeriodDays *int `json:"grace-period-days"` + EvidenceRequired string `json:"evidence-required"` + Steps []workflowSeedStep `json:"steps"` + ControlRelationships []workflowSeedControlRelationship `json:"control-relationships"` + Instances []workflowSeedInstance `json:"instances"` +} + +type workflowSeedStep struct { + Name string `json:"name"` + Description string `json:"description"` + Order int `json:"order"` + ResponsibleRole string `json:"responsible-role"` + EvidenceRequired []workflows.EvidenceRequirement `json:"evidence-required"` + EstimatedDuration int `json:"estimated-duration"` + DependsOn []string `json:"depends-on"` +} + +type workflowSeedControlRelationship struct { + ControlID string `json:"control-id"` + CatalogID string `json:"catalog-id"` + RelationshipType string `json:"relationship-type"` + Strength string `json:"strength"` + IsActive *bool `json:"is-active"` + Title string `json:"_title"` +} + +type workflowSeedInstance struct { + Name string `json:"name"` + Description string `json:"description"` + SystemID string `json:"system-id"` + Cadence string `json:"cadence"` + IsActive *bool `json:"is-active"` + GracePeriodDays *int `json:"grace-period-days"` + RoleAssignments []workflowSeedRoleAssignment `json:"role-assignments"` +} + +type workflowSeedRoleAssignment struct { + RoleName string `json:"role-name"` + AssignedToType string `json:"assigned-to-type"` + AssignedToID string `json:"assigned-to-id"` + IsActive *bool `json:"is-active"` +} + +type workflowSeedSummary struct { + DefinitionsCreated int + DefinitionsUpdated int + Steps int + Dependencies int + ControlRelationships int + Instances int + RoleAssignments int + Failed int + Skipped int +} + +func newWorkflowsCMD() *cobra.Command { + cmd := &cobra.Command{ + Use: "workflows", + Short: "Import workflow definitions from JSON", + Run: importSeedWorkflows, + } + + cmd.Flags().StringP("file", "f", "", "Input JSON file containing workflow definitions") + if err := cmd.MarkFlagRequired("file"); err != nil { + panic(err) + } + + return cmd +} + +func importSeedWorkflows(cmd *cobra.Command, args []string) { + zapLogger, err := zap.NewProduction() + if err != nil { + log.Fatalf("Can't initialize zap logger: %v", err) + } + sugar := zapLogger.Sugar() + defer func() { + _ = zapLogger.Sync() + }() + + inputFile, err := cmd.Flags().GetString("file") + if err != nil { + sugar.Fatalf("failed to get input file flag: %v", err) + } + + cfg := config.NewConfig(sugar) + db, err := service.ConnectSQLDb(context.Background(), cfg, sugar) + if err != nil { + sugar.Fatalf("failed to connect database: %v", err) + } + + summary, err := importWorkflowsFromFile(context.Background(), db, sugar, inputFile) + if err != nil { + sugar.Fatalf("failed to import workflow seed: %v", err) + } + + sugar.Infow("Workflow seed import completed", + "definitions_created", summary.DefinitionsCreated, + "definitions_updated", summary.DefinitionsUpdated, + "steps", summary.Steps, + "dependencies", summary.Dependencies, + "control_relationships", summary.ControlRelationships, + "instances", summary.Instances, + "role_assignments", summary.RoleAssignments, + "skipped", summary.Skipped, + "failed", summary.Failed, + ) + if summary.Failed > 0 { + sugar.Fatalf("workflow seed import completed with %d failed definitions", summary.Failed) + } +} + +func importWorkflowsFromFile(ctx context.Context, db *gorm.DB, sugar *zap.SugaredLogger, path string) (workflowSeedSummary, error) { + f, err := os.Open(path) + if err != nil { + return workflowSeedSummary{}, fmt.Errorf("failed to open input file: %w", err) + } + defer func() { + if closeErr := f.Close(); closeErr != nil && sugar != nil { + sugar.Errorw("failed to close input file", "error", closeErr) + } + }() + + var definitions []workflowSeedDefinition + if err := json.NewDecoder(f).Decode(&definitions); err != nil { + return workflowSeedSummary{}, fmt.Errorf("failed to decode input JSON: %w", err) + } + + return importWorkflowSeeds(ctx, db, sugar, definitions), nil +} + +func importWorkflowSeeds(ctx context.Context, db *gorm.DB, sugar *zap.SugaredLogger, definitions []workflowSeedDefinition) workflowSeedSummary { + var summary workflowSeedSummary + + for _, seedDef := range definitions { + if strings.TrimSpace(seedDef.Key) == "" { + summary.Skipped++ + if sugar != nil { + sugar.Errorw("Skipping workflow definition with empty key", "name", seedDef.Name) + } + continue + } + + var defSummary workflowSeedSummary + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var err error + defSummary, err = importWorkflowSeedDefinition(tx, seedDef) + return err + }) + if err != nil { + summary.Failed++ + if sugar != nil { + sugar.Errorw("Failed to import workflow definition", "key", seedDef.Key, "name", seedDef.Name, "error", err) + } + continue + } + + mergeWorkflowSeedSummary(&summary, defSummary) + } + + return summary +} + +func importWorkflowSeedDefinition(tx *gorm.DB, seedDef workflowSeedDefinition) (workflowSeedSummary, error) { + var summary workflowSeedSummary + + if seedDef.SuggestedCadence != "" && !workflows.CadenceType(seedDef.SuggestedCadence).IsValid() { + return summary, fmt.Errorf("invalid suggested-cadence %q for workflow definition %q", seedDef.SuggestedCadence, seedDef.Key) + } + + defID := deterministicWorkflowSeedUUID("workflow-definition", seedDef.Key) + definition := &workflows.WorkflowDefinition{ + UUIDModel: relational.UUIDModel{ + ID: &defID, + }, + Name: seedDef.Name, + Description: seedDef.Description, + Version: seedDef.Version, + SuggestedCadence: seedDef.SuggestedCadence, + GracePeriodDays: seedDef.GracePeriodDays, + EvidenceRequired: seedDef.EvidenceRequired, + } + + definitionSvc := workflows.NewWorkflowDefinitionService(tx) + if err := definitionSvc.ValidateDefinition(definition); err != nil { + return summary, err + } + created, err := upsertWorkflowSeed(tx, definition) + if err != nil { + return summary, fmt.Errorf("upsert workflow definition %q: %w", seedDef.Key, err) + } + if created { + summary.DefinitionsCreated++ + } else { + summary.DefinitionsUpdated++ + } + + stepSummary, err := importWorkflowSeedSteps(tx, seedDef, &defID) + if err != nil { + return summary, err + } + summary.Steps += stepSummary.Steps + summary.Dependencies += stepSummary.Dependencies + + relationshipSummary, err := importWorkflowSeedControlRelationships(tx, seedDef, &defID) + if err != nil { + return summary, err + } + summary.ControlRelationships += relationshipSummary.ControlRelationships + + instanceSummary, err := importWorkflowSeedInstances(tx, seedDef, &defID) + if err != nil { + return summary, err + } + summary.Instances += instanceSummary.Instances + summary.RoleAssignments += instanceSummary.RoleAssignments + + return summary, nil +} + +func importWorkflowSeedSteps(tx *gorm.DB, seedDef workflowSeedDefinition, defID *uuid.UUID) (workflowSeedSummary, error) { + var summary workflowSeedSummary + stepSvc := workflows.NewWorkflowStepDefinitionService(tx) + stepIDsByName := make(map[string]*uuid.UUID, len(seedDef.Steps)) + + for _, seedStep := range seedDef.Steps { + stepID := deterministicWorkflowSeedUUID("workflow-step-definition", seedDef.Key, seedStep.Name) + step := &workflows.WorkflowStepDefinition{ + UUIDModel: relational.UUIDModel{ + ID: &stepID, + }, + WorkflowDefinitionID: defID, + Name: seedStep.Name, + Description: seedStep.Description, + Order: seedStep.Order, + ResponsibleRole: seedStep.ResponsibleRole, + EvidenceRequired: datatypes.NewJSONSlice(seedStep.EvidenceRequired), + EstimatedDuration: seedStep.EstimatedDuration, + } + if err := stepSvc.ValidateStep(step); err != nil { + return summary, fmt.Errorf("validate step %q: %w", seedStep.Name, err) + } + if _, err := upsertWorkflowSeed(tx, step); err != nil { + return summary, fmt.Errorf("upsert step %q: %w", seedStep.Name, err) + } + stepIDCopy := stepID + stepIDsByName[seedStep.Name] = &stepIDCopy + summary.Steps++ + } + + for _, seedStep := range seedDef.Steps { + stepID := stepIDsByName[seedStep.Name] + for _, dependsOnName := range seedStep.DependsOn { + dependsOnStepID := stepIDsByName[dependsOnName] + if dependsOnStepID == nil { + return summary, fmt.Errorf("step %q depends on unknown step %q", seedStep.Name, dependsOnName) + } + if err := addWorkflowSeedDependency(tx, stepSvc, stepID, dependsOnStepID); err != nil { + return summary, fmt.Errorf("add dependency %q -> %q: %w", seedStep.Name, dependsOnName, err) + } + summary.Dependencies++ + } + } + + return summary, nil +} + +func addWorkflowSeedDependency(tx *gorm.DB, stepSvc *workflows.WorkflowStepDefinitionService, stepID, dependsOnStepID *uuid.UUID) error { + if stepID == nil || dependsOnStepID == nil { + return errors.New("step dependency IDs are required") + } + + var existing workflows.StepDependency + err := tx.Where("workflow_step_definition_id = ? AND depends_on_step_id = ?", stepID, dependsOnStepID).First(&existing).Error + if err == nil { + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + return stepSvc.AddDependency(stepID, dependsOnStepID) +} + +func importWorkflowSeedControlRelationships(tx *gorm.DB, seedDef workflowSeedDefinition, defID *uuid.UUID) (workflowSeedSummary, error) { + var summary workflowSeedSummary + relationshipSvc := workflows.NewControlRelationshipService(tx) + + for _, seedRelationship := range seedDef.ControlRelationships { + relationshipType := seedRelationship.RelationshipType + if relationshipType == "" { + relationshipType = workflows.RelationshipSatisfies.String() + } + strength := seedRelationship.Strength + if strength == "" { + strength = workflows.StrengthPrimary.String() + } + isActive := true + if seedRelationship.IsActive != nil { + isActive = *seedRelationship.IsActive + } + + relationshipID := deterministicWorkflowSeedUUID("workflow-control-relationship", seedDef.Key, seedRelationship.CatalogID, seedRelationship.ControlID) + relationship := &workflows.ControlRelationship{ + UUIDModel: relational.UUIDModel{ + ID: &relationshipID, + }, + WorkflowDefinitionID: defID, + ControlID: seedRelationship.ControlID, + ControlSource: seedRelationship.CatalogID, + CatalogID: seedRelationship.CatalogID, + RelationshipType: relationshipType, + Strength: strength, + IsActive: isActive, + } + + if err := relationshipSvc.ValidateRelationship(relationship); err != nil { + return summary, fmt.Errorf("validate control relationship %q: %w", seedRelationship.ControlID, err) + } + if _, err := upsertWorkflowSeed(tx, relationship); err != nil { + return summary, fmt.Errorf("upsert control relationship %q: %w", seedRelationship.ControlID, err) + } + summary.ControlRelationships++ + } + + return summary, nil +} + +func importWorkflowSeedInstances(tx *gorm.DB, seedDef workflowSeedDefinition, defID *uuid.UUID) (workflowSeedSummary, error) { + var summary workflowSeedSummary + instanceSvc := workflows.NewWorkflowInstanceService(tx) + assignmentSvc := workflows.NewRoleAssignmentService(tx) + + for _, seedInstance := range seedDef.Instances { + if seedInstance.Cadence != "" && !workflows.CadenceType(seedInstance.Cadence).IsValid() { + return summary, fmt.Errorf("invalid cadence %q for workflow instance %q", seedInstance.Cadence, seedInstance.Name) + } + + systemID, err := uuid.Parse(seedInstance.SystemID) + if err != nil { + return summary, fmt.Errorf("invalid system-id %q for workflow instance %q: %w", seedInstance.SystemID, seedInstance.Name, err) + } + + isActive := true + if seedInstance.IsActive != nil { + isActive = *seedInstance.IsActive + } + + instanceID := deterministicWorkflowSeedUUID("workflow-instance", seedDef.Key, seedInstance.Name) + instance := &workflows.WorkflowInstance{ + UUIDModel: relational.UUIDModel{ + ID: &instanceID, + }, + WorkflowDefinitionID: defID, + Name: seedInstance.Name, + Description: seedInstance.Description, + SystemSecurityPlanID: &systemID, + Cadence: seedInstance.Cadence, + IsActive: isActive, + GracePeriodDays: seedInstance.GracePeriodDays, + } + preserveWorkflowInstanceSchedule(tx, instanceSvc, instance) + + if err := instanceSvc.ValidateInstance(instance); err != nil { + return summary, fmt.Errorf("validate instance %q: %w", seedInstance.Name, err) + } + if _, err := upsertWorkflowSeed(tx, instance); err != nil { + return summary, fmt.Errorf("upsert instance %q: %w", seedInstance.Name, err) + } + summary.Instances++ + + for _, seedAssignment := range seedInstance.RoleAssignments { + if !workflows.AssignmentType(seedAssignment.AssignedToType).IsValid() { + return summary, fmt.Errorf("invalid assigned-to-type %q for role assignment %q", seedAssignment.AssignedToType, seedAssignment.RoleName) + } + + assignmentActive := true + if seedAssignment.IsActive != nil { + assignmentActive = *seedAssignment.IsActive + } + assignmentID := deterministicWorkflowSeedUUID("workflow-role-assignment", seedDef.Key, seedInstance.Name, seedAssignment.RoleName, seedAssignment.AssignedToType, seedAssignment.AssignedToID) + assignment := &workflows.RoleAssignment{ + UUIDModel: relational.UUIDModel{ + ID: &assignmentID, + }, + WorkflowInstanceID: &instanceID, + RoleName: seedAssignment.RoleName, + AssignedToType: seedAssignment.AssignedToType, + AssignedToID: seedAssignment.AssignedToID, + IsActive: assignmentActive, + } + if err := assignmentSvc.ValidateAssignment(assignment); err != nil { + return summary, fmt.Errorf("validate role assignment %q: %w", seedAssignment.RoleName, err) + } + if _, err := upsertWorkflowSeed(tx, assignment); err != nil { + return summary, fmt.Errorf("upsert role assignment %q: %w", seedAssignment.RoleName, err) + } + summary.RoleAssignments++ + } + } + + return summary, nil +} + +func preserveWorkflowInstanceSchedule(tx *gorm.DB, instanceSvc *workflows.WorkflowInstanceService, instance *workflows.WorkflowInstance) { + var existing workflows.WorkflowInstance + err := tx.Select("next_scheduled_at", "last_executed_at").First(&existing, "id = ?", instance.ID).Error + if err == nil { + instance.NextScheduledAt = existing.NextScheduledAt + instance.LastExecutedAt = existing.LastExecutedAt + return + } + + if instance.Cadence != "" { + nextSchedule := instanceSvc.CalculateNextSchedule(time.Now(), instance.Cadence) + instance.NextScheduledAt = &nextSchedule + } +} + +func upsertWorkflowSeed(tx *gorm.DB, value interface{}) (bool, error) { + id, err := workflowSeedID(value) + if err != nil { + return false, err + } + if id == nil { + return false, fmt.Errorf("workflow seed ID is required for %T", value) + } + + var count int64 + if err := tx.Model(value).Where("id = ?", id).Count(&count).Error; err != nil { + return false, err + } + + if err := tx.Clauses(clause.OnConflict{UpdateAll: true}).Create(value).Error; err != nil { + return false, err + } + + return count == 0, nil +} + +func workflowSeedID(value interface{}) (*uuid.UUID, error) { + switch v := value.(type) { + case *workflows.WorkflowDefinition: + return v.ID, nil + case *workflows.WorkflowStepDefinition: + return v.ID, nil + case *workflows.ControlRelationship: + return v.ID, nil + case *workflows.WorkflowInstance: + return v.ID, nil + case *workflows.RoleAssignment: + return v.ID, nil + default: + return nil, fmt.Errorf("unsupported workflow seed type %T", value) + } +} + +func deterministicWorkflowSeedUUID(parts ...string) uuid.UUID { + seedValue := strings.Join(append(parts, "v1"), ":") + hash := sha256.Sum256([]byte(seedValue)) + hashStr := hex.EncodeToString(hash[:16]) + id, _ := uuid.Parse(hashStr[:8] + "-" + hashStr[8:12] + "-" + hashStr[12:16] + "-" + hashStr[16:20] + "-" + hashStr[20:32]) + return id +} + +func mergeWorkflowSeedSummary(dst *workflowSeedSummary, src workflowSeedSummary) { + dst.DefinitionsCreated += src.DefinitionsCreated + dst.DefinitionsUpdated += src.DefinitionsUpdated + dst.Steps += src.Steps + dst.Dependencies += src.Dependencies + dst.ControlRelationships += src.ControlRelationships + dst.Instances += src.Instances + dst.RoleAssignments += src.RoleAssignments + dst.Failed += src.Failed + dst.Skipped += src.Skipped +} diff --git a/cmd/seed/workflows_test.go b/cmd/seed/workflows_test.go new file mode 100644 index 00000000..046415a6 --- /dev/null +++ b/cmd/seed/workflows_test.go @@ -0,0 +1,145 @@ +package seed + +import ( + "context" + "testing" + + "github.com/compliance-framework/api/internal/service/relational/workflows" + "go.uber.org/zap" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func setupWorkflowSeedTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("failed to create test database: %v", err) + } + + for _, entity := range workflows.GetWorkflowEntities() { + if err := db.AutoMigrate(entity); err != nil { + t.Fatalf("failed to migrate %T: %v", entity, err) + } + } + + return db +} + +func TestImportWorkflowsFromFile(t *testing.T) { + db := setupWorkflowSeedTestDB(t) + sugar := zap.NewNop().Sugar() + + summary, err := importWorkflowsFromFile(context.Background(), db, sugar, "testdata/soc2_workflows.sample.json") + if err != nil { + t.Fatalf("importWorkflowsFromFile returned error: %v", err) + } + if summary.Failed != 0 { + t.Fatalf("expected no failed definitions, got %d", summary.Failed) + } + if summary.DefinitionsCreated != 2 || summary.DefinitionsUpdated != 0 { + t.Fatalf("expected 2 created definitions and 0 updated, got created=%d updated=%d", summary.DefinitionsCreated, summary.DefinitionsUpdated) + } + assertWorkflowSeedCounts(t, db) + assertWorkflowSeedDependencies(t, db) + assertWorkflowSeedControlRelationships(t, db) + assertWorkflowSeedCronCadence(t, db) + + secondSummary, err := importWorkflowsFromFile(context.Background(), db, sugar, "testdata/soc2_workflows.sample.json") + if err != nil { + t.Fatalf("second importWorkflowsFromFile returned error: %v", err) + } + if secondSummary.Failed != 0 { + t.Fatalf("expected no failed definitions on second import, got %d", secondSummary.Failed) + } + if secondSummary.DefinitionsCreated != 0 || secondSummary.DefinitionsUpdated != 2 { + t.Fatalf("expected second import to update 2 definitions, got created=%d updated=%d", secondSummary.DefinitionsCreated, secondSummary.DefinitionsUpdated) + } + assertWorkflowSeedCounts(t, db) +} + +func assertWorkflowSeedCounts(t *testing.T, db *gorm.DB) { + t.Helper() + + counts := []struct { + name string + model interface{} + expected int64 + }{ + {"workflow definitions", &workflows.WorkflowDefinition{}, 2}, + {"workflow steps", &workflows.WorkflowStepDefinition{}, 10}, + {"step dependencies", &workflows.StepDependency{}, 8}, + {"control relationships", &workflows.ControlRelationship{}, 6}, + {"workflow instances", &workflows.WorkflowInstance{}, 2}, + {"role assignments", &workflows.RoleAssignment{}, 2}, + } + + for _, tc := range counts { + var count int64 + if err := db.Model(tc.model).Count(&count).Error; err != nil { + t.Fatalf("failed to count %s: %v", tc.name, err) + } + if count != tc.expected { + t.Fatalf("expected %d %s, got %d", tc.expected, tc.name, count) + } + } +} + +func assertWorkflowSeedDependencies(t *testing.T, db *gorm.DB) { + t.Helper() + + var step workflows.WorkflowStepDefinition + if err := db.Preload("DependsOn.DependsOnStep"). + Where("name = ?", "Document deprovisioning requirements"). + First(&step).Error; err != nil { + t.Fatalf("failed to load dependent step: %v", err) + } + if len(step.DependsOn) != 1 { + t.Fatalf("expected 1 dependency for %q, got %d", step.Name, len(step.DependsOn)) + } + if step.DependsOn[0].DependsOnStep == nil { + t.Fatalf("expected dependency to resolve to a workflow step") + } + if step.DependsOn[0].DependsOnStep.Name != "Define control scope & objectives" { + t.Fatalf("expected dependency to resolve by name, got %q", step.DependsOn[0].DependsOnStep.Name) + } +} + +func assertWorkflowSeedControlRelationships(t *testing.T, db *gorm.DB) { + t.Helper() + + var relationship workflows.ControlRelationship + if err := db.Where("control_id = ?", "ctrl-cc5-2-002").First(&relationship).Error; err != nil { + t.Fatalf("failed to load control relationship: %v", err) + } + if relationship.CatalogID != "0f9d8e10-363b-4a8f-ade5-f11c0b2b1202" { + t.Fatalf("expected catalog id to pass through, got %q", relationship.CatalogID) + } + if relationship.ControlSource == "Technology control scope is defined" { + t.Fatalf("expected _title to be stripped, but it was stored as control source") + } +} + +func assertWorkflowSeedCronCadence(t *testing.T, db *gorm.DB) { + t.Helper() + + var definition workflows.WorkflowDefinition + if err := db.Where("name = ?", "Asset Disposal, Media Sanitization & Handling").First(&definition).Error; err != nil { + t.Fatalf("failed to load cron workflow definition: %v", err) + } + if definition.SuggestedCadence != "cron:0 0 9 1 1,7 *" { + t.Fatalf("expected cron cadence to be stored, got %q", definition.SuggestedCadence) + } + + var instance workflows.WorkflowInstance + if err := db.Where("name = ?", "Asset Disposal, Media Sanitization & Handling — ToDo Demo App").First(&instance).Error; err != nil { + t.Fatalf("failed to load cron workflow instance: %v", err) + } + if instance.Cadence != "cron:0 0 9 1 1,7 *" { + t.Fatalf("expected cron instance cadence to be stored, got %q", instance.Cadence) + } +} From a4ba58e0ed6d5fac59e6d9979a46b792651c858d Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:01:34 -0300 Subject: [PATCH 2/7] fix: address review feedback --- cmd/seed/workflows.go | 28 +++++++++++++++++++++++----- cmd/seed/workflows_test.go | 11 +++++++++-- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/cmd/seed/workflows.go b/cmd/seed/workflows.go index 7651e8ae..d7c31460 100644 --- a/cmd/seed/workflows.go +++ b/cmd/seed/workflows.go @@ -154,7 +154,9 @@ func importWorkflowsFromFile(ctx context.Context, db *gorm.DB, sugar *zap.Sugare }() var definitions []workflowSeedDefinition - if err := json.NewDecoder(f).Decode(&definitions); err != nil { + decoder := json.NewDecoder(f) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&definitions); err != nil { return workflowSeedSummary{}, fmt.Errorf("failed to decode input JSON: %w", err) } @@ -163,15 +165,25 @@ func importWorkflowsFromFile(ctx context.Context, db *gorm.DB, sugar *zap.Sugare func importWorkflowSeeds(ctx context.Context, db *gorm.DB, sugar *zap.SugaredLogger, definitions []workflowSeedDefinition) workflowSeedSummary { var summary workflowSeedSummary + seenKeys := make(map[string]struct{}, len(definitions)) for _, seedDef := range definitions { - if strings.TrimSpace(seedDef.Key) == "" { + key := strings.TrimSpace(seedDef.Key) + if key == "" { summary.Skipped++ if sugar != nil { sugar.Errorw("Skipping workflow definition with empty key", "name", seedDef.Name) } continue } + if _, exists := seenKeys[key]; exists { + summary.Failed++ + if sugar != nil { + sugar.Errorw("Duplicate workflow definition key in seed input", "key", key, "name", seedDef.Name) + } + continue + } + seenKeys[key] = struct{}{} var defSummary workflowSeedSummary err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -391,7 +403,9 @@ func importWorkflowSeedInstances(tx *gorm.DB, seedDef workflowSeedDefinition, de IsActive: isActive, GracePeriodDays: seedInstance.GracePeriodDays, } - preserveWorkflowInstanceSchedule(tx, instanceSvc, instance) + if err := preserveWorkflowInstanceSchedule(tx, instanceSvc, instance); err != nil { + return summary, fmt.Errorf("preserve instance schedule %q: %w", seedInstance.Name, err) + } if err := instanceSvc.ValidateInstance(instance); err != nil { return summary, fmt.Errorf("validate instance %q: %w", seedInstance.Name, err) @@ -434,19 +448,23 @@ func importWorkflowSeedInstances(tx *gorm.DB, seedDef workflowSeedDefinition, de return summary, nil } -func preserveWorkflowInstanceSchedule(tx *gorm.DB, instanceSvc *workflows.WorkflowInstanceService, instance *workflows.WorkflowInstance) { +func preserveWorkflowInstanceSchedule(tx *gorm.DB, instanceSvc *workflows.WorkflowInstanceService, instance *workflows.WorkflowInstance) error { var existing workflows.WorkflowInstance err := tx.Select("next_scheduled_at", "last_executed_at").First(&existing, "id = ?", instance.ID).Error if err == nil { instance.NextScheduledAt = existing.NextScheduledAt instance.LastExecutedAt = existing.LastExecutedAt - return + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err } if instance.Cadence != "" { nextSchedule := instanceSvc.CalculateNextSchedule(time.Now(), instance.Cadence) instance.NextScheduledAt = &nextSchedule } + return nil } func upsertWorkflowSeed(tx *gorm.DB, value interface{}) (bool, error) { diff --git a/cmd/seed/workflows_test.go b/cmd/seed/workflows_test.go index 046415a6..a5254b21 100644 --- a/cmd/seed/workflows_test.go +++ b/cmd/seed/workflows_test.go @@ -21,6 +21,13 @@ func setupWorkflowSeedTestDB(t *testing.T) *gorm.DB { t.Fatalf("failed to create test database: %v", err) } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("failed to get sql.DB handle: %v", err) + } + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(1) + for _, entity := range workflows.GetWorkflowEntities() { if err := db.AutoMigrate(entity); err != nil { t.Fatalf("failed to migrate %T: %v", entity, err) @@ -119,8 +126,8 @@ func assertWorkflowSeedControlRelationships(t *testing.T, db *gorm.DB) { if relationship.CatalogID != "0f9d8e10-363b-4a8f-ade5-f11c0b2b1202" { t.Fatalf("expected catalog id to pass through, got %q", relationship.CatalogID) } - if relationship.ControlSource == "Technology control scope is defined" { - t.Fatalf("expected _title to be stripped, but it was stored as control source") + if relationship.ControlSource != "0f9d8e10-363b-4a8f-ade5-f11c0b2b1202" { + t.Fatalf("expected control source to use catalog id, got %q", relationship.ControlSource) } } From 58390d7d6a9534ee81ade6a77e0459ae49365da9 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:25:35 -0300 Subject: [PATCH 3/7] fix: address review feedback --- cmd/seed/workflows.go | 4 ++++ cmd/seed/workflows_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/cmd/seed/workflows.go b/cmd/seed/workflows.go index d7c31460..8bdcfae8 100644 --- a/cmd/seed/workflows.go +++ b/cmd/seed/workflows.go @@ -268,6 +268,10 @@ func importWorkflowSeedSteps(tx *gorm.DB, seedDef workflowSeedDefinition, defID stepIDsByName := make(map[string]*uuid.UUID, len(seedDef.Steps)) for _, seedStep := range seedDef.Steps { + if _, exists := stepIDsByName[seedStep.Name]; exists { + return summary, fmt.Errorf("duplicate step name %q in workflow definition %q", seedStep.Name, seedDef.Key) + } + stepID := deterministicWorkflowSeedUUID("workflow-step-definition", seedDef.Key, seedStep.Name) step := &workflows.WorkflowStepDefinition{ UUIDModel: relational.UUIDModel{ diff --git a/cmd/seed/workflows_test.go b/cmd/seed/workflows_test.go index a5254b21..3f28b184 100644 --- a/cmd/seed/workflows_test.go +++ b/cmd/seed/workflows_test.go @@ -2,6 +2,7 @@ package seed import ( "context" + "strings" "testing" "github.com/compliance-framework/api/internal/service/relational/workflows" @@ -69,6 +70,35 @@ func TestImportWorkflowsFromFile(t *testing.T) { assertWorkflowSeedCounts(t, db) } +func TestImportWorkflowSeedDefinitionRejectsDuplicateStepNames(t *testing.T) { + db := setupWorkflowSeedTestDB(t) + + _, err := importWorkflowSeedDefinition(db, workflowSeedDefinition{ + Key: "duplicate-step-name-test", + Name: "Duplicate Step Name Test", + Version: "1.0.0", + Steps: []workflowSeedStep{ + { + Name: "Collect evidence", + ResponsibleRole: "control-owner", + }, + { + Name: "Collect evidence", + ResponsibleRole: "control-owner", + }, + }, + }) + if err == nil { + t.Fatal("expected duplicate step name error, got nil") + } + if !strings.Contains(err.Error(), "duplicate step name") { + t.Fatalf("expected error to contain duplicate step name, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "Collect evidence") { + t.Fatalf("expected error to contain duplicate step name value, got %q", err.Error()) + } +} + func assertWorkflowSeedCounts(t *testing.T, db *gorm.DB) { t.Helper() From aa6e75de2949074fdf375bd3f502ffcf948e305b Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:37:24 -0300 Subject: [PATCH 4/7] fix: address review feedback --- cmd/seed/workflows.go | 1 + cmd/seed/workflows_test.go | 42 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/cmd/seed/workflows.go b/cmd/seed/workflows.go index 8bdcfae8..9036176b 100644 --- a/cmd/seed/workflows.go +++ b/cmd/seed/workflows.go @@ -183,6 +183,7 @@ func importWorkflowSeeds(ctx context.Context, db *gorm.DB, sugar *zap.SugaredLog } continue } + seedDef.Key = key seenKeys[key] = struct{}{} var defSummary workflowSeedSummary diff --git a/cmd/seed/workflows_test.go b/cmd/seed/workflows_test.go index 3f28b184..d1068fd3 100644 --- a/cmd/seed/workflows_test.go +++ b/cmd/seed/workflows_test.go @@ -99,6 +99,48 @@ func TestImportWorkflowSeedDefinitionRejectsDuplicateStepNames(t *testing.T) { } } +func TestImportWorkflowSeedsTrimsKeyBeforeDeterministicIDs(t *testing.T) { + db := setupWorkflowSeedTestDB(t) + + firstSummary := importWorkflowSeeds(context.Background(), db, nil, []workflowSeedDefinition{ + { + Key: " trim-key-test ", + Name: "Trim Key Test", + Description: "first import", + Version: "1.0.0", + }, + }) + if firstSummary.Failed != 0 || firstSummary.DefinitionsCreated != 1 { + t.Fatalf("expected first import to create one definition without failures, got created=%d failed=%d", firstSummary.DefinitionsCreated, firstSummary.Failed) + } + + expectedID := deterministicWorkflowSeedUUID("workflow-definition", "trim-key-test") + var definition workflows.WorkflowDefinition + if err := db.First(&definition, "id = ?", expectedID).Error; err != nil { + t.Fatalf("expected workflow definition to use trimmed key ID: %v", err) + } + + secondSummary := importWorkflowSeeds(context.Background(), db, nil, []workflowSeedDefinition{ + { + Key: "trim-key-test", + Name: "Trim Key Test", + Description: "second import", + Version: "1.0.0", + }, + }) + if secondSummary.Failed != 0 || secondSummary.DefinitionsUpdated != 1 { + t.Fatalf("expected second import to update one definition without failures, got updated=%d failed=%d", secondSummary.DefinitionsUpdated, secondSummary.Failed) + } + + var count int64 + if err := db.Model(&workflows.WorkflowDefinition{}).Count(&count).Error; err != nil { + t.Fatalf("failed to count workflow definitions: %v", err) + } + if count != 1 { + t.Fatalf("expected re-import with canonical key to keep one workflow definition, got %d", count) + } +} + func assertWorkflowSeedCounts(t *testing.T, db *gorm.DB) { t.Helper() From 2005afa49f466c177f031b97dce9cfec51ace6d6 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:53:32 -0300 Subject: [PATCH 5/7] fix: address review feedback --- cmd/seed/workflows.go | 71 ++++++++++++++++- cmd/seed/workflows_test.go | 152 +++++++++++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+), 1 deletion(-) diff --git a/cmd/seed/workflows.go b/cmd/seed/workflows.go index 9036176b..44f5c03c 100644 --- a/cmd/seed/workflows.go +++ b/cmd/seed/workflows.go @@ -486,7 +486,15 @@ func upsertWorkflowSeed(tx *gorm.DB, value interface{}) (bool, error) { return false, err } - if err := tx.Clauses(clause.OnConflict{UpdateAll: true}).Create(value).Error; err != nil { + columns, err := workflowSeedUpdateColumns(value) + if err != nil { + return false, err + } + + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.AssignmentColumns(columns), + }).Create(value).Error; err != nil { return false, err } @@ -510,6 +518,67 @@ func workflowSeedID(value interface{}) (*uuid.UUID, error) { } } +func workflowSeedUpdateColumns(value interface{}) ([]string, error) { + switch value.(type) { + case *workflows.WorkflowDefinition: + return []string{ + "name", + "description", + "version", + "suggested_cadence", + "evidence_required", + "grace_period_days", + "updated_at", + }, nil + case *workflows.WorkflowStepDefinition: + return []string{ + "workflow_definition_id", + "name", + "description", + "order", + "responsible_role", + "evidence_required", + "estimated_duration", + "grace_period_days", + "updated_at", + }, nil + case *workflows.ControlRelationship: + return []string{ + "workflow_definition_id", + "control_id", + "control_source", + "catalog_id", + "relationship_type", + "strength", + "is_active", + "updated_at", + }, nil + case *workflows.WorkflowInstance: + return []string{ + "workflow_definition_id", + "name", + "description", + "system_security_plan_id", + "cadence", + "is_active", + "grace_period_days", + "next_scheduled_at", + "last_executed_at", + "updated_at", + }, nil + case *workflows.RoleAssignment: + return []string{ + "workflow_instance_id", + "role_name", + "assigned_to_type", + "assigned_to_id", + "is_active", + }, nil + default: + return nil, fmt.Errorf("unsupported workflow seed type %T", value) + } +} + func deterministicWorkflowSeedUUID(parts ...string) uuid.UUID { seedValue := strings.Join(append(parts, "v1"), ":") hash := sha256.Sum256([]byte(seedValue)) diff --git a/cmd/seed/workflows_test.go b/cmd/seed/workflows_test.go index d1068fd3..74981db6 100644 --- a/cmd/seed/workflows_test.go +++ b/cmd/seed/workflows_test.go @@ -4,8 +4,11 @@ import ( "context" "strings" "testing" + "time" + "github.com/compliance-framework/api/internal/service/relational" "github.com/compliance-framework/api/internal/service/relational/workflows" + "github.com/google/uuid" "go.uber.org/zap" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -141,6 +144,155 @@ func TestImportWorkflowSeedsTrimsKeyBeforeDeterministicIDs(t *testing.T) { } } +func TestUpsertWorkflowSeedPreservesWorkflowDefinitionAuditAndSoftDeleteFields(t *testing.T) { + db := setupWorkflowSeedTestDB(t) + + id := uuid.New() + createdByID := uuid.New() + updatedByID := uuid.New() + createdAt := time.Now().Add(-2 * time.Hour).UTC().Truncate(time.Second) + deletedAt := time.Now().Add(-time.Hour).UTC().Truncate(time.Second) + originalGracePeriod := 3 + updatedGracePeriod := 7 + + existing := workflows.WorkflowDefinition{ + UUIDModel: relational.UUIDModel{ID: &id}, + CreatedAt: createdAt, + DeletedAt: gorm.DeletedAt{Time: deletedAt, Valid: true}, + Name: "Original name", + Description: "Original description", + Version: "1.0.0", + SuggestedCadence: "monthly", + EvidenceRequired: "original evidence", + GracePeriodDays: &originalGracePeriod, + CreatedByID: &createdByID, + UpdatedByID: &updatedByID, + } + if err := db.Create(&existing).Error; err != nil { + t.Fatalf("failed to create existing workflow definition: %v", err) + } + + seed := workflows.WorkflowDefinition{ + UUIDModel: relational.UUIDModel{ID: &id}, + Name: "Updated name", + Description: "Updated description", + Version: "2.0.0", + SuggestedCadence: "weekly", + EvidenceRequired: "updated evidence", + GracePeriodDays: &updatedGracePeriod, + } + if _, err := upsertWorkflowSeed(db, &seed); err != nil { + t.Fatalf("upsertWorkflowSeed returned error: %v", err) + } + + var updated workflows.WorkflowDefinition + if err := db.Unscoped().First(&updated, "id = ?", id).Error; err != nil { + t.Fatalf("failed to load updated workflow definition: %v", err) + } + if updated.Name != "Updated name" || + updated.Description != "Updated description" || + updated.Version != "2.0.0" || + updated.SuggestedCadence != "weekly" || + updated.EvidenceRequired != "updated evidence" || + updated.GracePeriodDays == nil || + *updated.GracePeriodDays != updatedGracePeriod { + t.Fatalf("expected workflow definition business fields to update, got %+v", updated) + } + if !updated.DeletedAt.Valid || !updated.DeletedAt.Time.Equal(deletedAt) { + t.Fatalf("expected deleted_at to be preserved, got %+v", updated.DeletedAt) + } + if updated.CreatedByID == nil || *updated.CreatedByID != createdByID { + t.Fatalf("expected created_by_id to be preserved, got %v", updated.CreatedByID) + } + if updated.UpdatedByID == nil || *updated.UpdatedByID != updatedByID { + t.Fatalf("expected updated_by_id to be preserved, got %v", updated.UpdatedByID) + } + if !updated.CreatedAt.Equal(createdAt) { + t.Fatalf("expected created_at to be preserved, got %v", updated.CreatedAt) + } +} + +func TestUpsertWorkflowSeedPreservesWorkflowInstanceAuditAndSoftDeleteFields(t *testing.T) { + db := setupWorkflowSeedTestDB(t) + + id := uuid.New() + definitionID := uuid.New() + systemID := uuid.New() + createdByID := uuid.New() + updatedByID := uuid.New() + createdAt := time.Now().Add(-2 * time.Hour).UTC().Truncate(time.Second) + deletedAt := time.Now().Add(-time.Hour).UTC().Truncate(time.Second) + lastExecutedAt := time.Now().Add(-30 * time.Minute).UTC().Truncate(time.Second) + nextScheduledAt := time.Now().Add(30 * time.Minute).UTC().Truncate(time.Second) + originalGracePeriod := 3 + updatedGracePeriod := 7 + + existing := workflows.WorkflowInstance{ + UUIDModel: relational.UUIDModel{ID: &id}, + CreatedAt: createdAt, + DeletedAt: gorm.DeletedAt{Time: deletedAt, Valid: true}, + Name: "Original name", + Description: "Original description", + Cadence: "monthly", + IsActive: false, + GracePeriodDays: &originalGracePeriod, + NextScheduledAt: &nextScheduledAt, + LastExecutedAt: &lastExecutedAt, + CreatedByID: &createdByID, + UpdatedByID: &updatedByID, + WorkflowDefinitionID: &definitionID, + SystemSecurityPlanID: &systemID, + } + if err := db.Create(&existing).Error; err != nil { + t.Fatalf("failed to create existing workflow instance: %v", err) + } + + seed := workflows.WorkflowInstance{ + UUIDModel: relational.UUIDModel{ID: &id}, + Name: "Updated name", + Description: "Updated description", + Cadence: "weekly", + IsActive: true, + GracePeriodDays: &updatedGracePeriod, + NextScheduledAt: &nextScheduledAt, + LastExecutedAt: &lastExecutedAt, + WorkflowDefinitionID: &definitionID, + SystemSecurityPlanID: &systemID, + } + if _, err := upsertWorkflowSeed(db, &seed); err != nil { + t.Fatalf("upsertWorkflowSeed returned error: %v", err) + } + + var updated workflows.WorkflowInstance + if err := db.Unscoped().First(&updated, "id = ?", id).Error; err != nil { + t.Fatalf("failed to load updated workflow instance: %v", err) + } + if updated.Name != "Updated name" || + updated.Description != "Updated description" || + updated.Cadence != "weekly" || + !updated.IsActive || + updated.GracePeriodDays == nil || + *updated.GracePeriodDays != updatedGracePeriod || + updated.NextScheduledAt == nil || + !updated.NextScheduledAt.Equal(nextScheduledAt) || + updated.LastExecutedAt == nil || + !updated.LastExecutedAt.Equal(lastExecutedAt) { + t.Fatalf("expected workflow instance business fields to update, got %+v", updated) + } + if !updated.DeletedAt.Valid || !updated.DeletedAt.Time.Equal(deletedAt) { + t.Fatalf("expected deleted_at to be preserved, got %+v", updated.DeletedAt) + } + if updated.CreatedByID == nil || *updated.CreatedByID != createdByID { + t.Fatalf("expected created_by_id to be preserved, got %v", updated.CreatedByID) + } + if updated.UpdatedByID == nil || *updated.UpdatedByID != updatedByID { + t.Fatalf("expected updated_by_id to be preserved, got %v", updated.UpdatedByID) + } + if !updated.CreatedAt.Equal(createdAt) { + t.Fatalf("expected created_at to be preserved, got %v", updated.CreatedAt) + } +} + func assertWorkflowSeedCounts(t *testing.T, db *gorm.DB) { t.Helper() From c839bbed109359b30213fc26e00078d1657150d8 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:00:42 -0300 Subject: [PATCH 6/7] fix: address review feedback --- cmd/seed/workflows.go | 2 +- cmd/seed/workflows_test.go | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/cmd/seed/workflows.go b/cmd/seed/workflows.go index 44f5c03c..70420696 100644 --- a/cmd/seed/workflows.go +++ b/cmd/seed/workflows.go @@ -482,7 +482,7 @@ func upsertWorkflowSeed(tx *gorm.DB, value interface{}) (bool, error) { } var count int64 - if err := tx.Model(value).Where("id = ?", id).Count(&count).Error; err != nil { + if err := tx.Unscoped().Model(value).Where("id = ?", id).Count(&count).Error; err != nil { return false, err } diff --git a/cmd/seed/workflows_test.go b/cmd/seed/workflows_test.go index 74981db6..3f5db993 100644 --- a/cmd/seed/workflows_test.go +++ b/cmd/seed/workflows_test.go @@ -181,9 +181,13 @@ func TestUpsertWorkflowSeedPreservesWorkflowDefinitionAuditAndSoftDeleteFields(t EvidenceRequired: "updated evidence", GracePeriodDays: &updatedGracePeriod, } - if _, err := upsertWorkflowSeed(db, &seed); err != nil { + created, err := upsertWorkflowSeed(db, &seed) + if err != nil { t.Fatalf("upsertWorkflowSeed returned error: %v", err) } + if created { + t.Fatal("expected soft-deleted workflow definition upsert to update an existing physical row") + } var updated workflows.WorkflowDefinition if err := db.Unscoped().First(&updated, "id = ?", id).Error; err != nil { @@ -259,9 +263,13 @@ func TestUpsertWorkflowSeedPreservesWorkflowInstanceAuditAndSoftDeleteFields(t * WorkflowDefinitionID: &definitionID, SystemSecurityPlanID: &systemID, } - if _, err := upsertWorkflowSeed(db, &seed); err != nil { + created, err := upsertWorkflowSeed(db, &seed) + if err != nil { t.Fatalf("upsertWorkflowSeed returned error: %v", err) } + if created { + t.Fatal("expected soft-deleted workflow instance upsert to update an existing physical row") + } var updated workflows.WorkflowInstance if err := db.Unscoped().First(&updated, "id = ?", id).Error; err != nil { From b159cd9437f08229079db843f68211e4c36e35cd Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:17:33 -0300 Subject: [PATCH 7/7] fix: address review feedback --- cmd/seed/workflows.go | 4 +- cmd/seed/workflows_test.go | 112 +++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/cmd/seed/workflows.go b/cmd/seed/workflows.go index 70420696..37e8fae1 100644 --- a/cmd/seed/workflows.go +++ b/cmd/seed/workflows.go @@ -44,6 +44,7 @@ type workflowSeedStep struct { ResponsibleRole string `json:"responsible-role"` EvidenceRequired []workflows.EvidenceRequirement `json:"evidence-required"` EstimatedDuration int `json:"estimated-duration"` + GracePeriodDays *int `json:"grace-period-days"` DependsOn []string `json:"depends-on"` } @@ -285,6 +286,7 @@ func importWorkflowSeedSteps(tx *gorm.DB, seedDef workflowSeedDefinition, defID ResponsibleRole: seedStep.ResponsibleRole, EvidenceRequired: datatypes.NewJSONSlice(seedStep.EvidenceRequired), EstimatedDuration: seedStep.EstimatedDuration, + GracePeriodDays: seedStep.GracePeriodDays, } if err := stepSvc.ValidateStep(step); err != nil { return summary, fmt.Errorf("validate step %q: %w", seedStep.Name, err) @@ -455,7 +457,7 @@ func importWorkflowSeedInstances(tx *gorm.DB, seedDef workflowSeedDefinition, de func preserveWorkflowInstanceSchedule(tx *gorm.DB, instanceSvc *workflows.WorkflowInstanceService, instance *workflows.WorkflowInstance) error { var existing workflows.WorkflowInstance - err := tx.Select("next_scheduled_at", "last_executed_at").First(&existing, "id = ?", instance.ID).Error + err := tx.Unscoped().Select("next_scheduled_at", "last_executed_at").First(&existing, "id = ?", instance.ID).Error if err == nil { instance.NextScheduledAt = existing.NextScheduledAt instance.LastExecutedAt = existing.LastExecutedAt diff --git a/cmd/seed/workflows_test.go b/cmd/seed/workflows_test.go index 3f5db993..9b55fcd0 100644 --- a/cmd/seed/workflows_test.go +++ b/cmd/seed/workflows_test.go @@ -144,6 +144,118 @@ func TestImportWorkflowSeedsTrimsKeyBeforeDeterministicIDs(t *testing.T) { } } +func TestImportWorkflowSeedStepGracePeriodDays(t *testing.T) { + db := setupWorkflowSeedTestDB(t) + + initialGracePeriod := 5 + updatedGracePeriod := 9 + seedDef := workflowSeedDefinition{ + Key: "step-grace-period-test", + Name: "Step Grace Period Test", + Version: "1.0.0", + Steps: []workflowSeedStep{ + { + Name: "Review evidence", + Order: 1, + ResponsibleRole: "control-owner", + GracePeriodDays: &initialGracePeriod, + }, + }, + } + if _, err := importWorkflowSeedDefinition(db, seedDef); err != nil { + t.Fatalf("importWorkflowSeedDefinition returned error: %v", err) + } + + stepID := deterministicWorkflowSeedUUID("workflow-step-definition", seedDef.Key, "Review evidence") + var step workflows.WorkflowStepDefinition + if err := db.First(&step, "id = ?", stepID).Error; err != nil { + t.Fatalf("failed to load workflow step definition: %v", err) + } + if step.GracePeriodDays == nil || *step.GracePeriodDays != initialGracePeriod { + t.Fatalf("expected initial step grace period %d, got %v", initialGracePeriod, step.GracePeriodDays) + } + + seedDef.Steps[0].GracePeriodDays = &updatedGracePeriod + if _, err := importWorkflowSeedDefinition(db, seedDef); err != nil { + t.Fatalf("second importWorkflowSeedDefinition returned error: %v", err) + } + if err := db.First(&step, "id = ?", stepID).Error; err != nil { + t.Fatalf("failed to reload workflow step definition: %v", err) + } + if step.GracePeriodDays == nil || *step.GracePeriodDays != updatedGracePeriod { + t.Fatalf("expected updated step grace period %d, got %v", updatedGracePeriod, step.GracePeriodDays) + } +} + +func TestImportWorkflowSeedPreservesSoftDeletedInstanceSchedule(t *testing.T) { + db := setupWorkflowSeedTestDB(t) + + seedKey := "soft-deleted-instance-schedule-test" + instanceName := "Soft Deleted Instance" + defID := deterministicWorkflowSeedUUID("workflow-definition", seedKey) + instanceID := deterministicWorkflowSeedUUID("workflow-instance", seedKey, instanceName) + systemID := uuid.New() + deletedAt := time.Now().Add(-time.Hour).UTC().Truncate(time.Second) + nextScheduledAt := time.Now().Add(24 * time.Hour).UTC().Truncate(time.Second) + lastExecutedAt := time.Now().Add(-24 * time.Hour).UTC().Truncate(time.Second) + + existingDefinition := workflows.WorkflowDefinition{ + UUIDModel: relational.UUIDModel{ID: &defID}, + Name: "Soft Deleted Instance Schedule Test", + Version: "1.0.0", + } + if err := db.Create(&existingDefinition).Error; err != nil { + t.Fatalf("failed to create existing workflow definition: %v", err) + } + + existingInstance := workflows.WorkflowInstance{ + UUIDModel: relational.UUIDModel{ID: &instanceID}, + DeletedAt: gorm.DeletedAt{Time: deletedAt, Valid: true}, + Name: instanceName, + Description: "Original description", + Cadence: "monthly", + IsActive: true, + NextScheduledAt: &nextScheduledAt, + LastExecutedAt: &lastExecutedAt, + WorkflowDefinitionID: &defID, + SystemSecurityPlanID: &systemID, + } + if err := db.Create(&existingInstance).Error; err != nil { + t.Fatalf("failed to create existing workflow instance: %v", err) + } + + _, err := importWorkflowSeedDefinition(db, workflowSeedDefinition{ + Key: seedKey, + Name: "Soft Deleted Instance Schedule Test", + Version: "1.0.0", + Instances: []workflowSeedInstance{ + { + Name: instanceName, + Description: "Updated description", + SystemID: systemID.String(), + Cadence: "weekly", + }, + }, + }) + if err != nil { + t.Fatalf("importWorkflowSeedDefinition returned error: %v", err) + } + + var updated workflows.WorkflowInstance + if err := db.Unscoped().First(&updated, "id = ?", instanceID).Error; err != nil { + t.Fatalf("failed to load workflow instance: %v", err) + } + if !updated.DeletedAt.Valid || !updated.DeletedAt.Time.Equal(deletedAt) { + t.Fatalf("expected deleted_at to be preserved, got %+v", updated.DeletedAt) + } + if updated.NextScheduledAt == nil || !updated.NextScheduledAt.Equal(nextScheduledAt) { + t.Fatalf("expected next_scheduled_at to be preserved, got %v", updated.NextScheduledAt) + } + if updated.LastExecutedAt == nil || !updated.LastExecutedAt.Equal(lastExecutedAt) { + t.Fatalf("expected last_executed_at to be preserved, got %v", updated.LastExecutedAt) + } +} + func TestUpsertWorkflowSeedPreservesWorkflowDefinitionAuditAndSoftDeleteFields(t *testing.T) { db := setupWorkflowSeedTestDB(t)