Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/seed/workflows.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,10 @@ func importWorkflowSeedDefinition(tx *gorm.DB, seedDef workflowSeedDefinition) (
}
summary.ControlRelationships += relationshipSummary.ControlRelationships

if err := workflows.NewFilterSyncService(tx, nil).SyncFilterForDefinition(defID); err != nil {
return summary, fmt.Errorf("sync workflow filter for seed definition %q: %w", seedDef.Key, err)
}

instanceSummary, err := importWorkflowSeedInstances(tx, seedDef, &defID)
if err != nil {
return summary, err
Expand Down
50 changes: 50 additions & 0 deletions cmd/seed/workflows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,17 @@ func setupWorkflowSeedTestDB(t *testing.T) *gorm.DB {
t.Fatalf("failed to migrate %T: %v", entity, err)
}
}
if err := db.AutoMigrate(&relational.Control{}, &relational.Filter{}); err != nil {
t.Fatalf("failed to migrate filter entities: %v", err)
}

return db
}

func TestImportWorkflowsFromFile(t *testing.T) {
db := setupWorkflowSeedTestDB(t)
sugar := zap.NewNop().Sugar()
seedWorkflowFilterControl(t, db)

summary, err := importWorkflowsFromFile(context.Background(), db, sugar, "testdata/soc2_workflows.sample.json")
if err != nil {
Expand All @@ -58,6 +62,7 @@ func TestImportWorkflowsFromFile(t *testing.T) {
assertWorkflowSeedCounts(t, db)
assertWorkflowSeedDependencies(t, db)
assertWorkflowSeedControlRelationships(t, db)
assertWorkflowSeedFilterControl(t, db)
assertWorkflowSeedCronCadence(t, db)

secondSummary, err := importWorkflowsFromFile(context.Background(), db, sugar, "testdata/soc2_workflows.sample.json")
Expand All @@ -71,6 +76,7 @@ func TestImportWorkflowsFromFile(t *testing.T) {
t.Fatalf("expected second import to update 2 definitions, got created=%d updated=%d", secondSummary.DefinitionsCreated, secondSummary.DefinitionsUpdated)
}
assertWorkflowSeedCounts(t, db)
assertWorkflowSeedFilterControl(t, db)
}

func TestImportWorkflowSeedDefinitionRejectsDuplicateStepNames(t *testing.T) {
Expand Down Expand Up @@ -475,6 +481,50 @@ func assertWorkflowSeedControlRelationships(t *testing.T, db *gorm.DB) {
}
}

func seedWorkflowFilterControl(t *testing.T, db *gorm.DB) {
t.Helper()

catalogID := uuid.MustParse("0f9d8e10-363b-4a8f-ade5-f11c0b2b1202")
control := relational.Control{
CatalogID: catalogID,
ID: "ctrl-cc5-2-002",
Title: "Technology control scope is defined",
}
if err := db.Create(&control).Error; err != nil {
t.Fatalf("failed to seed workflow filter control: %v", err)
}
}

func assertWorkflowSeedFilterControl(t *testing.T, db *gorm.DB) {
t.Helper()

var filters []relational.Filter
if err := db.Preload("Controls").
Where("name = ?", "Workflow: Technology Controls Governance & Independent Review").
Find(&filters).Error; err != nil {
t.Fatalf("failed to load workflow filter: %v", err)
}
if len(filters) != 1 {
t.Fatalf("expected one deterministic workflow filter, got %d", len(filters))
}

if len(filters[0].Controls) != 1 {
t.Fatalf("expected workflow filter to have one resolved control, got %d", len(filters[0].Controls))
}
control := filters[0].Controls[0]
if control.CatalogID != uuid.MustParse("0f9d8e10-363b-4a8f-ade5-f11c0b2b1202") || control.ID != "ctrl-cc5-2-002" {
t.Fatalf("expected workflow filter control ctrl-cc5-2-002 in SOC 2 catalog, got catalog=%s control=%s", control.CatalogID, control.ID)
}

var joinCount int64
if err := db.Table("filter_controls").Count(&joinCount).Error; err != nil {
t.Fatalf("failed to count filter controls: %v", err)
}
if joinCount != 1 {
t.Fatalf("expected repeated import to keep one filter control association, got %d", joinCount)
}
}

func assertWorkflowSeedCronCadence(t *testing.T, db *gorm.DB) {
t.Helper()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//go:build integration

package oscal

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"time"

"github.com/compliance-framework/api/internal/service/relational"
"github.com/compliance-framework/api/internal/service/relational/workflows"
workflowevidence "github.com/compliance-framework/api/internal/workflow"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)

func (suite *ProfileIntegrationSuite) TestComplianceProgressIncludesWorkflowCompletionEvidence() {
suite.Require().NoError(suite.Migrator.Refresh())
suite.Require().NoError(suite.DB.AutoMigrate(
&workflows.WorkflowDefinition{},
Comment thread
ccf-lisa[bot] marked this conversation as resolved.
&workflows.WorkflowInstance{},
&workflows.RoleAssignment{},
&workflows.WorkflowExecution{},
&workflows.WorkflowStepDefinition{},
&workflows.StepExecution{},
&workflows.ControlRelationship{},
))

catalogID := uuid.New()
control := relational.Control{CatalogID: catalogID, ID: "ctrl-workflow", Title: "Workflow Control"}
suite.Require().NoError(suite.DB.Create(&control).Error)

profileID := uuid.New()
profile := relational.Profile{
UUIDModel: relational.UUIDModel{ID: &profileID},
Metadata: relational.Metadata{Title: "Workflow Profile"},
}
suite.Require().NoError(suite.DB.Create(&profile).Error)
suite.Require().NoError(suite.DB.Model(&profile).Association("Controls").Append(&control))

definition := workflows.WorkflowDefinition{
Name: "Workflow Review",
Version: "1.0",
SuggestedCadence: string(workflows.CadenceWeekly),
}
suite.Require().NoError(suite.DB.Create(&definition).Error)

relationship := workflows.ControlRelationship{
WorkflowDefinitionID: definition.ID,
ControlID: control.ID,
ControlSource: "Test Catalog",
CatalogID: catalogID.String(),
RelationshipType: "satisfies",
Strength: "primary",
IsActive: true,
}
suite.Require().NoError(suite.DB.Create(&relationship).Error)
suite.Require().NoError(workflows.NewFilterSyncService(suite.DB, zap.NewNop().Sugar()).SyncFilterForDefinition(*definition.ID))

sspID := uuid.New()
instance := workflows.WorkflowInstance{
WorkflowDefinitionID: definition.ID,
Name: "Workflow Instance",
Cadence: string(workflows.CadenceWeekly),
SystemSecurityPlanID: &sspID,
}
suite.Require().NoError(suite.DB.Create(&instance).Error)

startedAt := time.Now().Add(-time.Hour)
completedAt := time.Now()
execution := workflows.WorkflowExecution{
WorkflowInstanceID: instance.ID,
Status: workflows.WorkflowStatusCompleted.String(),
TriggeredBy: "manual",
StartedAt: &startedAt,
CompletedAt: &completedAt,
}
suite.Require().NoError(suite.DB.Create(&execution).Error)
suite.Require().NoError(workflowevidence.NewEvidenceIntegration(suite.DB, zap.NewNop().Sugar()).AddExecutionCompletionEvidence(context.Background(), execution.ID))

e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/profiles/"+profileID.String()+"/compliance-progress", nil)
rec := httptest.NewRecorder()
ctx := e.NewContext(req, rec)
ctx.SetParamNames("id")
ctx.SetParamValues(profileID.String())

suite.Require().NoError(NewProfileHandler(zap.NewNop().Sugar(), suite.DB).ComplianceProgress(ctx))
suite.Require().Equal(http.StatusOK, rec.Code)

var response struct {
Data ProfileComplianceProgress `json:"data"`
}
suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &response))
suite.Require().Equal(1, response.Data.Summary.TotalControls)
suite.Require().Equal(1, response.Data.Summary.Satisfied)
suite.Require().Len(response.Data.Controls, 1)
suite.Require().Equal("satisfied", response.Data.Controls[0].ComputedStatus)

laterStartedAt := completedAt.Add(time.Hour)
nextExecution := workflows.WorkflowExecution{
WorkflowInstanceID: instance.ID,
Status: workflows.WorkflowStatusPending.String(),
TriggeredBy: "manual",
StartedAt: &laterStartedAt,
}
suite.Require().NoError(suite.DB.Create(&nextExecution).Error)
suite.Require().NoError(workflowevidence.NewEvidenceIntegration(suite.DB, zap.NewNop().Sugar()).AddWorkflowExecutionEvidence(context.Background(), nextExecution.ID, "started"))

req = httptest.NewRequest(http.MethodGet, "/profiles/"+profileID.String()+"/compliance-progress", nil)
rec = httptest.NewRecorder()
ctx = e.NewContext(req, rec)
ctx.SetParamNames("id")
ctx.SetParamValues(profileID.String())

suite.Require().NoError(NewProfileHandler(zap.NewNop().Sugar(), suite.DB).ComplianceProgress(ctx))
suite.Require().Equal(http.StatusOK, rec.Code)

suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &response))
suite.Require().Equal(1, response.Data.Summary.TotalControls)
suite.Require().Equal(1, response.Data.Summary.Satisfied)
suite.Require().Len(response.Data.Controls, 1)
suite.Require().Equal("satisfied", response.Data.Controls[0].ComputedStatus)
}
2 changes: 2 additions & 0 deletions internal/api/handler/workflows/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ func setupTestDB(t *testing.T) *gorm.DB {
err = db.AutoMigrate(
&relational.Metadata{},
&relational.Catalog{},
&relational.Control{},
&relational.Filter{},
&relational.User{},
&relational.BackMatterResource{},
&relational.BackMatter{},
Expand Down
97 changes: 66 additions & 31 deletions internal/api/handler/workflows/control_relationship.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,13 @@ func (h *ControlRelationshipHandler) Create(ctx echo.Context) error {
relationship.IsActive = *req.IsActive
}

if err := h.service.Create(relationship); err != nil {
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := workflows.NewControlRelationshipService(tx).Create(relationship); err != nil {
return err
}
return workflows.NewFilterSyncService(tx, h.sugar).SyncFilterForDefinition(*relationship.WorkflowDefinitionID)
})
if err != nil {
return h.HandleServiceError(ctx, err, "create", "control relationship")
}

Expand Down Expand Up @@ -225,16 +231,24 @@ func (h *ControlRelationshipHandler) Update(ctx echo.Context) error {
updates["strength"] = *req.Strength
}

// Use DB directly for partial updates
if len(updates) > 0 {
if err := h.db.Model(&workflows.ControlRelationship{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return h.HandleServiceError(ctx, err, "update", "control relationship")
var relationship *workflows.ControlRelationship
err = h.db.Transaction(func(tx *gorm.DB) error {
if len(updates) > 0 {
if err := tx.Model(&workflows.ControlRelationship{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return err
}
}
}

relationship, err := h.service.GetByID(id)
relationshipSvc := workflows.NewControlRelationshipService(tx)
var err error
relationship, err = relationshipSvc.GetByID(id)
if err != nil {
return err
}
return workflows.NewFilterSyncService(tx, h.sugar).SyncFilterForDefinition(*relationship.WorkflowDefinitionID)
})
if err != nil {
return h.HandleServiceError(ctx, err, "get", "control relationship after update")
return h.HandleServiceError(ctx, err, "update", "control relationship")
}

h.sugar.Infow("Control relationship updated", "id", id)
Expand All @@ -260,7 +274,18 @@ func (h *ControlRelationshipHandler) Delete(ctx echo.Context) error {
return HandleError(err)
}

if err := h.service.Delete(id); err != nil {
err = h.db.Transaction(func(tx *gorm.DB) error {
relationshipSvc := workflows.NewControlRelationshipService(tx)
relationship, err := relationshipSvc.GetByID(id)
if err != nil {
return err
}
if err := relationshipSvc.Delete(id); err != nil {
return err
}
return workflows.NewFilterSyncService(tx, h.sugar).SyncFilterForDefinition(*relationship.WorkflowDefinitionID)
})
if err != nil {
return h.HandleServiceError(ctx, err, "delete", "control relationship")
}

Expand Down Expand Up @@ -288,21 +313,26 @@ func (h *ControlRelationshipHandler) Activate(ctx echo.Context) error {
return HandleError(err)
}

// Check if relationship exists first
_, err = h.service.GetByID(id)
var relationship *workflows.ControlRelationship
err = h.db.Transaction(func(tx *gorm.DB) error {
relationshipSvc := workflows.NewControlRelationshipService(tx)
existing, err := relationshipSvc.GetByID(id)
if err != nil {
return err
}
if err := relationshipSvc.Activate(id); err != nil {
return err
}
relationship, err = relationshipSvc.GetByID(id)
if err != nil {
return err
}
return workflows.NewFilterSyncService(tx, h.sugar).SyncFilterForDefinition(*existing.WorkflowDefinitionID)
})
if err != nil {
return h.HandleServiceError(ctx, err, "get", "control relationship")
}

if err := h.service.Activate(id); err != nil {
return h.HandleServiceError(ctx, err, "activate", "control relationship")
}

relationship, err := h.service.GetByID(id)
if err != nil {
return h.HandleServiceError(ctx, err, "get", "control relationship after activation")
}

h.sugar.Infow("Control relationship activated", "id", id)
return h.RespondOK(ctx, ControlRelationshipResponse{Data: relationship})
}
Expand All @@ -327,21 +357,26 @@ func (h *ControlRelationshipHandler) Deactivate(ctx echo.Context) error {
return HandleError(err)
}

// Check if relationship exists first
_, err = h.service.GetByID(id)
var relationship *workflows.ControlRelationship
err = h.db.Transaction(func(tx *gorm.DB) error {
relationshipSvc := workflows.NewControlRelationshipService(tx)
existing, err := relationshipSvc.GetByID(id)
if err != nil {
return err
}
if err := relationshipSvc.Deactivate(id); err != nil {
return err
}
relationship, err = relationshipSvc.GetByID(id)
if err != nil {
return err
}
return workflows.NewFilterSyncService(tx, h.sugar).SyncFilterForDefinition(*existing.WorkflowDefinitionID)
})
if err != nil {
return h.HandleServiceError(ctx, err, "get", "control relationship")
}

if err := h.service.Deactivate(id); err != nil {
return h.HandleServiceError(ctx, err, "deactivate", "control relationship")
}

relationship, err := h.service.GetByID(id)
if err != nil {
return h.HandleServiceError(ctx, err, "get", "control relationship after deactivation")
}

h.sugar.Infow("Control relationship deactivated", "id", id)
return h.RespondOK(ctx, ControlRelationshipResponse{Data: relationship})
}
Loading
Loading