From 126d7b87cb88d4e5ac4bc1dc2998ae2c3dcfc9f0 Mon Sep 17 00:00:00 2001 From: James Salt Date: Thu, 9 Jul 2026 15:27:24 +0100 Subject: [PATCH 1/2] BCH-1340: Auto-generate downstream risks for non-compliant leveraged responsibilities (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-compliant evidence on a downstream customer-responsibility now auto-generates a downstream-local risk attached to that responsibility, reusing the existing evidence-to-Risk-Template worker: - New risk_responsibility_links(risk_id, responsibility_uuid) table. - New RiskSourceTypeInheritedResponsibility = "inherited-responsibility". - resolveSSPsViaFilters now returns responsibility matches separately from control matches, since they feed a structurally different risk (dedupe-keyed on the responsibility uuid, linked via risk_responsibility_links, one risk per SSP+responsibility rather than per SSP+template). - createOrUpdateRisksForResponsibilities creates/dedupes/reopens these risks, mirroring createNewRiskForSSP. Auto-remediation on satisfied evidence requires no changes to handleEvidenceResolution — it already operates generically on any risk with a risk_evidence_links row. - Promote-to-POA&M works unchanged (verified, not modified). Upstream risk stays upstream: a filter_controls-only match never creates an inherited-responsibility risk anywhere. --- internal/authz/manifest.yaml | 2 +- internal/service/migrator.go | 2 + internal/service/relational/risks/links.go | 15 + internal/service/relational/risks/models.go | 9 +- .../service/worker/risk_evidence_worker.go | 287 ++++++++++++++++-- ...herited_responsibility_integration_test.go | 211 +++++++++++++ ..._worker_responsibility_integration_test.go | 12 +- .../worker/risk_evidence_worker_test.go | 283 ++++++++++++++++- internal/tests/migrate.go | 2 + 9 files changed, 778 insertions(+), 45 deletions(-) create mode 100644 internal/service/worker/risk_evidence_worker_inherited_responsibility_integration_test.go diff --git a/internal/authz/manifest.yaml b/internal/authz/manifest.yaml index d2ead389..ebaced3a 100644 --- a/internal/authz/manifest.yaml +++ b/internal/authz/manifest.yaml @@ -143,7 +143,7 @@ resources: ssp_id: uuid # C0 on /system-security-plans/:sspId/risks, else C1 owned_by: set # PrimaryOwnerUserID — matches subject.user_uuid status: string - source_type: string # manual | evidence-auto | oscal-import | risk-promotion + source_type: string # manual | evidence-auto | oscal-import | risk-promotion | inherited-responsibility poam_item: actions: [read, create, update, delete] attributes: diff --git a/internal/service/migrator.go b/internal/service/migrator.go index 7084ad86..75c29343 100644 --- a/internal/service/migrator.go +++ b/internal/service/migrator.go @@ -122,6 +122,7 @@ func MigrateUpWithConfig(db *gorm.DB, cfg *config.Config) error { &riskrel.RiskEvidenceLink{}, &riskrel.RiskControlLink{}, &riskrel.RiskComponentLink{}, + &riskrel.RiskResponsibilityLink{}, &riskrel.RiskSubjectLink{}, &riskrel.RiskOwnerAssignment{}, &riskrel.RiskThreatRef{}, @@ -850,6 +851,7 @@ func MigrateDown(db *gorm.DB) error { &riskrel.RiskEvidenceLink{}, &riskrel.RiskControlLink{}, &riskrel.RiskComponentLink{}, + &riskrel.RiskResponsibilityLink{}, &riskrel.RiskSubjectLink{}, &riskrel.RiskOwnerAssignment{}, &riskrel.RiskThreatRef{}, diff --git a/internal/service/relational/risks/links.go b/internal/service/relational/risks/links.go index 34b02a46..f4d6afdb 100644 --- a/internal/service/relational/risks/links.go +++ b/internal/service/relational/risks/links.go @@ -45,6 +45,21 @@ func (RiskComponentLink) TableName() string { return "risk_component_links" } +type RiskResponsibilityLink struct { + RiskID uuid.UUID `json:"riskId" gorm:"type:uuid;primaryKey"` + // ResponsibilityUUID is the upstream ControlImplementationResponsibility uuid + // (BCH-1340) — resolved via the filter_responsibilities -> ssp_leverage_links arm in + // risk_evidence_worker.go, not a local catalog control. + ResponsibilityUUID uuid.UUID `json:"responsibilityUuid" gorm:"type:uuid;primaryKey;index"` + CreatedAt time.Time `json:"createdAt"` + CreatedByID *uuid.UUID `json:"createdById" gorm:"type:uuid;index"` + Risk *Risk `json:"-" gorm:"foreignKey:RiskID;references:ID;constraint:OnDelete:CASCADE"` +} + +func (RiskResponsibilityLink) TableName() string { + return "risk_responsibility_links" +} + type RiskSubjectLink struct { RiskID uuid.UUID `json:"riskId" gorm:"type:uuid;primaryKey"` SubjectID uuid.UUID `json:"subjectId" gorm:"type:uuid;primaryKey;index"` diff --git a/internal/service/relational/risks/models.go b/internal/service/relational/risks/models.go index 631801df..1926219b 100644 --- a/internal/service/relational/risks/models.go +++ b/internal/service/relational/risks/models.go @@ -89,14 +89,15 @@ func RiskLevelFilterValues(raw string) []string { type RiskSourceType string const ( - RiskSourceTypeManual RiskSourceType = "manual" - RiskSourceTypeEvidenceAuto RiskSourceType = "evidence-auto" - RiskSourceTypeOscalImport RiskSourceType = "oscal-import" + RiskSourceTypeManual RiskSourceType = "manual" + RiskSourceTypeEvidenceAuto RiskSourceType = "evidence-auto" + RiskSourceTypeOscalImport RiskSourceType = "oscal-import" + RiskSourceTypeInheritedResponsibility RiskSourceType = "inherited-responsibility" ) func (s RiskSourceType) IsValid() bool { switch s { - case RiskSourceTypeManual, RiskSourceTypeEvidenceAuto, RiskSourceTypeOscalImport: + case RiskSourceTypeManual, RiskSourceTypeEvidenceAuto, RiskSourceTypeOscalImport, RiskSourceTypeInheritedResponsibility: return true default: return false diff --git a/internal/service/worker/risk_evidence_worker.go b/internal/service/worker/risk_evidence_worker.go index d009ca05..f1fedfa1 100644 --- a/internal/service/worker/risk_evidence_worker.go +++ b/internal/service/worker/risk_evidence_worker.go @@ -33,6 +33,17 @@ type controlLinkInfo struct { ControlID string } +// resolvedResponsibilityInfo groups a resolved downstream SSP with the specific upstream +// responsibility uuid a filter_responsibilities row matched (BCH-1340). Kept separate +// from resolvedSSPInfo/controlLinkInfo: a responsibility match needs a structurally +// different risk (dedupe-keyed on the responsibility uuid, linked via +// risk_responsibility_links, one risk per (SSP, responsibility) pair rather than one per +// SSP+template), not a control-linked risk on the resolved SSP. +type resolvedResponsibilityInfo struct { + SSPID uuid.UUID + ResponsibilityUUID uuid.UUID +} + // RiskEvidenceWorker handles processing evidence and creating risks type RiskEvidenceWorker struct { db *gorm.DB @@ -95,13 +106,13 @@ func (w *RiskEvidenceWorker) Work(ctx context.Context, job *river.Job[RiskProces } // 5. Resolve SSPs via filter matching (replaces component-based resolution) - sspInfos, err := w.resolveSSPsViaFilters(ctx, evidence.Labels) + sspInfos, responsibilityInfos, err := w.resolveSSPsViaFilters(ctx, evidence.Labels) if err != nil { w.logger.Errorw("Failed to resolve SSPs via filters", "error", err, "evidence_id", args.EvidenceID) return err } - if len(sspInfos) == 0 { + if len(sspInfos) == 0 && len(responsibilityInfos) == 0 { w.logger.Infow("No SSPs resolved via filters for evidence", "evidence_id", args.EvidenceID) return nil } @@ -113,7 +124,9 @@ func (w *RiskEvidenceWorker) Work(ctx context.Context, job *river.Job[RiskProces return err } - // 7. Create/update risks for each template × SSP + // 7. Create/update risks for each template × SSP, and each template × resolved + // responsibility (BCH-1340) — the latter creates a downstream-local + // inherited-responsibility risk instead of a control-linked one. var errs []error for _, riskTemplate := range filteredRiskTemplates { if err := w.createOrUpdateRisksForSSPs(ctx, riskTemplate, evidence, sspInfos); err != nil { @@ -123,6 +136,13 @@ func (w *RiskEvidenceWorker) Work(ctx context.Context, job *river.Job[RiskProces "risk_template_id", riskTemplate.ID) errs = append(errs, err) } + if err := w.createOrUpdateRisksForResponsibilities(ctx, riskTemplate, evidence, responsibilityInfos); err != nil { + w.logger.Errorw("Failed to create or update risks for responsibilities", + "error", err, + "evidence_id", args.EvidenceID, + "risk_template_id", riskTemplate.ID) + errs = append(errs, err) + } } if len(errs) > 0 { @@ -133,15 +153,19 @@ func (w *RiskEvidenceWorker) Work(ctx context.Context, job *river.Job[RiskProces "evidence_id", args.EvidenceID, "risk_templates", len(filteredRiskTemplates), "ssps", len(sspInfos), + "responsibilities", len(responsibilityInfos), ) return nil } // resolveSSPsViaFilters resolves SSPs by evaluating evidence labels against all filters in-memory, -// then querying the DB for the filter→control→SSP path (and, for BCH-1339, the -// filter→responsibility→leverage-link→downstream-SSP path). -func (w *RiskEvidenceWorker) resolveSSPsViaFilters(ctx context.Context, evidenceLabels []relational.Labels) ([]resolvedSSPInfo, error) { +// then querying the DB for the filter→control→SSP path and, separately (BCH-1339/1340), +// the filter→responsibility→leverage-link→downstream-SSP path. The two paths are +// returned separately because they feed structurally different risks: a control match +// feeds a risk linked via risk_control_links, a responsibility match feeds a risk +// dedupe-keyed on the responsibility uuid and linked via risk_responsibility_links. +func (w *RiskEvidenceWorker) resolveSSPsViaFilters(ctx context.Context, evidenceLabels []relational.Labels) ([]resolvedSSPInfo, []resolvedResponsibilityInfo, error) { // 1. Load only filters that have at least one control or responsibility linked (via // filter_controls or filter_responsibilities). Filters with neither can never resolve // to an SSP, so loading them is wasteful. @@ -149,11 +173,11 @@ func (w *RiskEvidenceWorker) resolveSSPsViaFilters(ctx context.Context, evidence if err := w.db.WithContext(ctx). Where("id IN (SELECT DISTINCT filter_id FROM filter_controls) OR id IN (SELECT DISTINCT filter_id FROM filter_responsibilities)"). Find(&filters).Error; err != nil { - return nil, fmt.Errorf("failed to load filters: %w", err) + return nil, nil, fmt.Errorf("failed to load filters: %w", err) } if len(filters) == 0 { - return nil, nil + return nil, nil, nil } // 2. Normalize evidence labels into map[string][]string @@ -181,7 +205,7 @@ func (w *RiskEvidenceWorker) resolveSSPsViaFilters(ctx context.Context, evidence } if len(matchingFilterIDs) == 0 { - return nil, nil + return nil, nil, nil } w.logger.Debugw("Filters matched evidence labels", @@ -221,7 +245,7 @@ func (w *RiskEvidenceWorker) resolveSSPsViaFilters(ctx context.Context, evidence // another SSP that merely shares the control id in its profile. Where("f.ssp_id IS NULL OR f.ssp_id = ssp.id"). Scan(&rows).Error; err != nil { - return nil, fmt.Errorf("failed to query SSPs via filter controls: %w", err) + return nil, nil, fmt.Errorf("failed to query SSPs via filter controls: %w", err) } // 5. Group by SSP ID @@ -247,17 +271,19 @@ func (w *RiskEvidenceWorker) resolveSSPsViaFilters(ctx context.Context, evidence // provided-uuid and find responsibilities; here we start from a responsibility-uuid // and find its provided-uuid. filter_responsibilities.ssp_id must match the leverage // link's own downstream_ssp_id: the same upstream provided-uuid can be leveraged by - // several different downstream SSPs, and ssp_id is what picks the right one. - // Unlike the control arm, there's no catalog control being targeted here, so resolved - // SSPs get no ControlLinks — createRiskLinks already treats that as optional. + // several different downstream SSPs, and ssp_id is what picks the right one. Returned + // separately from resolvedSSPInfo/sspMap (BCH-1340): a responsibility match feeds a + // different kind of risk (dedupe-keyed on responsibility uuid, linked via + // risk_responsibility_links), not a control-linked risk on the resolved SSP. type filterResponsibilityRow struct { SystemSecurityPlanID uuid.UUID `gorm:"column:system_security_plan_id"` + ResponsibilityUUID uuid.UUID `gorm:"column:responsibility_uuid"` } var responsibilityRows []filterResponsibilityRow if err := w.db.WithContext(ctx). Table("filter_responsibilities fr"). - Select("DISTINCT sll.downstream_ssp_id AS system_security_plan_id"). + Select("DISTINCT sll.downstream_ssp_id AS system_security_plan_id, fr.responsibility_uuid AS responsibility_uuid"). Joins("JOIN control_implementation_responsibilities cir ON cir.id = fr.responsibility_uuid"). // Direct (uncast) equality: both sides are uuid on Postgres (cir.provided_uuid's // column type is aligned by the migrateAlignProvidedUuidColumnType data migration, @@ -268,17 +294,15 @@ func (w *RiskEvidenceWorker) resolveSSPsViaFilters(ctx context.Context, evidence Joins("JOIN ssp_leverage_links sll ON sll.provided_uuid = cir.provided_uuid AND sll.downstream_ssp_id = fr.ssp_id"). Where("fr.filter_id IN ?", matchingFilterIDs). Scan(&responsibilityRows).Error; err != nil { - return nil, fmt.Errorf("failed to query SSPs via filter responsibilities: %w", err) + return nil, nil, fmt.Errorf("failed to query SSPs via filter responsibilities: %w", err) } + responsibilityResult := make([]resolvedResponsibilityInfo, 0, len(responsibilityRows)) for _, row := range responsibilityRows { - if _, exists := sspMap[row.SystemSecurityPlanID]; !exists { - sspMap[row.SystemSecurityPlanID] = &resolvedSSPInfo{SSPID: row.SystemSecurityPlanID} - } - } - - if len(sspMap) == 0 { - return nil, nil + responsibilityResult = append(responsibilityResult, resolvedResponsibilityInfo{ + SSPID: row.SystemSecurityPlanID, + ResponsibilityUUID: row.ResponsibilityUUID, + }) } result := make([]resolvedSSPInfo, 0, len(sspMap)) @@ -286,7 +310,7 @@ func (w *RiskEvidenceWorker) resolveSSPsViaFilters(ctx context.Context, evidence result = append(result, *info) } - return result, nil + return result, responsibilityResult, nil } // RiskEvidenceWorker helper methods @@ -630,6 +654,223 @@ func (w *RiskEvidenceWorker) createNewRiskForSSP(ctx context.Context, riskTempla return nil } +// createOrUpdateRisksForResponsibilities creates or updates one inherited-responsibility +// risk per resolved (downstream SSP, responsibility) pair (BCH-1340). Mirrors +// createOrUpdateRisksForSSPs, but keyed on the responsibility uuid rather than the SSP +// alone, and linked via risk_responsibility_links rather than risk_control_links. +func (w *RiskEvidenceWorker) createOrUpdateRisksForResponsibilities(ctx context.Context, riskTemplate templates.RiskTemplate, evidence *relational.Evidence, responsibilityInfos []resolvedResponsibilityInfo) error { + var errs []error + for _, info := range responsibilityInfos { + dedupeKey := w.computeDedupeKeyForResponsibility(riskTemplate, info.SSPID, info.ResponsibilityUUID) + + var existingRisk risks.Risk + err := w.db.WithContext(ctx). + Where("dedupe_key = ? AND status != ?", dedupeKey, risks.RiskStatusClosed). + First(&existingRisk).Error + + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + errs = append(errs, fmt.Errorf("failed to check for existing responsibility risk: %w", err)) + continue + } + + if err == nil { + err = w.updateExistingResponsibilityRisk(ctx, &existingRisk, evidence, info.ResponsibilityUUID) + } else { + err = w.createNewResponsibilityRisk(ctx, riskTemplate, evidence, info, dedupeKey) + } + + if err != nil { + w.logger.Errorw("Failed to create or update responsibility risk", + "error", err, + "evidence_id", evidence.UUID, + "risk_template_id", riskTemplate.ID, + "ssp_id", info.SSPID, + "responsibility_uuid", info.ResponsibilityUUID) + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} + +// computeDedupeKeyForResponsibility computes the dedupe key for an inherited- +// responsibility risk: ssp_id:risk_template_id:responsibility:. The responsibility +// uuid is an additional scope dimension beyond computeDedupeKeyForSSP's base — the same +// upstream responsibility can be leveraged by multiple downstream SSPs (BCH-1339), each +// needing its own risk. +func (w *RiskEvidenceWorker) computeDedupeKeyForResponsibility(riskTemplate templates.RiskTemplate, sspID uuid.UUID, responsibilityUUID uuid.UUID) string { + return fmt.Sprintf("%s:%s:responsibility:%s", sspID.String(), riskTemplate.ID.String(), responsibilityUUID.String()) +} + +// updateExistingResponsibilityRisk mirrors updateExistingRisk, scoped to a single +// responsibility rather than a set of control links. +func (w *RiskEvidenceWorker) updateExistingResponsibilityRisk(ctx context.Context, existingRisk *risks.Risk, evidence *relational.Evidence, responsibilityUUID uuid.UUID) error { + now := time.Now().UTC() + previousLastSeen := existingRisk.LastSeenAt + + reopened := false + oldStatus := existingRisk.Status + if existingRisk.Status == string(risks.RiskStatusRemediated) { + existingRisk.Status = string(risks.RiskStatusOpen) + reopened = true + } + + err := w.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + existingRisk.LastSeenAt = now + if err := tx.Save(existingRisk).Error; err != nil { + return fmt.Errorf("failed to update existing responsibility risk: %w", err) + } + + // Re-create risk links (evidence + responsibility) for this new piece of + // evidence. createResponsibilityRiskLinks is idempotent via + // OnConflict{DoNothing}, so this is safe for existing risks. + if err := w.createResponsibilityRiskLinks(ctx, tx, *existingRisk.ID, evidence, responsibilityUUID); err != nil { + return fmt.Errorf("failed to create responsibility risk links: %w", err) + } + + if reopened { + if err := w.emitRiskEventAt(ctx, tx, *existingRisk.ID, string(risks.RiskEventTypeStatusChange), map[string]interface{}{ + "from": oldStatus, + "to": string(risks.RiskStatusOpen), + "evidence_id": evidence.UUID, + "reason": "new_failing_evidence", + }, now); err != nil { + return fmt.Errorf("failed to emit reopen status change event: %w", err) + } + if err := risks.NewRiskService(w.db).RecordRiskScoreSnapshot(tx, *existingRisk.ID, risks.RiskEventTypeStatusChange, nil, now); err != nil { + return fmt.Errorf("failed to record reopened risk score snapshot: %w", err) + } + } + + if err := w.emitRiskEvent(ctx, tx, *existingRisk.ID, string(risks.RiskEventTypeLastSeen), map[string]interface{}{ + "evidence_id": evidence.UUID, + "previous_last_seen": previousLastSeen, + "new_last_seen": now, + }); err != nil { + return fmt.Errorf("failed to emit risk event: %w", err) + } + + return nil + }) + if err != nil { + return err + } + + w.logger.Infow("Updated existing responsibility risk", + "risk_id", existingRisk.ID, + "evidence_id", evidence.UUID, + "dedupe_key", existingRisk.DedupeKey, + ) + + return nil +} + +// createNewResponsibilityRisk mirrors createNewRiskForSSP: sourced as +// inherited-responsibility rather than evidence-auto, and linked via +// createResponsibilityRiskLinks rather than createRiskLinks — no control or component +// links, since an inherited capability has neither a local catalog control nor a local +// component. +func (w *RiskEvidenceWorker) createNewResponsibilityRisk(ctx context.Context, riskTemplate templates.RiskTemplate, evidence *relational.Evidence, info resolvedResponsibilityInfo, dedupeKey string) error { + now := time.Now().UTC() + + title, statement, likelihoodHint, impactHint := w.resolveRiskTemplateFields(riskTemplate, evidence.Labels) + + newRisk := risks.Risk{ + Title: title, + Description: statement, + Status: string(risks.RiskStatusOpen), + SSPID: info.SSPID, + RiskTemplateID: riskTemplate.ID, + SourceType: string(risks.RiskSourceTypeInheritedResponsibility), + DedupeKey: dedupeKey, + FirstSeenAt: now, + LastSeenAt: now, + } + + if likelihoodHint != nil { + newRisk.Likelihood = likelihoodHint + } + if impactHint != nil { + newRisk.Impact = impactHint + } + + err := w.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&newRisk).Error; err != nil { + return fmt.Errorf("failed to create new responsibility risk: %w", err) + } + if err := w.copyTemplateAssociationsToRisk(tx, *newRisk.ID, riskTemplate); err != nil { + return fmt.Errorf("failed to copy risk template associations: %w", err) + } + if err := w.createResponsibilityRiskLinks(ctx, tx, *newRisk.ID, evidence, info.ResponsibilityUUID); err != nil { + return err + } + if err := w.emitRiskEventAt(ctx, tx, *newRisk.ID, string(risks.RiskEventTypeCreated), map[string]interface{}{ + "evidence_id": evidence.UUID, + "template_id": riskTemplate.ID, + "dedupe_key": dedupeKey, + "ssp_id": info.SSPID, + "responsibility_uuid": info.ResponsibilityUUID, + }, now); err != nil { + return fmt.Errorf("failed to emit created risk event: %w", err) + } + if err := risks.NewRiskService(w.db).RecordRiskScoreSnapshot(tx, *newRisk.ID, risks.RiskEventTypeCreated, nil, now); err != nil { + return fmt.Errorf("failed to record created risk score snapshot: %w", err) + } + return nil + }) + if err != nil { + return err + } + + w.logger.Infow("Created new responsibility risk", + "risk_id", newRisk.ID, + "evidence_id", evidence.UUID, + "risk_template_id", riskTemplate.ID, + "ssp_id", info.SSPID, + "responsibility_uuid", info.ResponsibilityUUID, + "dedupe_key", dedupeKey, + ) + + return nil +} + +// createResponsibilityRiskLinks links an inherited-responsibility risk to the evidence +// that triggered it and the upstream responsibility it covers. Mirrors createRiskLinks' +// evidence-linking half exactly — including the reason a RiskEvidenceLink is required at +// all: it's what makes handleEvidenceResolution's existing satisfied-evidence +// auto-remediation apply to these risks with zero changes there. No control or component +// links, since an inherited capability has neither. +func (w *RiskEvidenceWorker) createResponsibilityRiskLinks(ctx context.Context, db *gorm.DB, riskID uuid.UUID, evidence *relational.Evidence, responsibilityUUID uuid.UUID) error { + now := time.Now().UTC() + if evidence.UUID == uuid.Nil { + evidenceID := uuid.Nil + if evidence.ID != nil { + evidenceID = *evidence.ID + } + return fmt.Errorf("evidence %s is missing stream uuid", evidenceID) + } + + evidenceLink := &risks.RiskEvidenceLink{ + RiskID: riskID, + EvidenceID: evidence.UUID, + CreatedAt: now, + } + if err := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(evidenceLink).Error; err != nil { + return fmt.Errorf("failed to create evidence link: %w", err) + } + + responsibilityLink := &risks.RiskResponsibilityLink{ + RiskID: riskID, + ResponsibilityUUID: responsibilityUUID, + CreatedAt: now, + } + if err := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(responsibilityLink).Error; err != nil { + return fmt.Errorf("failed to create responsibility link: %w", err) + } + + return nil +} + func (w *RiskEvidenceWorker) copyTemplateAssociationsToRisk(tx *gorm.DB, riskID uuid.UUID, riskTemplate templates.RiskTemplate) error { if len(riskTemplate.ThreatRefs) > 0 { rows := make([]risks.RiskThreatRef, 0, len(riskTemplate.ThreatRefs)) diff --git a/internal/service/worker/risk_evidence_worker_inherited_responsibility_integration_test.go b/internal/service/worker/risk_evidence_worker_inherited_responsibility_integration_test.go new file mode 100644 index 00000000..f3d417b8 --- /dev/null +++ b/internal/service/worker/risk_evidence_worker_inherited_responsibility_integration_test.go @@ -0,0 +1,211 @@ +//go:build integration + +package worker + +import ( + "context" + "testing" + + "github.com/compliance-framework/api/internal/converters/labelfilter" + "github.com/compliance-framework/api/internal/service/relational" + poamsvc "github.com/compliance-framework/api/internal/service/relational/poam" + "github.com/compliance-framework/api/internal/service/relational/risks" + "github.com/compliance-framework/api/internal/tests" + oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" + "github.com/google/uuid" + "github.com/riverqueue/river" + "github.com/stretchr/testify/suite" + "go.uber.org/zap" + "gorm.io/datatypes" +) + +// InheritedResponsibilityRiskIntegrationSuite exercises BCH-1340's full worker pipeline +// (Work(), not just resolveSSPsViaFilters) against real Postgres: a leveraged +// responsibility + matching risk template + non-compliant evidence creates exactly one +// inherited-responsibility risk on the downstream SSP, deduped on re-processing, and +// auto-remediated once the evidence flips to satisfied. Also covers the isolation +// requirement (a control-arm-only match stays on the upstream SSP) and the +// promote-to-POA&M regression (works unchanged for this new source type). +type InheritedResponsibilityRiskIntegrationSuite struct { + tests.IntegrationTestSuite +} + +func TestInheritedResponsibilityRiskIntegrationSuite(t *testing.T) { + suite.Run(t, new(InheritedResponsibilityRiskIntegrationSuite)) +} + +func (suite *InheritedResponsibilityRiskIntegrationSuite) SetupTest() { + suite.Require().NoError(suite.Migrator.Refresh()) +} + +func (suite *InheritedResponsibilityRiskIntegrationSuite) newWorker() *RiskEvidenceWorker { + return NewRiskEvidenceWorker(suite.DB, zap.NewNop().Sugar()) +} + +// seedLeveragedResponsibility creates the minimal real-schema graph a +// filter_responsibilities → ssp_leverage_links resolution needs (mirroring +// RiskEvidenceWorkerResponsibilityIntegrationSuite's BCH-1339 helper): an Export with one +// ProvidedControlImplementation and one ControlImplementationResponsibility under it, an +// upstream and downstream SystemSecurityPlan, and an SSPLeverageLink tying the two +// together. +func (suite *InheritedResponsibilityRiskIntegrationSuite) seedLeveragedResponsibility() (responsibilityUUID, downstreamSSPID, upstreamSSPID uuid.UUID) { + export := relational.Export{} + suite.Require().NoError(suite.DB.Create(&export).Error) + + provided := relational.ProvidedControlImplementation{ExportId: *export.ID, Description: "provided"} + suite.Require().NoError(suite.DB.Create(&provided).Error) + + responsibility := relational.ControlImplementationResponsibility{ + ExportId: *export.ID, ProvidedUuid: *provided.ID, Description: "responsibility", + } + suite.Require().NoError(suite.DB.Create(&responsibility).Error) + + upstreamSSP := relational.SystemSecurityPlan{} + suite.Require().NoError(suite.DB.Create(&upstreamSSP).Error) + + downstreamSSP := relational.SystemSecurityPlan{} + suite.Require().NoError(suite.DB.Create(&downstreamSSP).Error) + + link := relational.SSPLeverageLink{ + DownstreamSSPID: *downstreamSSP.ID, + UpstreamSSPID: *upstreamSSP.ID, + OfferingID: uuid.New(), + ControlID: "ac-1", + ProvidedUUID: *provided.ID, + InheritedUUID: uuid.New(), + LeveragedAuthUUID: uuid.New(), + Satisfaction: relational.SSPLeverageSatisfactionPartial, + Status: relational.SSPLeverageStatusActive, + } + suite.Require().NoError(suite.DB.Create(&link).Error) + + return *responsibility.ID, *downstreamSSP.ID, *upstreamSSP.ID +} + +func (suite *InheritedResponsibilityRiskIntegrationSuite) seedResponsibilityFilter(downstreamSSPID, responsibilityUUID uuid.UUID, labelKey, labelValue string) { + filterScope := labelfilter.Filter{ + Scope: &labelfilter.Scope{ + Condition: &labelfilter.Condition{Label: labelKey, Operator: "=", Value: labelValue}, + }, + } + f := relational.Filter{Name: "responsibility-filter", Filter: datatypes.NewJSONType(filterScope)} + suite.Require().NoError(suite.DB.Create(&f).Error) + suite.Require().NoError(suite.DB.Create(&relational.FilterResponsibility{ + FilterID: *f.ID, + ResponsibilityUUID: responsibilityUUID, + SSPID: downstreamSSPID, + }).Error) +} + +func (suite *InheritedResponsibilityRiskIntegrationSuite) TestCreatedDedupedAndRemediated() { + responsibilityUUID, downstreamSSPID, upstreamSSPID := suite.seedLeveragedResponsibility() + suite.seedResponsibilityFilter(downstreamSSPID, responsibilityUUID, "environment", "production") + + riskTemplate := createTestRiskTemplate(suite.T(), suite.DB) + evidence := createTestEvidence(suite.T(), suite.DB) + + worker := suite.newWorker() + ctx := context.Background() + args := RiskProcessEvidenceArgs{EvidenceID: *evidence.ID, EvidenceEnd: "2026-01-01T00:00:00Z", Status: "not-satisfied"} + suite.Require().NoError(worker.Work(ctx, &river.Job[RiskProcessEvidenceArgs]{Args: args})) + + var risk risks.Risk + suite.Require().NoError(suite.DB.Where("ssp_id = ?", downstreamSSPID).First(&risk).Error) + suite.Equal(string(risks.RiskSourceTypeInheritedResponsibility), risk.SourceType) + suite.Equal(string(risks.RiskStatusOpen), risk.Status) + suite.Require().NotNil(risk.RiskTemplateID) + suite.Equal(*riskTemplate.ID, *risk.RiskTemplateID) + + var responsibilityLink risks.RiskResponsibilityLink + suite.Require().NoError(suite.DB.Where("risk_id = ? AND responsibility_uuid = ?", risk.ID, responsibilityUUID).First(&responsibilityLink).Error) + + var upstreamRiskCount int64 + suite.Require().NoError(suite.DB.Model(&risks.Risk{}).Where("ssp_id = ?", upstreamSSPID).Count(&upstreamRiskCount).Error) + suite.Zero(upstreamRiskCount, "no risk should ever land on the upstream SSP for a responsibility-only match") + + // Re-run the same evidence job — dedupe by responsibility uuid must not duplicate. + suite.Require().NoError(worker.Work(ctx, &river.Job[RiskProcessEvidenceArgs]{Args: args})) + var riskCount int64 + suite.Require().NoError(suite.DB.Model(&risks.Risk{}).Where("ssp_id = ?", downstreamSSPID).Count(&riskCount).Error) + suite.Equal(int64(1), riskCount) + + // Flip: newer evidence for the same stream, now satisfied, auto-remediates the risk — + // exercising the existing (unmodified) handleEvidenceResolution path end to end. + satisfiedID := uuid.New() + satisfied := &relational.Evidence{ + UUIDModel: relational.UUIDModel{ID: &satisfiedID}, + UUID: evidence.UUID, + Title: "Satisfied Evidence", + Start: evidence.Start, + End: evidence.End.Add(1), + Status: datatypes.NewJSONType(oscalTypes_1_1_3.ObjectiveStatus{State: "satisfied"}), + } + suite.Require().NoError(suite.DB.Create(satisfied).Error) + + satisfiedArgs := RiskProcessEvidenceArgs{EvidenceID: satisfiedID, EvidenceEnd: "2026-01-02T00:00:00Z", Status: "satisfied"} + suite.Require().NoError(worker.Work(ctx, &river.Job[RiskProcessEvidenceArgs]{Args: satisfiedArgs})) + + var remediated risks.Risk + suite.Require().NoError(suite.DB.First(&remediated, "id = ?", risk.ID).Error) + suite.Equal(string(risks.RiskStatusRemediated), remediated.Status) +} + +func (suite *InheritedResponsibilityRiskIntegrationSuite) TestIsolationUpstreamOnlyMatchStaysUpstream() { + catalogID := uuid.New() + controlID := "AC-1" + seedFilterForControl(suite.T(), suite.DB, catalogID, controlID, "environment", "production") + upstreamSSP := createTestSSPWithControl(suite.T(), suite.DB, catalogID, controlID) + + createTestRiskTemplate(suite.T(), suite.DB) + evidence := createTestEvidence(suite.T(), suite.DB) + + worker := suite.newWorker() + ctx := context.Background() + args := RiskProcessEvidenceArgs{EvidenceID: *evidence.ID, EvidenceEnd: "2026-01-01T00:00:00Z", Status: "not-satisfied"} + suite.Require().NoError(worker.Work(ctx, &river.Job[RiskProcessEvidenceArgs]{Args: args})) + + var risk risks.Risk + suite.Require().NoError(suite.DB.Where("ssp_id = ?", *upstreamSSP.ID).First(&risk).Error) + suite.Equal(string(risks.RiskSourceTypeEvidenceAuto), risk.SourceType) + + var inheritedResponsibilityCount int64 + suite.Require().NoError(suite.DB.Model(&risks.Risk{}). + Where("source_type = ?", string(risks.RiskSourceTypeInheritedResponsibility)). + Count(&inheritedResponsibilityCount).Error) + suite.Zero(inheritedResponsibilityCount, "a filter_controls-only match must never create an inherited-responsibility risk") + + var responsibilityLinkCount int64 + suite.Require().NoError(suite.DB.Model(&risks.RiskResponsibilityLink{}).Count(&responsibilityLinkCount).Error) + suite.Zero(responsibilityLinkCount) +} + +func (suite *InheritedResponsibilityRiskIntegrationSuite) TestPromotesToPoamUnchanged() { + responsibilityUUID, downstreamSSPID, _ := suite.seedLeveragedResponsibility() + suite.seedResponsibilityFilter(downstreamSSPID, responsibilityUUID, "environment", "production") + + createTestRiskTemplate(suite.T(), suite.DB) + evidence := createTestEvidence(suite.T(), suite.DB) + + worker := suite.newWorker() + ctx := context.Background() + args := RiskProcessEvidenceArgs{EvidenceID: *evidence.ID, EvidenceEnd: "2026-01-01T00:00:00Z", Status: "not-satisfied"} + suite.Require().NoError(worker.Work(ctx, &river.Job[RiskProcessEvidenceArgs]{Args: args})) + + var risk risks.Risk + suite.Require().NoError(suite.DB.Where("ssp_id = ?", downstreamSSPID).First(&risk).Error) + suite.Require().Equal(string(risks.RiskSourceTypeInheritedResponsibility), risk.SourceType) + + // PromoteToPoam requires "investigating" status — a pre-existing, source-type-agnostic + // triage step that's out of this ticket's scope; simulate it directly. + suite.Require().NoError(suite.DB.Model(&risk).Update("status", string(risks.RiskStatusInvestigating)).Error) + + poamItem, err := risks.NewRiskService(suite.DB).PromoteToPoam(poamsvc.NewPoamService(suite.DB), risks.PromoteToPoamParams{ + RiskID: *risk.ID, + }) + suite.Require().NoError(err) + suite.Require().NotNil(poamItem) + + var promoted risks.Risk + suite.Require().NoError(suite.DB.First(&promoted, "id = ?", risk.ID).Error) + suite.Equal(string(risks.RiskStatusMitigatingPlanned), promoted.Status) +} diff --git a/internal/service/worker/risk_evidence_worker_responsibility_integration_test.go b/internal/service/worker/risk_evidence_worker_responsibility_integration_test.go index 3be7abc9..1f2c4c23 100644 --- a/internal/service/worker/risk_evidence_worker_responsibility_integration_test.go +++ b/internal/service/worker/risk_evidence_worker_responsibility_integration_test.go @@ -90,13 +90,14 @@ func (suite *RiskEvidenceWorkerResponsibilityIntegrationSuite) TestResolvesDowns suite.seedResponsibilityFilter(downstreamSSPID, responsibilityUUID, "environment", "production") worker := NewRiskEvidenceWorker(suite.DB, zap.NewNop().Sugar()) - sspInfos, err := worker.resolveSSPsViaFilters(context.Background(), []relational.Labels{ + sspInfos, responsibilityInfos, err := worker.resolveSSPsViaFilters(context.Background(), []relational.Labels{ {Name: "environment", Value: "production"}, }) suite.Require().NoError(err) - suite.Require().Len(sspInfos, 1) - suite.Equal(downstreamSSPID, sspInfos[0].SSPID) - suite.Empty(sspInfos[0].ControlLinks) + suite.Empty(sspInfos) + suite.Require().Len(responsibilityInfos, 1) + suite.Equal(downstreamSSPID, responsibilityInfos[0].SSPID) + suite.Equal(responsibilityUUID, responsibilityInfos[0].ResponsibilityUUID) } func (suite *RiskEvidenceWorkerResponsibilityIntegrationSuite) TestNoMatchReturnsNoSSPs() { @@ -104,9 +105,10 @@ func (suite *RiskEvidenceWorkerResponsibilityIntegrationSuite) TestNoMatchReturn suite.seedResponsibilityFilter(downstreamSSPID, responsibilityUUID, "environment", "staging") worker := NewRiskEvidenceWorker(suite.DB, zap.NewNop().Sugar()) - sspInfos, err := worker.resolveSSPsViaFilters(context.Background(), []relational.Labels{ + sspInfos, responsibilityInfos, err := worker.resolveSSPsViaFilters(context.Background(), []relational.Labels{ {Name: "environment", Value: "production"}, }) suite.Require().NoError(err) suite.Empty(sspInfos) + suite.Empty(responsibilityInfos) } diff --git a/internal/service/worker/risk_evidence_worker_test.go b/internal/service/worker/risk_evidence_worker_test.go index 8ba6f280..8ea56f9d 100644 --- a/internal/service/worker/risk_evidence_worker_test.go +++ b/internal/service/worker/risk_evidence_worker_test.go @@ -52,6 +52,7 @@ func newRiskEvidenceWorkerTestDB(t *testing.T) *gorm.DB { &risks.RiskSubjectLink{}, &risks.RiskComponentLink{}, &risks.RiskControlLink{}, + &risks.RiskResponsibilityLink{}, &risks.RiskThreatRef{}, &risks.RiskRemediationTemplate{}, &risks.RiskRemediationTask{}, @@ -1280,6 +1281,256 @@ func TestRiskEvidenceWorker_createRiskLinks_MissingEvidenceStreamUUID(t *testing assert.Zero(t, evidenceLinkCount) } +func TestRiskEvidenceWorker_createOrUpdateRisksForResponsibilities_CreateNew(t *testing.T) { + t.Parallel() + + worker := createTestRiskEvidenceWorker(t) + ctx := context.Background() + + downstreamSSPID := uuid.New() + responsibilityUUID := uuid.New() + riskTemplate := createTestRiskTemplate(t, worker.db) + evidence := createTestEvidence(t, worker.db) + + responsibilityInfos := []resolvedResponsibilityInfo{ + {SSPID: downstreamSSPID, ResponsibilityUUID: responsibilityUUID}, + } + + err := worker.createOrUpdateRisksForResponsibilities(ctx, *riskTemplate, evidence, responsibilityInfos) + assert.NoError(t, err) + + var risk risks.Risk + err = worker.db.WithContext(ctx). + Where("ssp_id = ? AND risk_template_id = ?", downstreamSSPID, riskTemplate.ID). + First(&risk).Error + assert.NoError(t, err) + assert.Equal(t, riskTemplate.Title, risk.Title) + assert.Equal(t, string(risks.RiskSourceTypeInheritedResponsibility), risk.SourceType) + assert.Equal(t, risks.RiskStatusOpen, risks.RiskStatus(risk.Status)) + assert.Equal(t, worker.computeDedupeKeyForResponsibility(*riskTemplate, downstreamSSPID, responsibilityUUID), risk.DedupeKey) + + var responsibilityLink risks.RiskResponsibilityLink + require.NoError(t, worker.db.Where("risk_id = ?", risk.ID).First(&responsibilityLink).Error) + assert.Equal(t, responsibilityUUID, responsibilityLink.ResponsibilityUUID) + + var evidenceLink risks.RiskEvidenceLink + require.NoError(t, worker.db.Where("risk_id = ? AND evidence_id = ?", risk.ID, evidence.UUID).First(&evidenceLink).Error) + + // No control/component links — an inherited capability has neither. + var controlLinkCount int64 + require.NoError(t, worker.db.Model(&risks.RiskControlLink{}).Where("risk_id = ?", risk.ID).Count(&controlLinkCount).Error) + assert.Zero(t, controlLinkCount) +} + +func TestRiskEvidenceWorker_createOrUpdateRisksForResponsibilities_UpdateExisting(t *testing.T) { + t.Parallel() + + worker := createTestRiskEvidenceWorker(t) + ctx := context.Background() + + downstreamSSPID := uuid.New() + responsibilityUUID := uuid.New() + riskTemplate := createTestRiskTemplate(t, worker.db) + evidence := createTestEvidence(t, worker.db) + + dedupeKey := worker.computeDedupeKeyForResponsibility(*riskTemplate, downstreamSSPID, responsibilityUUID) + existingRisk := &risks.Risk{ + Title: "Existing Responsibility Risk", + Description: "Existing description", + Status: string(risks.RiskStatusOpen), + SSPID: downstreamSSPID, + RiskTemplateID: riskTemplate.ID, + SourceType: string(risks.RiskSourceTypeInheritedResponsibility), + DedupeKey: dedupeKey, + FirstSeenAt: time.Now().Add(-1 * time.Hour), + LastSeenAt: time.Now().Add(-1 * time.Hour), + } + require.NoError(t, worker.db.Create(existingRisk).Error) + + responsibilityInfos := []resolvedResponsibilityInfo{ + {SSPID: downstreamSSPID, ResponsibilityUUID: responsibilityUUID}, + } + + err := worker.createOrUpdateRisksForResponsibilities(ctx, *riskTemplate, evidence, responsibilityInfos) + assert.NoError(t, err) + + var updatedRisk risks.Risk + require.NoError(t, worker.db.WithContext(ctx).First(&updatedRisk, existingRisk.ID).Error) + assert.True(t, updatedRisk.LastSeenAt.After(existingRisk.LastSeenAt)) + + // Re-processing the same evidence doesn't duplicate the risk (dedupe). + var riskCount int64 + require.NoError(t, worker.db.Model(&risks.Risk{}).Where("dedupe_key = ?", dedupeKey).Count(&riskCount).Error) + assert.Equal(t, int64(1), riskCount) +} + +func TestRiskEvidenceWorker_createOrUpdateRisksForResponsibilities_ReopensRemediated(t *testing.T) { + t.Parallel() + + worker := createTestRiskEvidenceWorker(t) + ctx := context.Background() + + downstreamSSPID := uuid.New() + responsibilityUUID := uuid.New() + riskTemplate := createTestRiskTemplate(t, worker.db) + evidence := createTestEvidence(t, worker.db) + + dedupeKey := worker.computeDedupeKeyForResponsibility(*riskTemplate, downstreamSSPID, responsibilityUUID) + existingRisk := &risks.Risk{ + Title: "Remediated Responsibility Risk", + Description: "Existing description", + Status: string(risks.RiskStatusRemediated), + SSPID: downstreamSSPID, + RiskTemplateID: riskTemplate.ID, + SourceType: string(risks.RiskSourceTypeInheritedResponsibility), + DedupeKey: dedupeKey, + FirstSeenAt: time.Now().Add(-1 * time.Hour), + LastSeenAt: time.Now().Add(-1 * time.Hour), + } + require.NoError(t, worker.db.Create(existingRisk).Error) + + // The dedupe pre-check in createOrUpdateRisksForResponsibilities excludes closed + // risks but not remediated ones, so new failing evidence must reopen it rather than + // create a duplicate. + responsibilityInfos := []resolvedResponsibilityInfo{ + {SSPID: downstreamSSPID, ResponsibilityUUID: responsibilityUUID}, + } + + err := worker.createOrUpdateRisksForResponsibilities(ctx, *riskTemplate, evidence, responsibilityInfos) + assert.NoError(t, err) + + var updatedRisk risks.Risk + require.NoError(t, worker.db.WithContext(ctx).First(&updatedRisk, existingRisk.ID).Error) + assert.Equal(t, risks.RiskStatusOpen, risks.RiskStatus(updatedRisk.Status)) +} + +func TestRiskEvidenceWorker_computeDedupeKeyForResponsibility(t *testing.T) { + t.Parallel() + + worker := createTestRiskEvidenceWorker(t) + sspID := uuid.New() + responsibilityUUID := uuid.New() + templateID := uuid.New() + riskTemplate := templates.RiskTemplate{UUIDModel: relational.UUIDModel{ID: &templateID}} + + key := worker.computeDedupeKeyForResponsibility(riskTemplate, sspID, responsibilityUUID) + expected := fmt.Sprintf("%s:%s:responsibility:%s", sspID, templateID, responsibilityUUID) + assert.Equal(t, expected, key) + + // Different SSPs leveraging the same responsibility must not collide. + otherSSPID := uuid.New() + otherKey := worker.computeDedupeKeyForResponsibility(riskTemplate, otherSSPID, responsibilityUUID) + assert.NotEqual(t, key, otherKey) +} + +func TestRiskEvidenceWorker_createResponsibilityRiskLinks(t *testing.T) { + t.Parallel() + + worker := createTestRiskEvidenceWorker(t) + ctx := context.Background() + + evidence := createTestEvidence(t, worker.db) + + sspID := uuid.New() + riskID := uuid.New() + risk := &risks.Risk{ + UUIDModel: relational.UUIDModel{ID: &riskID}, + SSPID: sspID, + Title: "Test Risk", + Description: "Test Description", + Status: "open", + SourceType: string(risks.RiskSourceTypeInheritedResponsibility), + FirstSeenAt: time.Now(), + LastSeenAt: time.Now(), + } + require.NoError(t, worker.db.Create(risk).Error) + + responsibilityUUID := uuid.New() + err := worker.createResponsibilityRiskLinks(ctx, worker.db, riskID, evidence, responsibilityUUID) + assert.NoError(t, err) + + var evidenceLink risks.RiskEvidenceLink + err = worker.db.WithContext(ctx). + Where("risk_id = ? AND evidence_id = ?", riskID, evidence.UUID). + First(&evidenceLink).Error + assert.NoError(t, err) + + var responsibilityLink risks.RiskResponsibilityLink + err = worker.db.WithContext(ctx). + Where("risk_id = ? AND responsibility_uuid = ?", riskID, responsibilityUUID). + First(&responsibilityLink).Error + assert.NoError(t, err) + + // Idempotent via OnConflict{DoNothing} — calling it again must not error or duplicate. + require.NoError(t, worker.createResponsibilityRiskLinks(ctx, worker.db, riskID, evidence, responsibilityUUID)) + var linkCount int64 + require.NoError(t, worker.db.Model(&risks.RiskResponsibilityLink{}).Where("risk_id = ?", riskID).Count(&linkCount).Error) + assert.Equal(t, int64(1), linkCount) +} + +func TestRiskEvidenceWorker_createResponsibilityRiskLinks_MissingEvidenceStreamUUID(t *testing.T) { + t.Parallel() + + worker := createTestRiskEvidenceWorker(t) + ctx := context.Background() + + evidenceID := uuid.New() + evidence := &relational.Evidence{ + UUIDModel: relational.UUIDModel{ID: &evidenceID}, + // UUID intentionally left zero. + } + + err := worker.createResponsibilityRiskLinks(ctx, worker.db, uuid.New(), evidence, uuid.New()) + assert.Error(t, err) +} + +// TestRiskEvidenceWorker_Resolution_InheritedResponsibilityRiskAutoRemediates confirms +// the claim behind BCH-1340's design: handleEvidenceResolution is source-type-agnostic +// and needs zero changes to auto-remediate an inherited-responsibility risk once its +// evidence is satisfied — it only cares about risk_evidence_links, which +// createResponsibilityRiskLinks creates exactly like createRiskLinks does for +// control-linked risks. +func TestRiskEvidenceWorker_Resolution_InheritedResponsibilityRiskAutoRemediates(t *testing.T) { + t.Parallel() + + worker := createTestRiskEvidenceWorker(t) + ctx := context.Background() + + evidenceID := uuid.New() + evidence := &relational.Evidence{ + UUIDModel: relational.UUIDModel{ID: &evidenceID}, + UUID: evidenceID, + Title: "Satisfied Responsibility Evidence", + Start: time.Now().Add(-1 * time.Hour), + End: time.Now(), + Status: datatypes.NewJSONType(oscalTypes_1_1_3.ObjectiveStatus{State: "satisfied"}), + } + require.NoError(t, worker.db.Create(evidence).Error) + + riskID := uuid.New() + risk := &risks.Risk{ + UUIDModel: relational.UUIDModel{ID: &riskID}, + Title: "Inherited Responsibility Risk", + Description: "Test", + Status: string(risks.RiskStatusOpen), + SSPID: uuid.New(), + SourceType: string(risks.RiskSourceTypeInheritedResponsibility), + DedupeKey: fmt.Sprintf("dedupe-%s", riskID), + FirstSeenAt: time.Now().Add(-1 * time.Hour), + LastSeenAt: time.Now().Add(-1 * time.Hour), + } + require.NoError(t, worker.db.Create(risk).Error) + require.NoError(t, worker.db.Create(&risks.RiskEvidenceLink{RiskID: riskID, EvidenceID: evidenceID}).Error) + require.NoError(t, worker.db.Create(&risks.RiskResponsibilityLink{RiskID: riskID, ResponsibilityUUID: uuid.New()}).Error) + + err := worker.handleEvidenceResolution(ctx, evidence) + require.NoError(t, err) + + var updated risks.Risk + require.NoError(t, worker.db.First(&updated, "id = ?", riskID).Error) + assert.Equal(t, string(risks.RiskStatusRemediated), updated.Status) +} + func TestRiskEvidenceWorker_emitRiskEvent(t *testing.T) { t.Parallel() @@ -1386,13 +1637,14 @@ func TestRiskEvidenceWorker_resolveSSPsViaFilters(t *testing.T) { {Name: "category", Value: "security"}, } - sspInfos, err := worker.resolveSSPsViaFilters(ctx, labels) + sspInfos, responsibilityInfos, err := worker.resolveSSPsViaFilters(ctx, labels) require.NoError(t, err) require.Len(t, sspInfos, 1) assert.Equal(t, *ssp.ID, sspInfos[0].SSPID) require.Len(t, sspInfos[0].ControlLinks, 1) assert.Equal(t, catalogID, sspInfos[0].ControlLinks[0].CatalogID) assert.Equal(t, controlID, sspInfos[0].ControlLinks[0].ControlID) + assert.Empty(t, responsibilityInfos) } func TestRiskEvidenceWorker_resolveSSPsViaFilters_NoMatch(t *testing.T) { @@ -1408,9 +1660,10 @@ func TestRiskEvidenceWorker_resolveSSPsViaFilters_NoMatch(t *testing.T) { {Name: "environment", Value: "production"}, } - sspInfos, err := worker.resolveSSPsViaFilters(ctx, labels) + sspInfos, responsibilityInfos, err := worker.resolveSSPsViaFilters(ctx, labels) require.NoError(t, err) assert.Nil(t, sspInfos) + assert.Nil(t, responsibilityInfos) } func TestRiskEvidenceWorker_resolveSSPsViaFilters_NoFilters(t *testing.T) { @@ -1423,9 +1676,10 @@ func TestRiskEvidenceWorker_resolveSSPsViaFilters_NoFilters(t *testing.T) { {Name: "environment", Value: "production"}, } - sspInfos, err := worker.resolveSSPsViaFilters(ctx, labels) + sspInfos, responsibilityInfos, err := worker.resolveSSPsViaFilters(ctx, labels) require.NoError(t, err) assert.Nil(t, sspInfos) + assert.Nil(t, responsibilityInfos) } // --- Resolution flow tests --- @@ -1842,9 +2096,10 @@ func TestRiskEvidenceWorker_resolveSSPsViaFilters_MultipleSSPs(t *testing.T) { {Name: "environment", Value: "production"}, } - sspInfos, err := worker.resolveSSPsViaFilters(ctx, labels) + sspInfos, responsibilityInfos, err := worker.resolveSSPsViaFilters(ctx, labels) require.NoError(t, err) require.Len(t, sspInfos, 2) + assert.Empty(t, responsibilityInfos) sspIDs := make(map[uuid.UUID]bool) for _, info := range sspInfos { @@ -1872,11 +2127,12 @@ func TestRiskEvidenceWorker_resolveSSPsViaFilters_ResolvesViaFilterResponsibilit {Name: "environment", Value: "production"}, } - sspInfos, err := worker.resolveSSPsViaFilters(ctx, labels) + sspInfos, responsibilityInfos, err := worker.resolveSSPsViaFilters(ctx, labels) require.NoError(t, err) - require.Len(t, sspInfos, 1) - assert.Equal(t, downstreamSSPID, sspInfos[0].SSPID) - assert.Empty(t, sspInfos[0].ControlLinks) + assert.Empty(t, sspInfos) + require.Len(t, responsibilityInfos, 1) + assert.Equal(t, downstreamSSPID, responsibilityInfos[0].SSPID) + assert.Equal(t, responsibilityUUID, responsibilityInfos[0].ResponsibilityUUID) } func TestRiskEvidenceWorker_resolveSSPsViaFilters_ResponsibilityLeveragedByMultipleSSPs(t *testing.T) { @@ -1922,13 +2178,15 @@ func TestRiskEvidenceWorker_resolveSSPsViaFilters_ResponsibilityLeveragedByMulti {Name: "environment", Value: "production"}, } - sspInfos, err := worker.resolveSSPsViaFilters(ctx, labels) + sspInfos, responsibilityInfos, err := worker.resolveSSPsViaFilters(ctx, labels) require.NoError(t, err) - require.Len(t, sspInfos, 2) + assert.Empty(t, sspInfos) + require.Len(t, responsibilityInfos, 2) sspIDs := make(map[uuid.UUID]bool) - for _, info := range sspInfos { + for _, info := range responsibilityInfos { sspIDs[info.SSPID] = true + assert.Equal(t, responsibilityUUID, info.ResponsibilityUUID) } assert.True(t, sspIDs[downstreamSSP1]) assert.True(t, sspIDs[downstreamSSP2]) @@ -1978,9 +2236,10 @@ func TestRiskEvidenceWorker_resolveSSPsViaFilters_ResponsibilityWrongSSPScopeDoe {Name: "environment", Value: "production"}, } - sspInfos, err := worker.resolveSSPsViaFilters(ctx, labels) + sspInfos, responsibilityInfos, err := worker.resolveSSPsViaFilters(ctx, labels) require.NoError(t, err) assert.Empty(t, sspInfos) + assert.Empty(t, responsibilityInfos) } func TestComputeDedupeKeyForSSP_WithDedupeLabelKeys(t *testing.T) { diff --git a/internal/tests/migrate.go b/internal/tests/migrate.go index 9ac9787b..0df7154c 100644 --- a/internal/tests/migrate.go +++ b/internal/tests/migrate.go @@ -129,6 +129,7 @@ func (t *TestMigrator) Up() error { &riskrel.RiskEvidenceLink{}, &riskrel.RiskControlLink{}, &riskrel.RiskComponentLink{}, + &riskrel.RiskResponsibilityLink{}, &riskrel.RiskSubjectLink{}, &riskrel.RiskOwnerAssignment{}, &riskrel.RiskThreatRef{}, @@ -515,6 +516,7 @@ func (t *TestMigrator) Down() error { &riskrel.RiskEvidenceLink{}, &riskrel.RiskControlLink{}, &riskrel.RiskComponentLink{}, + &riskrel.RiskResponsibilityLink{}, &riskrel.RiskSubjectLink{}, &riskrel.RiskOwnerAssignment{}, &riskrel.RiskThreatRef{}, From c7ff731c0b7f8d65997ec4f5890eeb4179a7c547 Mon Sep 17 00:00:00 2001 From: James Salt Date: Fri, 10 Jul 2026 09:39:06 +0100 Subject: [PATCH 2/2] BCH-1340: Address PR review comments on manifest doc drift and test helper duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes risk-promotion from risk.source_type's doc comment — it's actually a PoamItemSourceType value, not a RiskSourceType one, and was never accepted by RiskSourceType.IsValid(). Also centralizes the leveraged- responsibility seeding helpers shared by the BCH-1339 and BCH-1340 integration suites into a single test helper file. --- internal/authz/manifest.yaml | 2 +- ...herited_responsibility_integration_test.go | 64 +--------------- ..._worker_responsibility_integration_test.go | 65 +--------------- .../risk_evidence_worker_test_helpers_test.go | 75 +++++++++++++++++++ 4 files changed, 84 insertions(+), 122 deletions(-) create mode 100644 internal/service/worker/risk_evidence_worker_test_helpers_test.go diff --git a/internal/authz/manifest.yaml b/internal/authz/manifest.yaml index ebaced3a..812d283d 100644 --- a/internal/authz/manifest.yaml +++ b/internal/authz/manifest.yaml @@ -143,7 +143,7 @@ resources: ssp_id: uuid # C0 on /system-security-plans/:sspId/risks, else C1 owned_by: set # PrimaryOwnerUserID — matches subject.user_uuid status: string - source_type: string # manual | evidence-auto | oscal-import | risk-promotion | inherited-responsibility + source_type: string # manual | evidence-auto | oscal-import | inherited-responsibility poam_item: actions: [read, create, update, delete] attributes: diff --git a/internal/service/worker/risk_evidence_worker_inherited_responsibility_integration_test.go b/internal/service/worker/risk_evidence_worker_inherited_responsibility_integration_test.go index f3d417b8..5cd8743a 100644 --- a/internal/service/worker/risk_evidence_worker_inherited_responsibility_integration_test.go +++ b/internal/service/worker/risk_evidence_worker_inherited_responsibility_integration_test.go @@ -6,7 +6,6 @@ import ( "context" "testing" - "github.com/compliance-framework/api/internal/converters/labelfilter" "github.com/compliance-framework/api/internal/service/relational" poamsvc "github.com/compliance-framework/api/internal/service/relational/poam" "github.com/compliance-framework/api/internal/service/relational/risks" @@ -42,64 +41,9 @@ func (suite *InheritedResponsibilityRiskIntegrationSuite) newWorker() *RiskEvide return NewRiskEvidenceWorker(suite.DB, zap.NewNop().Sugar()) } -// seedLeveragedResponsibility creates the minimal real-schema graph a -// filter_responsibilities → ssp_leverage_links resolution needs (mirroring -// RiskEvidenceWorkerResponsibilityIntegrationSuite's BCH-1339 helper): an Export with one -// ProvidedControlImplementation and one ControlImplementationResponsibility under it, an -// upstream and downstream SystemSecurityPlan, and an SSPLeverageLink tying the two -// together. -func (suite *InheritedResponsibilityRiskIntegrationSuite) seedLeveragedResponsibility() (responsibilityUUID, downstreamSSPID, upstreamSSPID uuid.UUID) { - export := relational.Export{} - suite.Require().NoError(suite.DB.Create(&export).Error) - - provided := relational.ProvidedControlImplementation{ExportId: *export.ID, Description: "provided"} - suite.Require().NoError(suite.DB.Create(&provided).Error) - - responsibility := relational.ControlImplementationResponsibility{ - ExportId: *export.ID, ProvidedUuid: *provided.ID, Description: "responsibility", - } - suite.Require().NoError(suite.DB.Create(&responsibility).Error) - - upstreamSSP := relational.SystemSecurityPlan{} - suite.Require().NoError(suite.DB.Create(&upstreamSSP).Error) - - downstreamSSP := relational.SystemSecurityPlan{} - suite.Require().NoError(suite.DB.Create(&downstreamSSP).Error) - - link := relational.SSPLeverageLink{ - DownstreamSSPID: *downstreamSSP.ID, - UpstreamSSPID: *upstreamSSP.ID, - OfferingID: uuid.New(), - ControlID: "ac-1", - ProvidedUUID: *provided.ID, - InheritedUUID: uuid.New(), - LeveragedAuthUUID: uuid.New(), - Satisfaction: relational.SSPLeverageSatisfactionPartial, - Status: relational.SSPLeverageStatusActive, - } - suite.Require().NoError(suite.DB.Create(&link).Error) - - return *responsibility.ID, *downstreamSSP.ID, *upstreamSSP.ID -} - -func (suite *InheritedResponsibilityRiskIntegrationSuite) seedResponsibilityFilter(downstreamSSPID, responsibilityUUID uuid.UUID, labelKey, labelValue string) { - filterScope := labelfilter.Filter{ - Scope: &labelfilter.Scope{ - Condition: &labelfilter.Condition{Label: labelKey, Operator: "=", Value: labelValue}, - }, - } - f := relational.Filter{Name: "responsibility-filter", Filter: datatypes.NewJSONType(filterScope)} - suite.Require().NoError(suite.DB.Create(&f).Error) - suite.Require().NoError(suite.DB.Create(&relational.FilterResponsibility{ - FilterID: *f.ID, - ResponsibilityUUID: responsibilityUUID, - SSPID: downstreamSSPID, - }).Error) -} - func (suite *InheritedResponsibilityRiskIntegrationSuite) TestCreatedDedupedAndRemediated() { - responsibilityUUID, downstreamSSPID, upstreamSSPID := suite.seedLeveragedResponsibility() - suite.seedResponsibilityFilter(downstreamSSPID, responsibilityUUID, "environment", "production") + responsibilityUUID, downstreamSSPID, upstreamSSPID := seedLeveragedResponsibility(suite.T(), suite.DB) + seedResponsibilityFilter(suite.T(), suite.DB, downstreamSSPID, responsibilityUUID, "environment", "production") riskTemplate := createTestRiskTemplate(suite.T(), suite.DB) evidence := createTestEvidence(suite.T(), suite.DB) @@ -180,8 +124,8 @@ func (suite *InheritedResponsibilityRiskIntegrationSuite) TestIsolationUpstreamO } func (suite *InheritedResponsibilityRiskIntegrationSuite) TestPromotesToPoamUnchanged() { - responsibilityUUID, downstreamSSPID, _ := suite.seedLeveragedResponsibility() - suite.seedResponsibilityFilter(downstreamSSPID, responsibilityUUID, "environment", "production") + responsibilityUUID, downstreamSSPID, _ := seedLeveragedResponsibility(suite.T(), suite.DB) + seedResponsibilityFilter(suite.T(), suite.DB, downstreamSSPID, responsibilityUUID, "environment", "production") createTestRiskTemplate(suite.T(), suite.DB) evidence := createTestEvidence(suite.T(), suite.DB) diff --git a/internal/service/worker/risk_evidence_worker_responsibility_integration_test.go b/internal/service/worker/risk_evidence_worker_responsibility_integration_test.go index 1f2c4c23..27c21eec 100644 --- a/internal/service/worker/risk_evidence_worker_responsibility_integration_test.go +++ b/internal/service/worker/risk_evidence_worker_responsibility_integration_test.go @@ -6,13 +6,10 @@ import ( "context" "testing" - "github.com/compliance-framework/api/internal/converters/labelfilter" "github.com/compliance-framework/api/internal/service/relational" "github.com/compliance-framework/api/internal/tests" - "github.com/google/uuid" "github.com/stretchr/testify/suite" "go.uber.org/zap" - "gorm.io/datatypes" ) // RiskEvidenceWorkerResponsibilityIntegrationSuite exercises resolveSSPsViaFilters's @@ -31,63 +28,9 @@ func (suite *RiskEvidenceWorkerResponsibilityIntegrationSuite) SetupTest() { suite.Require().NoError(suite.Migrator.Refresh()) } -// seedLeveragedResponsibility creates the minimal real-schema graph a -// filter_responsibilities → ssp_leverage_links resolution needs: an Export with one -// ProvidedControlImplementation and one ControlImplementationResponsibility under it, a -// downstream SystemSecurityPlan, and an SSPLeverageLink tying the two together. Returns -// the responsibility uuid and the downstream SSP id. -func (suite *RiskEvidenceWorkerResponsibilityIntegrationSuite) seedLeveragedResponsibility() (responsibilityUUID uuid.UUID, downstreamSSPID uuid.UUID) { - export := relational.Export{} - suite.Require().NoError(suite.DB.Create(&export).Error) - - provided := relational.ProvidedControlImplementation{ExportId: *export.ID, Description: "provided"} - suite.Require().NoError(suite.DB.Create(&provided).Error) - - responsibility := relational.ControlImplementationResponsibility{ - ExportId: *export.ID, ProvidedUuid: *provided.ID, Description: "responsibility", - } - suite.Require().NoError(suite.DB.Create(&responsibility).Error) - - upstreamSSP := relational.SystemSecurityPlan{} - suite.Require().NoError(suite.DB.Create(&upstreamSSP).Error) - - downstreamSSP := relational.SystemSecurityPlan{} - suite.Require().NoError(suite.DB.Create(&downstreamSSP).Error) - - link := relational.SSPLeverageLink{ - DownstreamSSPID: *downstreamSSP.ID, - UpstreamSSPID: *upstreamSSP.ID, - OfferingID: uuid.New(), - ControlID: "ac-1", - ProvidedUUID: *provided.ID, - InheritedUUID: uuid.New(), - LeveragedAuthUUID: uuid.New(), - Satisfaction: relational.SSPLeverageSatisfactionPartial, - Status: relational.SSPLeverageStatusActive, - } - suite.Require().NoError(suite.DB.Create(&link).Error) - - return *responsibility.ID, *downstreamSSP.ID -} - -func (suite *RiskEvidenceWorkerResponsibilityIntegrationSuite) seedResponsibilityFilter(downstreamSSPID, responsibilityUUID uuid.UUID, labelKey, labelValue string) { - filterScope := labelfilter.Filter{ - Scope: &labelfilter.Scope{ - Condition: &labelfilter.Condition{Label: labelKey, Operator: "=", Value: labelValue}, - }, - } - f := relational.Filter{Name: "responsibility-filter", Filter: datatypes.NewJSONType(filterScope)} - suite.Require().NoError(suite.DB.Create(&f).Error) - suite.Require().NoError(suite.DB.Create(&relational.FilterResponsibility{ - FilterID: *f.ID, - ResponsibilityUUID: responsibilityUUID, - SSPID: downstreamSSPID, - }).Error) -} - func (suite *RiskEvidenceWorkerResponsibilityIntegrationSuite) TestResolvesDownstreamSSPViaFilterResponsibilities() { - responsibilityUUID, downstreamSSPID := suite.seedLeveragedResponsibility() - suite.seedResponsibilityFilter(downstreamSSPID, responsibilityUUID, "environment", "production") + responsibilityUUID, downstreamSSPID, _ := seedLeveragedResponsibility(suite.T(), suite.DB) + seedResponsibilityFilter(suite.T(), suite.DB, downstreamSSPID, responsibilityUUID, "environment", "production") worker := NewRiskEvidenceWorker(suite.DB, zap.NewNop().Sugar()) sspInfos, responsibilityInfos, err := worker.resolveSSPsViaFilters(context.Background(), []relational.Labels{ @@ -101,8 +44,8 @@ func (suite *RiskEvidenceWorkerResponsibilityIntegrationSuite) TestResolvesDowns } func (suite *RiskEvidenceWorkerResponsibilityIntegrationSuite) TestNoMatchReturnsNoSSPs() { - responsibilityUUID, downstreamSSPID := suite.seedLeveragedResponsibility() - suite.seedResponsibilityFilter(downstreamSSPID, responsibilityUUID, "environment", "staging") + responsibilityUUID, downstreamSSPID, _ := seedLeveragedResponsibility(suite.T(), suite.DB) + seedResponsibilityFilter(suite.T(), suite.DB, downstreamSSPID, responsibilityUUID, "environment", "staging") worker := NewRiskEvidenceWorker(suite.DB, zap.NewNop().Sugar()) sspInfos, responsibilityInfos, err := worker.resolveSSPsViaFilters(context.Background(), []relational.Labels{ diff --git a/internal/service/worker/risk_evidence_worker_test_helpers_test.go b/internal/service/worker/risk_evidence_worker_test_helpers_test.go new file mode 100644 index 00000000..76375070 --- /dev/null +++ b/internal/service/worker/risk_evidence_worker_test_helpers_test.go @@ -0,0 +1,75 @@ +//go:build integration + +package worker + +import ( + "testing" + + "github.com/compliance-framework/api/internal/converters/labelfilter" + "github.com/compliance-framework/api/internal/service/relational" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +// seedLeveragedResponsibility creates the minimal real-schema graph a +// filter_responsibilities → ssp_leverage_links resolution needs: an Export with one +// ProvidedControlImplementation and one ControlImplementationResponsibility under it, an +// upstream and downstream SystemSecurityPlan, and an SSPLeverageLink tying the two +// together. Shared by RiskEvidenceWorkerResponsibilityIntegrationSuite (BCH-1339) and +// InheritedResponsibilityRiskIntegrationSuite (BCH-1340). +func seedLeveragedResponsibility(t *testing.T, db *gorm.DB) (responsibilityUUID, downstreamSSPID, upstreamSSPID uuid.UUID) { + t.Helper() + + export := relational.Export{} + require.NoError(t, db.Create(&export).Error) + + provided := relational.ProvidedControlImplementation{ExportId: *export.ID, Description: "provided"} + require.NoError(t, db.Create(&provided).Error) + + responsibility := relational.ControlImplementationResponsibility{ + ExportId: *export.ID, ProvidedUuid: *provided.ID, Description: "responsibility", + } + require.NoError(t, db.Create(&responsibility).Error) + + upstreamSSP := relational.SystemSecurityPlan{} + require.NoError(t, db.Create(&upstreamSSP).Error) + + downstreamSSP := relational.SystemSecurityPlan{} + require.NoError(t, db.Create(&downstreamSSP).Error) + + link := relational.SSPLeverageLink{ + DownstreamSSPID: *downstreamSSP.ID, + UpstreamSSPID: *upstreamSSP.ID, + OfferingID: uuid.New(), + ControlID: "ac-1", + ProvidedUUID: *provided.ID, + InheritedUUID: uuid.New(), + LeveragedAuthUUID: uuid.New(), + Satisfaction: relational.SSPLeverageSatisfactionPartial, + Status: relational.SSPLeverageStatusActive, + } + require.NoError(t, db.Create(&link).Error) + + return *responsibility.ID, *downstreamSSP.ID, *upstreamSSP.ID +} + +// seedResponsibilityFilter creates a filter matching labelKey=labelValue and links it to +// responsibilityUUID, scoped to downstreamSSPID, via filter_responsibilities. +func seedResponsibilityFilter(t *testing.T, db *gorm.DB, downstreamSSPID, responsibilityUUID uuid.UUID, labelKey, labelValue string) { + t.Helper() + + filterScope := labelfilter.Filter{ + Scope: &labelfilter.Scope{ + Condition: &labelfilter.Condition{Label: labelKey, Operator: "=", Value: labelValue}, + }, + } + f := relational.Filter{Name: "responsibility-filter", Filter: datatypes.NewJSONType(filterScope)} + require.NoError(t, db.Create(&f).Error) + require.NoError(t, db.Create(&relational.FilterResponsibility{ + FilterID: *f.ID, + ResponsibilityUUID: responsibilityUUID, + SSPID: downstreamSSPID, + }).Error) +}