diff --git a/docs/docs.go b/docs/docs.go index bf3826d0..9b95a508 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -39150,6 +39150,13 @@ const docTemplate = `{ "controlId": { "type": "string" }, + "driftRiskId": { + "description": "DriftRiskID is the open drift risk for this link (BCH-1341's applyDriftToLink /\ncomputeDedupeKeyForLeverageDrift convention), set only when Status is Drifted and a\nmatching risk is still open — nil otherwise (including for Revoked, which has no\nre-attest path and thus no risk to link).", + "type": "string" + }, + "id": { + "type": "string" + }, "inheritedFrom": { "$ref": "#/definitions/oscal.leveragedControlInheritedFrom" }, @@ -39171,6 +39178,9 @@ const docTemplate = `{ }, "statementId": { "type": "string" + }, + "status": { + "$ref": "#/definitions/relational.SSPLeverageStatus" } } }, diff --git a/docs/swagger.json b/docs/swagger.json index ab6a7593..d43204c9 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -39144,6 +39144,13 @@ "controlId": { "type": "string" }, + "driftRiskId": { + "description": "DriftRiskID is the open drift risk for this link (BCH-1341's applyDriftToLink /\ncomputeDedupeKeyForLeverageDrift convention), set only when Status is Drifted and a\nmatching risk is still open — nil otherwise (including for Revoked, which has no\nre-attest path and thus no risk to link).", + "type": "string" + }, + "id": { + "type": "string" + }, "inheritedFrom": { "$ref": "#/definitions/oscal.leveragedControlInheritedFrom" }, @@ -39165,6 +39172,9 @@ }, "statementId": { "type": "string" + }, + "status": { + "$ref": "#/definitions/relational.SSPLeverageStatus" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 244aa571..2e145a71 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -4395,6 +4395,15 @@ definitions: properties: controlId: type: string + driftRiskId: + description: |- + DriftRiskID is the open drift risk for this link (BCH-1341's applyDriftToLink / + computeDedupeKeyForLeverageDrift convention), set only when Status is Drifted and a + matching risk is still open — nil otherwise (including for Revoked, which has no + re-attest path and thus no risk to link). + type: string + id: + type: string inheritedFrom: $ref: '#/definitions/oscal.leveragedControlInheritedFrom' outstandingResponsibilities: @@ -4415,6 +4424,8 @@ definitions: $ref: '#/definitions/relational.SSPLeverageSatisfaction' statementId: type: string + status: + $ref: '#/definitions/relational.SSPLeverageStatus' type: object oscal.profileSummary: properties: diff --git a/internal/api/handler/oscal/ssp_leverage.go b/internal/api/handler/oscal/ssp_leverage.go index fbbb8179..eabb20a7 100644 --- a/internal/api/handler/oscal/ssp_leverage.go +++ b/internal/api/handler/oscal/ssp_leverage.go @@ -677,10 +677,12 @@ type leveragedControlInheritedFrom struct { } type leveragedControlResponse struct { + ID uuid.UUID `json:"id"` ControlID string `json:"controlId"` StatementID *string `json:"statementId,omitempty"` InheritedFrom leveragedControlInheritedFrom `json:"inheritedFrom"` Satisfaction relational.SSPLeverageSatisfaction `json:"satisfaction"` + Status relational.SSPLeverageStatus `json:"status"` OutstandingResponsibilities []upstreamResponsibility `json:"outstandingResponsibilities"` // ResponsibilityPosture is the live, evidence-backed posture (satisfied / // not-satisfied / unknown) per upstream responsibility uuid under this link's @@ -688,6 +690,11 @@ type leveragedControlResponse struct { // Satisfaction/OutstandingResponsibilities above (which reflect what was attested at // subscribe time, not current evidence). ResponsibilityPosture map[uuid.UUID]string `json:"responsibilityPosture"` + // DriftRiskID is the open drift risk for this link (BCH-1341's applyDriftToLink / + // computeDedupeKeyForLeverageDrift convention), set only when Status is Drifted and a + // matching risk is still open — nil otherwise (including for Revoked, which has no + // re-attest path and thus no risk to link). + DriftRiskID *uuid.UUID `json:"driftRiskId,omitempty"` } // LeveragedControls godoc @@ -797,6 +804,35 @@ func (h *SSPLeverageHandler) LeveragedControls(ctx echo.Context) error { return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } + // Batch-resolve every drifted link's open drift risk in one query, keyed by the + // dedupe_key convention computeDedupeKeyForLeverageDrift/applyDriftToLink already use — + // rather than a lookup per drifted link. + dedupeKeyToLinkID := make(map[string]uuid.UUID) + dedupeKeys := make([]string, 0, len(links)) + for _, link := range links { + if link.Status != relational.SSPLeverageStatusDrifted { + continue + } + key := computeDedupeKeyForLeverageDrift(*link.ID) + dedupeKeyToLinkID[key] = *link.ID + dedupeKeys = append(dedupeKeys, key) + } + driftRiskIDByLinkID := make(map[uuid.UUID]uuid.UUID, len(dedupeKeys)) + if len(dedupeKeys) > 0 { + var driftRisks []risks.Risk + if err := h.db.Select("id, dedupe_key"). + Where("ssp_id = ? AND dedupe_key IN ? AND status != ?", sspID, dedupeKeys, risks.RiskStatusClosed). + Find(&driftRisks).Error; err != nil { + h.sugar.Errorf("Failed to load drift risks for leverage links: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + for _, r := range driftRisks { + if linkID, ok := dedupeKeyToLinkID[r.DedupeKey]; ok { + driftRiskIDByLinkID[linkID] = *r.ID + } + } + } + result := make([]leveragedControlResponse, 0, len(links)) for _, link := range links { byComponentID := byComponentIDByInherited[link.InheritedUUID] @@ -808,7 +844,13 @@ func (h *SSPLeverageHandler) LeveragedControls(ctx echo.Context) error { linkPosture[r.ResponsibilityUUID] = posture[r.ResponsibilityUUID] } + var driftRiskID *uuid.UUID + if id, ok := driftRiskIDByLinkID[*link.ID]; ok { + driftRiskID = &id + } + result = append(result, leveragedControlResponse{ + ID: *link.ID, ControlID: link.ControlID, StatementID: link.StatementID, InheritedFrom: leveragedControlInheritedFrom{ @@ -818,8 +860,10 @@ func (h *SSPLeverageHandler) LeveragedControls(ctx echo.Context) error { OfferingVersion: link.OfferingVersion, }, Satisfaction: satisfaction, + Status: link.Status, OutstandingResponsibilities: outstanding, ResponsibilityPosture: linkPosture, + DriftRiskID: driftRiskID, }) } diff --git a/internal/api/handler/oscal/ssp_leverage_reattest_test.go b/internal/api/handler/oscal/ssp_leverage_reattest_test.go index 2bf4d7e7..7d994f49 100644 --- a/internal/api/handler/oscal/ssp_leverage_reattest_test.go +++ b/internal/api/handler/oscal/ssp_leverage_reattest_test.go @@ -1,6 +1,7 @@ package oscal import ( + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -75,6 +76,118 @@ func TestReAttestClearsDriftAndRemediatesRisk(t *testing.T) { require.Equal(t, string(risks.RiskStatusRemediated), risk.Status) } +// TestLeveragedControlsIncludesIdStatusAndDriftRiskId: BCH-1346 needs the link's own id +// (to call ReAttest) and status (to know a link is drifted at all) on the projection +// response — neither existed before. An active link has both but no driftRiskId; a +// drifted link's driftRiskId points at its still-open drift risk; a revoked link (no +// current code path sets this status, but the type allows it) has status but no +// driftRiskId, matching drift risk's own reattest-only-from-drifted invariant. +func TestLeveragedControlsIncludesIdStatusAndDriftRiskId(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newLeverageFixture(t, db) + link, riskID := subscribeAndDrift(t, db, fx) + require.Equal(t, relational.SSPLeverageStatusDrifted, link.Status) + + h := NewSSPLeverageHandler(zap.NewNop().Sugar(), db, &stubPDP{allow: true}, authz.FailClosed) + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + ctx.SetParamNames("id") + ctx.SetParamValues(fx.downstreamSSPID.String()) + + require.NoError(t, h.LeveragedControls(ctx)) + require.Equal(t, http.StatusOK, rec.Code) + + var parsed struct { + Data []leveragedControlResponse `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &parsed)) + require.Len(t, parsed.Data, 1) + require.Equal(t, *link.ID, parsed.Data[0].ID) + require.Equal(t, relational.SSPLeverageStatusDrifted, parsed.Data[0].Status) + require.NotNil(t, parsed.Data[0].DriftRiskID) + require.Equal(t, riskID, *parsed.Data[0].DriftRiskID) +} + +// TestLeveragedControlsActiveLinkHasNoDriftRiskId: an active (non-drifted) link's +// projection entry carries its own id/status but no driftRiskId — there's no drift risk +// to link because nothing drifted. +func TestLeveragedControlsActiveLinkHasNoDriftRiskId(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newLeverageFixture(t, db) + + pdp := &stubPDP{allow: true} + subscribeHandler := NewSSPLeverageHandler(zap.NewNop().Sugar(), db, pdp, authz.FailClosed) + body := subscribeBody(fx.downstreamSSPID, fx.itemID, fx.respAID) + subCtx, _, subRec := newSubscribeRequestContext(fx.offeringID, body) + require.NoError(t, subscribeHandler.Subscribe(subCtx)) + require.Equal(t, http.StatusCreated, subRec.Code) + + var link relational.SSPLeverageLink + require.NoError(t, db.Where("downstream_ssp_id = ?", fx.downstreamSSPID).First(&link).Error) + + h := NewSSPLeverageHandler(zap.NewNop().Sugar(), db, &stubPDP{allow: true}, authz.FailClosed) + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + ctx.SetParamNames("id") + ctx.SetParamValues(fx.downstreamSSPID.String()) + + require.NoError(t, h.LeveragedControls(ctx)) + require.Equal(t, http.StatusOK, rec.Code) + + var parsed struct { + Data []leveragedControlResponse `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &parsed)) + require.Len(t, parsed.Data, 1) + require.Equal(t, *link.ID, parsed.Data[0].ID) + require.Equal(t, relational.SSPLeverageStatusActive, parsed.Data[0].Status) + require.Nil(t, parsed.Data[0].DriftRiskID) +} + +// TestLeveragedControlsRevokedLinkHasNoDriftRiskId: no current code path sets a leverage +// link to Revoked, but the status exists on the type and BCH-1346's UI must handle it — +// manually flipping status confirms the projection still omits driftRiskId (matching +// ReAttest's own drifted-only precondition: a revoked link was never drifted, so it has +// no drift risk to find via the dedupe-key lookup). +func TestLeveragedControlsRevokedLinkHasNoDriftRiskId(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newLeverageFixture(t, db) + + pdp := &stubPDP{allow: true} + subscribeHandler := NewSSPLeverageHandler(zap.NewNop().Sugar(), db, pdp, authz.FailClosed) + body := subscribeBody(fx.downstreamSSPID, fx.itemID, fx.respAID) + subCtx, _, subRec := newSubscribeRequestContext(fx.offeringID, body) + require.NoError(t, subscribeHandler.Subscribe(subCtx)) + require.Equal(t, http.StatusCreated, subRec.Code) + + require.NoError(t, db.Model(&relational.SSPLeverageLink{}). + Where("downstream_ssp_id = ?", fx.downstreamSSPID). + Update("status", relational.SSPLeverageStatusRevoked).Error) + + h := NewSSPLeverageHandler(zap.NewNop().Sugar(), db, &stubPDP{allow: true}, authz.FailClosed) + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + ctx.SetParamNames("id") + ctx.SetParamValues(fx.downstreamSSPID.String()) + + require.NoError(t, h.LeveragedControls(ctx)) + require.Equal(t, http.StatusOK, rec.Code) + + var parsed struct { + Data []leveragedControlResponse `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &parsed)) + require.Len(t, parsed.Data, 1) + require.Equal(t, relational.SSPLeverageStatusRevoked, parsed.Data[0].Status) + require.Nil(t, parsed.Data[0].DriftRiskID) +} + func TestReAttestRejectsNonDriftedLink(t *testing.T) { db := newSSPLeverageTestDB(t) fx := newLeverageFixture(t, db)