From 31b1e7526f45d3dc872a22e357f5e3b3797b41d1 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:45:16 -0300 Subject: [PATCH 1/3] implement: surface cross-SSP inherited controls in compliance + lineage --- docs/docs.go | 282 ++++++++++++- docs/swagger.json | 282 ++++++++++++- docs/swagger.yaml | 205 ++++++++- internal/api/handler/lineage.go | 143 ++++++- internal/api/handler/lineage_leverage.go | 212 ++++++++++ .../lineage_leverage_integration_test.go | 391 ++++++++++++++++++ internal/api/handler/lineage_test.go | 36 +- .../api/handler/oscal/profile_compliance.go | 236 ++++++----- ...le_compliance_leverage_integration_test.go | 200 +++++++++ internal/api/handler/oscal/ssp_leverage.go | 258 ++---------- .../api/handler/oscal/ssp_leverage_drift.go | 12 +- internal/service/leverage/leverage_test.go | 228 ++++++++++ internal/service/leverage/posture.go | 75 ++++ internal/service/leverage/project.go | 198 +++++++++ internal/service/leverage/resolve.go | 90 ++++ internal/service/leverage/summary.go | 263 ++++++++++++ internal/service/leverage/types.go | 70 ++++ internal/service/relational/evidence.go | 62 +++ 18 files changed, 2856 insertions(+), 387 deletions(-) create mode 100644 internal/api/handler/lineage_leverage.go create mode 100644 internal/api/handler/lineage_leverage_integration_test.go create mode 100644 internal/api/handler/oscal/profile_compliance_leverage_integration_test.go create mode 100644 internal/service/leverage/leverage_test.go create mode 100644 internal/service/leverage/posture.go create mode 100644 internal/service/leverage/project.go create mode 100644 internal/service/leverage/resolve.go create mode 100644 internal/service/leverage/summary.go create mode 100644 internal/service/leverage/types.go diff --git a/docs/docs.go b/docs/docs.go index 82738b39..af3a5c65 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -4530,6 +4530,64 @@ const docTemplate = `{ ] } }, + "/lineage/nodes/{key}/leverage": { + "get": { + "description": "Returns, for a control node, one row per downstream System Security Plan that\ninherits the control from an upstream offering: every leverage link with its\nupstream origin (SSP + offering titles), full and outstanding responsibilities,\nlive per-responsibility posture, and any open drift risk. Powers the lineage\ndrawer's inherited-capability panel. Only control keys\n(control:\u003ccatalogId\u003e/\u003ccontrolId\u003e) are supported; other node kinds return 400.\nOptional sspId filters to a single downstream SSP.", + "produces": [ + "application/json" + ], + "tags": [ + "Lineage" + ], + "summary": "Per-SSP inherited leverage detail for a control node", + "parameters": [ + { + "type": "string", + "description": "URL-encoded control node key", + "name": "key", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Filter to a single downstream SSP", + "name": "sspId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/service.ListResponse-handler_LineageLeverageRow" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/lineage/nodes/{key}/ssps": { "get": { "description": "Returns, for a control node, one row per System Security Plan: whether the control is in that plan's profile and its rolled-up posture, evidence status and declared implementation status there. Powers the drawer's plan-by-plan table. Only control keys (control:\u003ccatalogId\u003e/\u003ccontrolId\u003e) are supported; other node kinds return 400.", @@ -15324,7 +15382,7 @@ const docTemplate = `{ }, "/oscal/profiles/{id}/compliance-progress": { "get": { - "description": "Returns aggregated compliance progress for controls in a Profile, including summary, optional per-control rows, and group rollups.", + "description": "Returns aggregated compliance progress for controls in a Profile, including summary, optional per-control rows, and group rollups.\nWhen sspId is supplied, controls inherited from an upstream leverage link (all links active, all live-derived full, no decisive downstream evidence, in scope) are credited to a distinct \"inherited\" bucket and carry a leverage badge payload. Inherited counts as compliant in compliancePercent and as assessed in assessedPercent; evidence always wins over inherited credit. Without sspId, inherited is 0 and no leverage payloads are emitted.", "produces": [ "application/json" ], @@ -37178,6 +37236,9 @@ const docTemplate = `{ "compliancePercent": { "type": "number" }, + "inherited": { + "type": "integer" + }, "notSatisfied": { "type": "integer" }, @@ -37192,6 +37253,114 @@ const docTemplate = `{ } } }, + "handler.LineageLeverageInheritedFrom": { + "type": "object", + "properties": { + "offeringId": { + "type": "string" + }, + "offeringTitle": { + "type": "string" + }, + "offeringVersion": { + "type": "integer" + }, + "upstreamSspId": { + "type": "string" + }, + "upstreamSspTitle": { + "type": "string" + } + } + }, + "handler.LineageLeverageLink": { + "type": "object", + "properties": { + "byComponentId": { + "type": "string" + }, + "controlId": { + "type": "string" + }, + "driftRiskId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "inheritedFrom": { + "$ref": "#/definitions/handler.LineageLeverageInheritedFrom" + }, + "outstandingResponsibilities": { + "type": "array", + "items": { + "$ref": "#/definitions/leverage.Responsibility" + } + }, + "providedUuid": { + "type": "string" + }, + "responsibilities": { + "type": "array", + "items": { + "$ref": "#/definitions/leverage.Responsibility" + } + }, + "responsibilityPosture": { + "description": "ResponsibilityPosture is keyed by responsibility UUID. UI must fence these keys\nfrom camelCasing.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "satisfaction": { + "$ref": "#/definitions/relational.SSPLeverageSatisfaction" + }, + "statementId": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/relational.SSPLeverageStatus" + } + } + }, + "handler.LineageLeverageRow": { + "type": "object", + "properties": { + "links": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.LineageLeverageLink" + } + }, + "sspId": { + "type": "string" + }, + "sspTitle": { + "type": "string" + } + } + }, + "handler.LineageLeverageSummary": { + "type": "object", + "properties": { + "links": { + "type": "integer" + }, + "outstandingCount": { + "type": "integer" + }, + "satisfaction": { + "type": "string" + }, + "status": { + "type": "string" + }, + "totalResponsibilities": { + "type": "integer" + } + } + }, "handler.LineageLinkage": { "type": "object", "properties": { @@ -37332,6 +37501,9 @@ const docTemplate = `{ "attention": { "type": "integer" }, + "inherited": { + "type": "integer" + }, "notApplicable": { "type": "integer" }, @@ -37389,6 +37561,9 @@ const docTemplate = `{ "attention": { "type": "integer" }, + "inherited": { + "type": "integer" + }, "notApplicable": { "type": "integer" }, @@ -37421,6 +37596,9 @@ const docTemplate = `{ "inProfile": { "type": "boolean" }, + "leverage": { + "$ref": "#/definitions/handler.LineageLeverageSummary" + }, "posture": { "type": "string" }, @@ -37444,6 +37622,9 @@ const docTemplate = `{ "inProfile": { "type": "boolean" }, + "leverage": { + "$ref": "#/definitions/handler.LineageLeverageSummary" + }, "posture": { "type": "string" } @@ -38968,6 +39149,17 @@ const docTemplate = `{ } } }, + "leverage.Responsibility": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "responsibilityUuid": { + "type": "string" + } + } + }, "notificationtroubleshooting.ConfiguredSystemDestinationResponse": { "type": "object", "properties": { @@ -40002,6 +40194,14 @@ const docTemplate = `{ "implemented": { "type": "boolean" }, + "leverage": { + "description": "Leverage carries the badge/tooltip payload for an inherited (or leverage-linked but\nuncredited) control. Present only when sspId is supplied and the control has at\nleast one leverage link; omitted otherwise.", + "allOf": [ + { + "$ref": "#/definitions/oscal.ProfileComplianceControlLeverage" + } + ] + }, "statusCounts": { "type": "array", "items": { @@ -40013,6 +40213,35 @@ const docTemplate = `{ } } }, + "oscal.ProfileComplianceControlLeverage": { + "type": "object", + "properties": { + "inherited": { + "type": "boolean" + }, + "inheritedFrom": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.ProfileComplianceLeverageOrigin" + } + }, + "links": { + "type": "integer" + }, + "outstandingCount": { + "type": "integer" + }, + "satisfaction": { + "$ref": "#/definitions/relational.SSPLeverageSatisfaction" + }, + "status": { + "$ref": "#/definitions/relational.SSPLeverageStatus" + }, + "totalResponsibilities": { + "type": "integer" + } + } + }, "oscal.ProfileComplianceGroup": { "type": "object", "properties": { @@ -40022,6 +40251,10 @@ const docTemplate = `{ "id": { "type": "string" }, + "inherited": { + "description": "Inherited counts inherited-credited controls in this group; it counts as compliant\nin CompliancePercent (same rule as the summary).", + "type": "integer" + }, "notSatisfied": { "type": "integer" }, @@ -40053,6 +40286,26 @@ const docTemplate = `{ } } }, + "oscal.ProfileComplianceLeverageOrigin": { + "type": "object", + "properties": { + "offeringId": { + "type": "string" + }, + "offeringTitle": { + "type": "string" + }, + "offeringVersion": { + "type": "integer" + }, + "upstreamSspId": { + "type": "string" + }, + "upstreamSspTitle": { + "type": "string" + } + } + }, "oscal.ProfileComplianceProgress": { "type": "object", "properties": { @@ -40116,6 +40369,10 @@ const docTemplate = `{ "implementedControls": { "type": "integer" }, + "inherited": { + "description": "Inherited counts controls credited to an upstream leverage link (all links active,\nall live-derived full, no decisive downstream evidence). It counts as compliant in\nCompliancePercent and as assessed in AssessedPercent; it is populated only when\nsspId is supplied, and is emitted as 0 (never omitted) otherwise.", + "type": "integer" + }, "notSatisfied": { "type": "integer" }, @@ -49021,6 +49278,29 @@ const docTemplate = `{ } } }, + "service.ListResponse-handler_LineageLeverageRow": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.LineageLeverageRow" + } + }, + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, "service.ListResponse-handler_LineageNode": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 62e77d4d..a7e7eaaa 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -4524,6 +4524,64 @@ ] } }, + "/lineage/nodes/{key}/leverage": { + "get": { + "description": "Returns, for a control node, one row per downstream System Security Plan that\ninherits the control from an upstream offering: every leverage link with its\nupstream origin (SSP + offering titles), full and outstanding responsibilities,\nlive per-responsibility posture, and any open drift risk. Powers the lineage\ndrawer's inherited-capability panel. Only control keys\n(control:\u003ccatalogId\u003e/\u003ccontrolId\u003e) are supported; other node kinds return 400.\nOptional sspId filters to a single downstream SSP.", + "produces": [ + "application/json" + ], + "tags": [ + "Lineage" + ], + "summary": "Per-SSP inherited leverage detail for a control node", + "parameters": [ + { + "type": "string", + "description": "URL-encoded control node key", + "name": "key", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Filter to a single downstream SSP", + "name": "sspId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/service.ListResponse-handler_LineageLeverageRow" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/lineage/nodes/{key}/ssps": { "get": { "description": "Returns, for a control node, one row per System Security Plan: whether the control is in that plan's profile and its rolled-up posture, evidence status and declared implementation status there. Powers the drawer's plan-by-plan table. Only control keys (control:\u003ccatalogId\u003e/\u003ccontrolId\u003e) are supported; other node kinds return 400.", @@ -15318,7 +15376,7 @@ }, "/oscal/profiles/{id}/compliance-progress": { "get": { - "description": "Returns aggregated compliance progress for controls in a Profile, including summary, optional per-control rows, and group rollups.", + "description": "Returns aggregated compliance progress for controls in a Profile, including summary, optional per-control rows, and group rollups.\nWhen sspId is supplied, controls inherited from an upstream leverage link (all links active, all live-derived full, no decisive downstream evidence, in scope) are credited to a distinct \"inherited\" bucket and carry a leverage badge payload. Inherited counts as compliant in compliancePercent and as assessed in assessedPercent; evidence always wins over inherited credit. Without sspId, inherited is 0 and no leverage payloads are emitted.", "produces": [ "application/json" ], @@ -37172,6 +37230,9 @@ "compliancePercent": { "type": "number" }, + "inherited": { + "type": "integer" + }, "notSatisfied": { "type": "integer" }, @@ -37186,6 +37247,114 @@ } } }, + "handler.LineageLeverageInheritedFrom": { + "type": "object", + "properties": { + "offeringId": { + "type": "string" + }, + "offeringTitle": { + "type": "string" + }, + "offeringVersion": { + "type": "integer" + }, + "upstreamSspId": { + "type": "string" + }, + "upstreamSspTitle": { + "type": "string" + } + } + }, + "handler.LineageLeverageLink": { + "type": "object", + "properties": { + "byComponentId": { + "type": "string" + }, + "controlId": { + "type": "string" + }, + "driftRiskId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "inheritedFrom": { + "$ref": "#/definitions/handler.LineageLeverageInheritedFrom" + }, + "outstandingResponsibilities": { + "type": "array", + "items": { + "$ref": "#/definitions/leverage.Responsibility" + } + }, + "providedUuid": { + "type": "string" + }, + "responsibilities": { + "type": "array", + "items": { + "$ref": "#/definitions/leverage.Responsibility" + } + }, + "responsibilityPosture": { + "description": "ResponsibilityPosture is keyed by responsibility UUID. UI must fence these keys\nfrom camelCasing.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "satisfaction": { + "$ref": "#/definitions/relational.SSPLeverageSatisfaction" + }, + "statementId": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/relational.SSPLeverageStatus" + } + } + }, + "handler.LineageLeverageRow": { + "type": "object", + "properties": { + "links": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.LineageLeverageLink" + } + }, + "sspId": { + "type": "string" + }, + "sspTitle": { + "type": "string" + } + } + }, + "handler.LineageLeverageSummary": { + "type": "object", + "properties": { + "links": { + "type": "integer" + }, + "outstandingCount": { + "type": "integer" + }, + "satisfaction": { + "type": "string" + }, + "status": { + "type": "string" + }, + "totalResponsibilities": { + "type": "integer" + } + } + }, "handler.LineageLinkage": { "type": "object", "properties": { @@ -37326,6 +37495,9 @@ "attention": { "type": "integer" }, + "inherited": { + "type": "integer" + }, "notApplicable": { "type": "integer" }, @@ -37383,6 +37555,9 @@ "attention": { "type": "integer" }, + "inherited": { + "type": "integer" + }, "notApplicable": { "type": "integer" }, @@ -37415,6 +37590,9 @@ "inProfile": { "type": "boolean" }, + "leverage": { + "$ref": "#/definitions/handler.LineageLeverageSummary" + }, "posture": { "type": "string" }, @@ -37438,6 +37616,9 @@ "inProfile": { "type": "boolean" }, + "leverage": { + "$ref": "#/definitions/handler.LineageLeverageSummary" + }, "posture": { "type": "string" } @@ -38962,6 +39143,17 @@ } } }, + "leverage.Responsibility": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "responsibilityUuid": { + "type": "string" + } + } + }, "notificationtroubleshooting.ConfiguredSystemDestinationResponse": { "type": "object", "properties": { @@ -39996,6 +40188,14 @@ "implemented": { "type": "boolean" }, + "leverage": { + "description": "Leverage carries the badge/tooltip payload for an inherited (or leverage-linked but\nuncredited) control. Present only when sspId is supplied and the control has at\nleast one leverage link; omitted otherwise.", + "allOf": [ + { + "$ref": "#/definitions/oscal.ProfileComplianceControlLeverage" + } + ] + }, "statusCounts": { "type": "array", "items": { @@ -40007,6 +40207,35 @@ } } }, + "oscal.ProfileComplianceControlLeverage": { + "type": "object", + "properties": { + "inherited": { + "type": "boolean" + }, + "inheritedFrom": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.ProfileComplianceLeverageOrigin" + } + }, + "links": { + "type": "integer" + }, + "outstandingCount": { + "type": "integer" + }, + "satisfaction": { + "$ref": "#/definitions/relational.SSPLeverageSatisfaction" + }, + "status": { + "$ref": "#/definitions/relational.SSPLeverageStatus" + }, + "totalResponsibilities": { + "type": "integer" + } + } + }, "oscal.ProfileComplianceGroup": { "type": "object", "properties": { @@ -40016,6 +40245,10 @@ "id": { "type": "string" }, + "inherited": { + "description": "Inherited counts inherited-credited controls in this group; it counts as compliant\nin CompliancePercent (same rule as the summary).", + "type": "integer" + }, "notSatisfied": { "type": "integer" }, @@ -40047,6 +40280,26 @@ } } }, + "oscal.ProfileComplianceLeverageOrigin": { + "type": "object", + "properties": { + "offeringId": { + "type": "string" + }, + "offeringTitle": { + "type": "string" + }, + "offeringVersion": { + "type": "integer" + }, + "upstreamSspId": { + "type": "string" + }, + "upstreamSspTitle": { + "type": "string" + } + } + }, "oscal.ProfileComplianceProgress": { "type": "object", "properties": { @@ -40110,6 +40363,10 @@ "implementedControls": { "type": "integer" }, + "inherited": { + "description": "Inherited counts controls credited to an upstream leverage link (all links active,\nall live-derived full, no decisive downstream evidence). It counts as compliant in\nCompliancePercent and as assessed in AssessedPercent; it is populated only when\nsspId is supplied, and is emitted as 0 (never omitted) otherwise.", + "type": "integer" + }, "notSatisfied": { "type": "integer" }, @@ -49015,6 +49272,29 @@ } } }, + "service.ListResponse-handler_LineageLeverageRow": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.LineageLeverageRow" + } + }, + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, "service.ListResponse-handler_LineageNode": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index f760b49c..4f1332b0 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2068,6 +2068,8 @@ definitions: type: number compliancePercent: type: number + inherited: + type: integer notSatisfied: type: integer satisfied: @@ -2077,6 +2079,79 @@ definitions: unknown: type: integer type: object + handler.LineageLeverageInheritedFrom: + properties: + offeringId: + type: string + offeringTitle: + type: string + offeringVersion: + type: integer + upstreamSspId: + type: string + upstreamSspTitle: + type: string + type: object + handler.LineageLeverageLink: + properties: + byComponentId: + type: string + controlId: + type: string + driftRiskId: + type: string + id: + type: string + inheritedFrom: + $ref: '#/definitions/handler.LineageLeverageInheritedFrom' + outstandingResponsibilities: + items: + $ref: '#/definitions/leverage.Responsibility' + type: array + providedUuid: + type: string + responsibilities: + items: + $ref: '#/definitions/leverage.Responsibility' + type: array + responsibilityPosture: + additionalProperties: + type: string + description: |- + ResponsibilityPosture is keyed by responsibility UUID. UI must fence these keys + from camelCasing. + type: object + satisfaction: + $ref: '#/definitions/relational.SSPLeverageSatisfaction' + statementId: + type: string + status: + $ref: '#/definitions/relational.SSPLeverageStatus' + type: object + handler.LineageLeverageRow: + properties: + links: + items: + $ref: '#/definitions/handler.LineageLeverageLink' + type: array + sspId: + type: string + sspTitle: + type: string + type: object + handler.LineageLeverageSummary: + properties: + links: + type: integer + outstandingCount: + type: integer + satisfaction: + type: string + status: + type: string + totalResponsibilities: + type: integer + type: object handler.LineageLinkage: properties: operationalControls: @@ -2182,6 +2257,8 @@ definitions: properties: attention: type: integer + inherited: + type: integer notApplicable: type: integer notSatisfied: @@ -2219,6 +2296,8 @@ definitions: properties: attention: type: integer + inherited: + type: integer notApplicable: type: integer notSatisfied: @@ -2240,6 +2319,8 @@ definitions: type: string inProfile: type: boolean + leverage: + $ref: '#/definitions/handler.LineageLeverageSummary' posture: type: string sspId: @@ -2255,6 +2336,8 @@ definitions: type: string inProfile: type: boolean + leverage: + $ref: '#/definitions/handler.LineageLeverageSummary' posture: type: string type: object @@ -3292,6 +3375,13 @@ definitions: query: $ref: '#/definitions/labelfilter.Query' type: object + leverage.Responsibility: + properties: + description: + type: string + responsibilityUuid: + type: string + type: object notificationtroubleshooting.ConfiguredSystemDestinationResponse: properties: destinationTarget: @@ -3976,6 +4066,13 @@ definitions: type: string implemented: type: boolean + leverage: + allOf: + - $ref: '#/definitions/oscal.ProfileComplianceControlLeverage' + description: |- + Leverage carries the badge/tooltip payload for an inherited (or leverage-linked but + uncredited) control. Present only when sspId is supplied and the control has at + least one leverage link; omitted otherwise. statusCounts: items: $ref: '#/definitions/oscal.ProfileComplianceStatusCount' @@ -3983,12 +4080,36 @@ definitions: title: type: string type: object + oscal.ProfileComplianceControlLeverage: + properties: + inherited: + type: boolean + inheritedFrom: + items: + $ref: '#/definitions/oscal.ProfileComplianceLeverageOrigin' + type: array + links: + type: integer + outstandingCount: + type: integer + satisfaction: + $ref: '#/definitions/relational.SSPLeverageSatisfaction' + status: + $ref: '#/definitions/relational.SSPLeverageStatus' + totalResponsibilities: + type: integer + type: object oscal.ProfileComplianceGroup: properties: compliancePercent: type: integer id: type: string + inherited: + description: |- + Inherited counts inherited-credited controls in this group; it counts as compliant + in CompliancePercent (same rule as the summary). + type: integer notSatisfied: type: integer satisfied: @@ -4009,6 +4130,19 @@ definitions: unimplementedControls: type: integer type: object + oscal.ProfileComplianceLeverageOrigin: + properties: + offeringId: + type: string + offeringTitle: + type: string + offeringVersion: + type: integer + upstreamSspId: + type: string + upstreamSspTitle: + type: string + type: object oscal.ProfileComplianceProgress: properties: controls: @@ -4050,6 +4184,13 @@ definitions: type: integer implementedControls: type: integer + inherited: + description: |- + Inherited counts controls credited to an upstream leverage link (all links active, + all live-derived full, no decisive downstream evidence). It counts as compliant in + CompliancePercent and as assessed in AssessedPercent; it is populated only when + sspId is supplied, and is emitted as 0 (never omitted) otherwise. + type: integer notSatisfied: type: integer satisfied: @@ -10047,6 +10188,21 @@ definitions: subjectId: type: string type: object + service.ListResponse-handler_LineageLeverageRow: + properties: + data: + items: + $ref: '#/definitions/handler.LineageLeverageRow' + type: array + limit: + type: integer + page: + type: integer + total: + type: integer + totalPages: + type: integer + type: object service.ListResponse-handler_LineageNode: properties: data: @@ -14802,6 +14958,50 @@ paths: summary: List lineage node children tags: - Lineage + /lineage/nodes/{key}/leverage: + get: + description: |- + Returns, for a control node, one row per downstream System Security Plan that + inherits the control from an upstream offering: every leverage link with its + upstream origin (SSP + offering titles), full and outstanding responsibilities, + live per-responsibility posture, and any open drift risk. Powers the lineage + drawer's inherited-capability panel. Only control keys + (control:/) are supported; other node kinds return 400. + Optional sspId filters to a single downstream SSP. + parameters: + - description: URL-encoded control node key + in: path + name: key + required: true + type: string + - description: Filter to a single downstream SSP + in: query + name: sspId + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/service.ListResponse-handler_LineageLeverageRow' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Per-SSP inherited leverage detail for a control node + tags: + - Lineage /lineage/nodes/{key}/ssps: get: description: 'Returns, for a control node, one row per System Security Plan: @@ -21804,8 +22004,9 @@ paths: - Profile /oscal/profiles/{id}/compliance-progress: get: - description: Returns aggregated compliance progress for controls in a Profile, - including summary, optional per-control rows, and group rollups. + description: |- + Returns aggregated compliance progress for controls in a Profile, including summary, optional per-control rows, and group rollups. + When sspId is supplied, controls inherited from an upstream leverage link (all links active, all live-derived full, no decisive downstream evidence, in scope) are credited to a distinct "inherited" bucket and carry a leverage badge payload. Inherited counts as compliant in compliancePercent and as assessed in assessedPercent; evidence always wins over inherited credit. Without sspId, inherited is 0 and no leverage payloads are emitted. parameters: - description: Profile ID in: path diff --git a/internal/api/handler/lineage.go b/internal/api/handler/lineage.go index 1c9048af..b45d142b 100644 --- a/internal/api/handler/lineage.go +++ b/internal/api/handler/lineage.go @@ -13,6 +13,7 @@ import ( "github.com/compliance-framework/api/internal/api/middleware" "github.com/compliance-framework/api/internal/converters/labelfilter" svc "github.com/compliance-framework/api/internal/service" + "github.com/compliance-framework/api/internal/service/leverage" "github.com/compliance-framework/api/internal/service/relational" riskrel "github.com/compliance-framework/api/internal/service/relational/risks" "github.com/google/uuid" @@ -42,6 +43,7 @@ func (h *LineageHandler) Register(api *echo.Group, guard middleware.ResourceGuar api.GET("/roots", h.Roots, guard.Read()) api.GET("/nodes/:key/children", h.Children, guard.Read()) api.GET("/nodes/:key/ssps", h.SSPDetail, guard.Read()) + api.GET("/nodes/:key/leverage", h.LeverageDetail, guard.Read()) } // ── Response shapes ──────────────────────────────────────────────────────────── @@ -54,16 +56,34 @@ func (h *LineageHandler) Register(api *echo.Group, guard middleware.ResourceGuar // present it is a count of in-scope (control × SSP) cells — a control tracked by N // SSPs contributes up to N — so a control failing in one plan and N/A in another // is not collapsed. Consumers must not render it as a raw control count in that -// scope. (Satisfied/NotSatisfied/Unknown follow the same unit.) +// scope. (Satisfied/NotSatisfied/Unknown/Inherited follow the same unit.) +// +// Inherited counts cells credited to an upstream leverage link. It counts as +// compliant in CompliancePercent and as assessed in AssessedPercent; it is NOT part +// of Unknown. type LineageCompliance struct { TotalControls int `json:"totalControls"` Satisfied int `json:"satisfied"` NotSatisfied int `json:"notSatisfied"` Unknown int `json:"unknown"` + Inherited int `json:"inherited"` CompliancePercent float64 `json:"compliancePercent"` AssessedPercent float64 `json:"assessedPercent"` } +// LineageLeverageSummary is the compact per-(control, SSP) leverage badge carried on +// posture overlays and drawer rows. Present whenever at least one leverage link +// exists for the control in that SSP — regardless of whether it earned inherited +// credit — so the UI can badge partial/drifted leverage too. Status is the worst +// link status; Satisfaction is full iff every link is full. +type LineageLeverageSummary struct { + Links int `json:"links"` + Status string `json:"status"` + Satisfaction string `json:"satisfaction"` + OutstandingCount int `json:"outstandingCount"` + TotalResponsibilities int `json:"totalResponsibilities"` +} + type LineageRiskCounts struct { Open int `json:"open"` Investigating int `json:"investigating"` @@ -95,6 +115,7 @@ const ( PostureOutOfScope = "out-of-scope" PostureSatisfied = "satisfied" PostureNotSatisfied = "not-satisfied" + PostureInherited = "inherited" PostureNotApplicable = "not-applicable" PosturePlanned = "planned" PostureAttention = "attention" @@ -105,10 +126,11 @@ const ( // inputs (profile membership, evidence status, uniform implementation status) // alongside the derived posture so the UI can render or re-derive as needed. type LineageSSPStatus struct { - Posture string `json:"posture"` - InProfile bool `json:"inProfile"` - EvidenceStatus string `json:"evidenceStatus"` - ImplementationStatus string `json:"implementationStatus,omitempty"` + Posture string `json:"posture"` + InProfile bool `json:"inProfile"` + EvidenceStatus string `json:"evidenceStatus"` + ImplementationStatus string `json:"implementationStatus,omitempty"` + Leverage *LineageLeverageSummary `json:"leverage,omitempty"` } // LineagePostureCounts tallies the postures of a structural node's own @@ -118,6 +140,7 @@ type LineageSSPStatus struct { type LineagePostureCounts struct { Satisfied int `json:"satisfied"` NotSatisfied int `json:"notSatisfied"` + Inherited int `json:"inherited"` NotApplicable int `json:"notApplicable"` Planned int `json:"planned"` Attention int `json:"attention"` @@ -133,6 +156,7 @@ type LineageSSPBreakdown struct { OutOfScope int `json:"outOfScope"` Satisfied int `json:"satisfied"` NotSatisfied int `json:"notSatisfied"` + Inherited int `json:"inherited"` NotApplicable int `json:"notApplicable"` Planned int `json:"planned"` Attention int `json:"attention"` @@ -143,12 +167,13 @@ type LineageSSPBreakdown struct { // implementation status — so the drawer can show a plan-by-plan breakdown instead // of a single collapsed verdict. type LineageSSPRow struct { - SSPID string `json:"sspId"` - SSPTitle string `json:"sspTitle"` - InProfile bool `json:"inProfile"` - Posture string `json:"posture"` - EvidenceStatus string `json:"evidenceStatus"` - ImplementationStatus string `json:"implementationStatus,omitempty"` + SSPID string `json:"sspId"` + SSPTitle string `json:"sspTitle"` + InProfile bool `json:"inProfile"` + Posture string `json:"posture"` + EvidenceStatus string `json:"evidenceStatus"` + ImplementationStatus string `json:"implementationStatus,omitempty"` + Leverage *LineageLeverageSummary `json:"leverage,omitempty"` } type LineageNode struct { @@ -380,6 +405,7 @@ func (h *LineageHandler) SSPDetail(ctx echo.Context) error { Posture: a.posture, EvidenceStatus: a.evidence, ImplementationStatus: a.impl, + Leverage: a.leverage, }) } sort.Slice(rows, func(i, j int) bool { @@ -463,6 +489,13 @@ type lineageEngine struct { // implemented-requirement carries no catalog id (as elsewhere in the codebase). implStatusBySSP map[uuid.UUID]map[string]string + // Cross-SSP leverage aggregates keyed by (SSP, UPPER(controlID)) — the inherited + // credit, worst status, live satisfaction and responsibility counts per control. + // Loaded once per engine build: single-SSP scope keys only the selected SSP; + // global scope keys every downstream SSP. Keyed by control-id only, the same + // precedent as implStatusBySSP. + leverage map[leverage.ControlKey]leverage.ControlAggregate + // Global multi-SSP scope (loaded only when sspID is nil): the full SSP list, each // SSP's title, and each SSP's resolved profile controls (scopeKey set), for the // cross-SSP breakdown on control nodes and the per-SSP drawer table. @@ -518,6 +551,7 @@ func (h *LineageHandler) buildEngine(sspID, componentID *uuid.UUID) (*lineageEng profileControlsBySSP: map[uuid.UUID]map[string]struct{}{}, closureCache: map[relational.ControlRef][]relational.ControlRef{}, assessCache: map[assessKey]sspAssessment{}, + leverage: map[leverage.ControlKey]leverage.ControlAggregate{}, } if err := e.loadCatalogs(h.db); err != nil { @@ -532,6 +566,9 @@ func (h *LineageHandler) buildEngine(sspID, componentID *uuid.UUID) (*lineageEng if err := e.loadRisks(h.db); err != nil { return nil, err } + if err := e.loadLeverage(h.db); err != nil { + return nil, err + } if sspID != nil { if err := e.loadProfileScope(h.db, *sspID); err != nil { return nil, err @@ -953,9 +990,33 @@ func (e *lineageEngine) loadGlobalSSPScope(db *gorm.DB) error { return e.loadImplementationStatuses(db, e.allSSPIDs) } -// derivePosture applies the posture ladder for one control in one SSP: scope, -// then decisive evidence, then declared implementation status. -func derivePosture(inScope bool, evidenceStatus, implStatus string) string { +// loadLeverage summarizes every leverage link in scope (single SSP when e.sspID is +// set, all downstream SSPs otherwise) and aggregates it per (SSP, control-id). Run +// once per engine build; Summarize does ~7 bulk queries regardless of link count. +func (e *lineageEngine) loadLeverage(db *gorm.DB) error { + summaries, err := leverage.Summarize(db, e.sspID) + if err != nil { + return err + } + e.leverage = leverage.AggregateByControl(summaries) + return nil +} + +// leverageAgg returns the leverage aggregate for a (control, SSP) cell, matched by +// UPPER-folded control-id only (no catalog id — the established leverage precedent). +// ok is false when the SSP holds no leverage link for that control-id. +func (e *lineageEngine) leverageAgg(ref relational.ControlRef, sspID uuid.UUID) (leverage.ControlAggregate, bool) { + agg, ok := e.leverage[leverage.ControlKey{SSPID: sspID, ControlID: leverage.NormalizeControlID(ref.ControlID)}] + return agg, ok +} + +// derivePosture applies the posture ladder for one control in one SSP: scope, then +// decisive evidence, then — only when evidence is inconclusive — machine-verified +// inherited credit, then declared implementation status. Inherited sits above +// not-applicable/planned: both are non-problem rungs, but the leverage rung is +// machine-verified (drift monitoring, re-attest) and carries more information. Evidence +// always wins over inherited credit in both directions. +func derivePosture(inScope bool, evidenceStatus, implStatus string, inheritedCredit bool) string { if !inScope { return PostureOutOfScope } @@ -965,6 +1026,9 @@ func derivePosture(inScope bool, evidenceStatus, implStatus string) string { case relational.EvidenceStatusSatisfied: return PostureSatisfied } + if inheritedCredit { + return PostureInherited + } switch implStatus { case string(relational.ImplementationStatusNotApplicable): return PostureNotApplicable @@ -997,6 +1061,9 @@ type sspAssessment struct { inScope bool evidence string // satisfied / not-satisfied / unknown impl string // uniform declared status across in-scope implementers, or "" + // leverage is the badge summary for this (control, SSP) cell, non-nil whenever the + // SSP holds at least one leverage link for the control-id (credit or not). + leverage *LineageLeverageSummary } // assessKey memoizes assessSSP. Comparable (ControlRef + uuid.UUID are both @@ -1035,12 +1102,27 @@ func (e *lineageEngine) assessSSP(ref relational.ControlRef, sspID uuid.UUID, me evidence := e.statusFromStreams(e.closureStreamsForSSP(closure, sspID)) impl := collapseUniformStatus(implStates) + + // Inherited credit is checked on ref itself, not the implements-closure: leverage + // links are recorded against the concrete control-id, and parents pick inherited + // leaves up through the postureCountsFor / sspBreakdownForSet leaf tallies. Evidence + // still wins (derivePosture consults credit only when evidence is inconclusive). + agg, hasLeverage := e.leverageAgg(ref, sspID) a := sspAssessment{ - posture: derivePosture(inScope, evidence, impl), + posture: derivePosture(inScope, evidence, impl, hasLeverage && agg.Credit), inScope: inScope, evidence: evidence, impl: impl, } + if hasLeverage { + a.leverage = &LineageLeverageSummary{ + Links: agg.Links, + Status: string(agg.Status), + Satisfaction: string(agg.Satisfaction), + OutstandingCount: agg.OutstandingCount, + TotalResponsibilities: agg.TotalResponsibilities, + } + } e.assessCache[key] = a return a } @@ -1094,6 +1176,7 @@ func (e *lineageEngine) controlSSPStatus(ref relational.ControlRef) LineageSSPSt InProfile: a.inScope, EvidenceStatus: a.evidence, ImplementationStatus: a.impl, + Leverage: a.leverage, } } @@ -1127,6 +1210,8 @@ func (e *lineageEngine) postureCountsFor(seeds []relational.ControlRef) *Lineage counts.Satisfied++ case PostureNotSatisfied: counts.NotSatisfied++ + case PostureInherited: + counts.Inherited++ case PostureNotApplicable: counts.NotApplicable++ case PosturePlanned: @@ -1169,6 +1254,8 @@ func (e *lineageEngine) sspBreakdownForSet(set map[relational.ControlRef]struct{ b.Satisfied++ case PostureNotSatisfied: b.NotSatisfied++ + case PostureInherited: + b.Inherited++ case PostureNotApplicable: b.NotApplicable++ case PosturePlanned: @@ -1265,20 +1352,23 @@ func (e *lineageEngine) complianceFor(set map[relational.ControlRef]struct{}, br if breakdown != nil { sat := breakdown.Satisfied not := breakdown.NotSatisfied + inh := breakdown.Inherited // Attention/not-applicable/planned are the in-scope-but-not-proven cells. + // Inherited is credited (compliant + assessed), not part of Unknown. unk := breakdown.Attention + breakdown.NotApplicable + breakdown.Planned - total := sat + not + unk + total := sat + inh + not + unk return LineageCompliance{ TotalControls: total, Satisfied: sat, NotSatisfied: not, Unknown: unk, - CompliancePercent: pct1(sat, total), - AssessedPercent: pct1(sat+not, total), + Inherited: inh, + CompliancePercent: pct1(sat+inh, total), + AssessedPercent: pct1(sat+inh+not, total), } } - total, sat, not, unk := 0, 0, 0, 0 + total, sat, not, unk, inh := 0, 0, 0, 0, 0 for ref := range set { if !e.countsInCompliance(ref) { continue @@ -1290,6 +1380,16 @@ func (e *lineageEngine) complianceFor(set map[relational.ControlRef]struct{}, br case relational.EvidenceStatusNotSatisfied: not++ default: + // Evidence is inconclusive. In single-SSP scope, promote to inherited when + // the control is in scope and its leverage links earn credit — the fallback + // analogue of the breakdown's per-cell inherited posture. Global-with-no-SSPs + // has no SSP to key leverage on, so this never fires there. + if e.sspID != nil && e.inScope(ref) { + if agg, ok := e.leverageAgg(ref, *e.sspID); ok && agg.Credit { + inh++ + continue + } + } unk++ } } @@ -1298,8 +1398,9 @@ func (e *lineageEngine) complianceFor(set map[relational.ControlRef]struct{}, br Satisfied: sat, NotSatisfied: not, Unknown: unk, - CompliancePercent: pct1(sat, total), - AssessedPercent: pct1(sat+not, total), + Inherited: inh, + CompliancePercent: pct1(sat+inh, total), + AssessedPercent: pct1(sat+inh+not, total), } } diff --git a/internal/api/handler/lineage_leverage.go b/internal/api/handler/lineage_leverage.go new file mode 100644 index 00000000..9c25d5b7 --- /dev/null +++ b/internal/api/handler/lineage_leverage.go @@ -0,0 +1,212 @@ +package handler + +import ( + "errors" + "net/http" + "sort" + "strings" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + + "github.com/compliance-framework/api/internal/api" + svc "github.com/compliance-framework/api/internal/service" + "github.com/compliance-framework/api/internal/service/leverage" + "github.com/compliance-framework/api/internal/service/relational" +) + +// LineageLeverageInheritedFrom names the upstream capability a leverage link consumes, +// with both the offering and the upstream SSP title resolved. +type LineageLeverageInheritedFrom struct { + UpstreamSSPID uuid.UUID `json:"upstreamSspId"` + UpstreamSSPTitle string `json:"upstreamSspTitle"` + OfferingID uuid.UUID `json:"offeringId"` + OfferingTitle string `json:"offeringTitle"` + OfferingVersion int `json:"offeringVersion"` +} + +// LineageLeverageLink is one leverage link's full drawer detail. Its JSON is identical +// to oscal's leveragedControlResponse (GET /oscal/system-security-plans/:id/leveraged-controls) +// plus upstreamSspTitle inside inheritedFrom, so the UI reuses one link shape. +type LineageLeverageLink struct { + ID uuid.UUID `json:"id"` + ControlID string `json:"controlId"` + StatementID *string `json:"statementId,omitempty"` + InheritedFrom LineageLeverageInheritedFrom `json:"inheritedFrom"` + ProvidedUuid uuid.UUID `json:"providedUuid"` + ByComponentId *uuid.UUID `json:"byComponentId,omitempty"` + Satisfaction relational.SSPLeverageSatisfaction `json:"satisfaction"` + Status relational.SSPLeverageStatus `json:"status"` + Responsibilities []leverage.Responsibility `json:"responsibilities"` + OutstandingResponsibilities []leverage.Responsibility `json:"outstandingResponsibilities"` + // ResponsibilityPosture is keyed by responsibility UUID. UI must fence these keys + // from camelCasing. + ResponsibilityPosture map[uuid.UUID]string `json:"responsibilityPosture"` + DriftRiskID *uuid.UUID `json:"driftRiskId,omitempty"` +} + +// LineageLeverageRow groups one downstream SSP's leverage links for a control. +type LineageLeverageRow struct { + SSPID string `json:"sspId"` + SSPTitle string `json:"sspTitle"` + Links []LineageLeverageLink `json:"links"` +} + +// LeverageDetail godoc +// +// @Summary Per-SSP inherited leverage detail for a control node +// @Description Returns, for a control node, one row per downstream System Security Plan that +// @Description inherits the control from an upstream offering: every leverage link with its +// @Description upstream origin (SSP + offering titles), full and outstanding responsibilities, +// @Description live per-responsibility posture, and any open drift risk. Powers the lineage +// @Description drawer's inherited-capability panel. Only control keys +// @Description (control:/) are supported; other node kinds return 400. +// @Description Optional sspId filters to a single downstream SSP. +// @Tags Lineage +// @Produce json +// @Param key path string true "URL-encoded control node key" +// @Param sspId query string false "Filter to a single downstream SSP" +// @Success 200 {object} svc.ListResponse[LineageLeverageRow] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /lineage/nodes/{key}/leverage [get] +func (h *LineageHandler) LeverageDetail(ctx echo.Context) error { + // sspId here is a row filter (which downstream SSP), not a metric scope; componentId + // is irrelevant to leverage and is ignored. + var sspFilter *uuid.UUID + if raw := strings.TrimSpace(ctx.QueryParam("sspId")); raw != "" { + id, parseErr := uuid.Parse(raw) + if parseErr != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(parseErr)) + } + sspFilter = &id + } + + kind, catalogID, subID, err := parseNodeKey(ctx.Param("key")) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if kind != "control" { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("leverage detail is only available for control nodes"))) + } + ref := relational.ControlRef{CatalogID: catalogID, ControlID: subID} + + // Build a global engine only to resolve downstream SSP titles and validate the + // control exists (its catalog type is registered). Rejected alternative: having the + // UI call per-SSP /oscal/system-security-plans/:id/leveraged-controls would be N + // requests and would require ssp:read on each SSP — the read guard here is the same + // as /ssps. + engine, err := h.buildEngine(nil, nil) + if err != nil { + h.sugar.Errorw("failed to build lineage engine", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + if _, ok := engine.controlCatalogType[ref]; !ok { + return ctx.JSON(http.StatusNotFound, api.NewError(errors.New("control not found"))) + } + + // Leverage matches by control-id only (no catalog id), the established precedent. + projectionsBySSP, err := leverage.ProjectForControl(h.db, subID) + if err != nil { + h.sugar.Errorw("failed to project leverage for control", "controlID", subID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + // Upstream SSP titles: one query, mirroring collectInheritedSharedResponsibility. + upstreamTitleByID, err := h.upstreamSSPTitles(projectionsBySSP, sspFilter) + if err != nil { + h.sugar.Errorw("failed to resolve upstream SSP titles", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + rows := make([]LineageLeverageRow, 0, len(projectionsBySSP)) + for sspID, projections := range projectionsBySSP { + if sspFilter != nil && sspID != *sspFilter { + continue + } + links := make([]LineageLeverageLink, 0, len(projections)) + for _, p := range projections { + var byComponentID *uuid.UUID + if p.ByComponentID != uuid.Nil { + id := p.ByComponentID + byComponentID = &id + } + links = append(links, LineageLeverageLink{ + ID: *p.Link.ID, + ControlID: p.Link.ControlID, + StatementID: p.Link.StatementID, + InheritedFrom: LineageLeverageInheritedFrom{ + UpstreamSSPID: p.Link.UpstreamSSPID, + UpstreamSSPTitle: upstreamTitleByID[p.Link.UpstreamSSPID], + OfferingID: p.Link.OfferingID, + OfferingTitle: p.OfferingTitle, + OfferingVersion: p.Link.OfferingVersion, + }, + ProvidedUuid: p.Link.ProvidedUUID, + ByComponentId: byComponentID, + Satisfaction: p.Satisfaction, + Status: p.Link.Status, + Responsibilities: p.Responsibilities, + OutstandingResponsibilities: p.Outstanding, + ResponsibilityPosture: p.Posture, + DriftRiskID: p.DriftRiskID, + }) + } + rows = append(rows, LineageLeverageRow{ + SSPID: sspID.String(), + SSPTitle: engine.sspTitles[sspID], + Links: links, + }) + } + + sort.Slice(rows, func(i, j int) bool { + if rows[i].SSPTitle == rows[j].SSPTitle { + return rows[i].SSPID < rows[j].SSPID + } + return rows[i].SSPTitle < rows[j].SSPTitle + }) + + limit := len(rows) + if limit < 1 { + limit = 1 + } + return ctx.JSON(http.StatusOK, svc.NewListResponse(rows, int64(len(rows)), 1, limit)) +} + +// upstreamSSPTitles resolves the Metadata.Title of every distinct upstream SSP across +// the projections (optionally narrowed to one downstream SSP), in a single Preload +// query — mirrors collectInheritedSharedResponsibility's upstream-title resolution. +func (h *LineageHandler) upstreamSSPTitles(projectionsBySSP map[uuid.UUID][]leverage.Projection, sspFilter *uuid.UUID) (map[uuid.UUID]string, error) { + seen := map[uuid.UUID]struct{}{} + upstreamIDs := make([]uuid.UUID, 0) + for sspID, projections := range projectionsBySSP { + if sspFilter != nil && sspID != *sspFilter { + continue + } + for _, p := range projections { + if _, dup := seen[p.Link.UpstreamSSPID]; dup { + continue + } + seen[p.Link.UpstreamSSPID] = struct{}{} + upstreamIDs = append(upstreamIDs, p.Link.UpstreamSSPID) + } + } + + titles := make(map[uuid.UUID]string, len(upstreamIDs)) + if len(upstreamIDs) == 0 { + return titles, nil + } + var upstreams []relational.SystemSecurityPlan + if err := h.db.Preload("Metadata").Where("id IN ?", upstreamIDs).Find(&upstreams).Error; err != nil { + return nil, err + } + for i := range upstreams { + if upstreams[i].ID == nil { + continue + } + titles[*upstreams[i].ID] = upstreams[i].Metadata.Title + } + return titles, nil +} diff --git a/internal/api/handler/lineage_leverage_integration_test.go b/internal/api/handler/lineage_leverage_integration_test.go new file mode 100644 index 00000000..bc4ecb36 --- /dev/null +++ b/internal/api/handler/lineage_leverage_integration_test.go @@ -0,0 +1,391 @@ +//go:build integration + +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/compliance-framework/api/internal/converters/labelfilter" + "github.com/compliance-framework/api/internal/service/leverage" + "github.com/compliance-framework/api/internal/service/relational" + riskrel "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/labstack/echo/v4" + "github.com/stretchr/testify/suite" + "go.uber.org/zap" + "gorm.io/datatypes" +) + +func TestLineageLeverage(t *testing.T) { + suite.Run(t, new(LineageLeverageSuite)) +} + +type LineageLeverageSuite struct { + tests.IntegrationTestSuite +} + +// inheritedSeed carries the ids of a single-control, single-downstream leverage +// scenario so a test can mutate one part (drift the link, add evidence, drop a +// satisfied row) and re-query. +type inheritedSeed struct { + catID uuid.UUID + controlID string + downstreamSSP uuid.UUID + upstreamSSP uuid.UUID + offeringID uuid.UUID + linkID uuid.UUID + byComponentID uuid.UUID + providedUUID uuid.UUID + resp1, resp2 uuid.UUID + sat2ID uuid.UUID +} + +// seedInherited builds an upstream offering with two responsibilities, a downstream SSP +// whose profile carries control AC-2, and an active leverage link with both +// responsibilities satisfied — the credit-earning baseline. +func (suite *LineageLeverageSuite) seedInherited() inheritedSeed { + suite.Require().NoError(suite.Migrator.Refresh()) + now := time.Now().UTC() + + catID := uuid.New() + suite.Require().NoError(suite.DB.Create(&relational.Catalog{ + UUIDModel: relational.UUIDModel{ID: &catID}, + CatalogType: relational.CatalogTypeStandard, + Metadata: relational.Metadata{Title: "Std", Version: "1.0.0", OscalVersion: "1.1.3", LastModified: &now}, + Controls: []relational.Control{{CatalogID: catID, ID: "AC-2", Title: "Account Management"}}, + }).Error) + + // Downstream SSP whose profile resolves AC-2. + profileID := uuid.New() + suite.Require().NoError(suite.DB.Create(&relational.Profile{ + UUIDModel: relational.UUIDModel{ID: &profileID}, + Metadata: relational.Metadata{Title: "P", Version: "1.0.0", OscalVersion: "1.1.3"}, + }).Error) + suite.Require().NoError(suite.DB.Exec( + "INSERT INTO profile_controls (profile_id, control_catalog_id, control_id) VALUES (?, ?, ?)", + profileID, catID, "AC-2").Error) + downstreamSSP := uuid.New() + suite.Require().NoError(suite.DB.Create(&relational.SystemSecurityPlan{ + UUIDModel: relational.UUIDModel{ID: &downstreamSSP}, + Metadata: relational.Metadata{Title: "Downstream SSP", Version: "1.0.0", OscalVersion: "1.1.3", LastModified: &now}, + }).Error) + suite.Require().NoError(suite.DB.Exec( + "INSERT INTO ssp_profiles (system_security_plan_id, profile_id) VALUES (?, ?)", + downstreamSSP, profileID).Error) + + // Upstream SSP + published offering + provided capability with two responsibilities. + upstreamSSP := uuid.New() + suite.Require().NoError(suite.DB.Create(&relational.SystemSecurityPlan{ + UUIDModel: relational.UUIDModel{ID: &upstreamSSP}, + Metadata: relational.Metadata{Title: "Platform SSP", Version: "1.0.0", OscalVersion: "1.1.3", LastModified: &now}, + }).Error) + offeringID := uuid.New() + suite.Require().NoError(suite.DB.Create(&relational.SSPExportOffering{ + UUIDModel: relational.UUIDModel{ID: &offeringID}, + SSPID: upstreamSSP, + Title: "Managed Postgres", + Version: 2, + Status: relational.SSPExportOfferingStatusPublished, + }).Error) + + exportID := uuid.New() + providedUUID := uuid.New() + suite.Require().NoError(suite.DB.Create(&relational.ProvidedControlImplementation{ + UUIDModel: relational.UUIDModel{ID: &providedUUID}, + Description: "provided", + ExportId: exportID, + }).Error) + resp1, resp2 := uuid.New(), uuid.New() + for _, r := range []uuid.UUID{resp1, resp2} { + r := r + suite.Require().NoError(suite.DB.Create(&relational.ControlImplementationResponsibility{ + UUIDModel: relational.UUIDModel{ID: &r}, + Description: "resp " + r.String(), + ProvidedUuid: providedUUID, + ExportId: exportID, + }).Error) + } + + // Downstream inherited row + active link + satisfied rows for BOTH responsibilities. + byComponentID := uuid.New() + inheritedUUID := uuid.New() + suite.Require().NoError(suite.DB.Create(&relational.InheritedControlImplementation{ + UUIDModel: relational.UUIDModel{ID: &inheritedUUID}, + ProvidedUuid: providedUUID, + Description: "inherited", + ByComponentId: byComponentID, + }).Error) + + linkID := uuid.New() + suite.Require().NoError(suite.DB.Create(&relational.SSPLeverageLink{ + UUIDModel: relational.UUIDModel{ID: &linkID}, + DownstreamSSPID: downstreamSSP, + UpstreamSSPID: upstreamSSP, + OfferingID: offeringID, + OfferingVersion: 2, + ControlID: "AC-2", + ProvidedUUID: providedUUID, + InheritedUUID: inheritedUUID, + Satisfaction: relational.SSPLeverageSatisfactionFull, + Status: relational.SSPLeverageStatusActive, + }).Error) + + sat1ID, sat2ID := uuid.New(), uuid.New() + suite.Require().NoError(suite.DB.Create(&relational.SatisfiedControlImplementationResponsibility{ + UUIDModel: relational.UUIDModel{ID: &sat1ID}, + ResponsibilityUuid: resp1, + Description: "sat1", + ByComponentId: byComponentID, + }).Error) + suite.Require().NoError(suite.DB.Create(&relational.SatisfiedControlImplementationResponsibility{ + UUIDModel: relational.UUIDModel{ID: &sat2ID}, + ResponsibilityUuid: resp2, + Description: "sat2", + ByComponentId: byComponentID, + }).Error) + + return inheritedSeed{ + catID: catID, controlID: "AC-2", downstreamSSP: downstreamSSP, upstreamSSP: upstreamSSP, + offeringID: offeringID, linkID: linkID, byComponentID: byComponentID, providedUUID: providedUUID, + resp1: resp1, resp2: resp2, sat2ID: sat2ID, + } +} + +func (suite *LineageLeverageSuite) get(handlerCall func(echo.Context) error, path, key, sspID string, out any) { + url := path + if sspID != "" { + url += "?sspId=" + sspID + } + e := echo.New() + req := httptest.NewRequest(http.MethodGet, url, nil) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + if key != "" { + ctx.SetParamNames("key") + ctx.SetParamValues(key) + } + suite.Require().NoError(handlerCall(ctx)) + suite.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), out)) +} + +func (suite *LineageLeverageSuite) handler() *LineageHandler { + return NewLineageHandler(zap.NewNop().Sugar(), suite.DB) +} + +func (suite *LineageLeverageSuite) controlNode(catID, sspID string) LineageNode { + var resp struct { + Data []LineageNode `json:"data"` + } + suite.get(suite.handler().Children, "/x", "catalog:"+catID, sspID, &resp) + for _, n := range resp.Data { + if n.ControlID == "AC-2" { + return n + } + } + suite.Require().Fail("AC-2 control node not found") + return LineageNode{} +} + +func (suite *LineageLeverageSuite) catalogRoot(catID, sspID string) LineageNode { + var resp struct { + Data []LineageNode `json:"data"` + } + suite.get(suite.handler().Roots, "/x", "", sspID, &resp) + for _, n := range resp.Data { + if n.CatalogID == catID { + return n + } + } + suite.Require().Fail("catalog root not found") + return LineageNode{} +} + +// TestInheritedCreditRootsAndDrawer covers the credit-earning baseline: the control +// reads inherited in scoped and global rollups, the /ssps drawer, and the new +// /leverage drawer (Contract C shape). +func (suite *LineageLeverageSuite) TestInheritedCreditRootsAndDrawer() { + s := suite.seedInherited() + catKey := s.catID.String() + controlKey := "control:" + s.catID.String() + "/AC-2" + + // (1) Scoped roots: inherited posture + counts + percent math. + root := suite.catalogRoot(catKey, s.downstreamSSP.String()) + suite.Require().NotNil(root.PostureCounts) + suite.Equal(1, root.PostureCounts.Inherited) + suite.Equal(1, root.Compliance.Inherited) + suite.Equal(0, root.Compliance.Unknown) + suite.Equal(1, root.Compliance.TotalControls) + suite.Equal(float64(100), root.Compliance.CompliancePercent) + suite.Equal(float64(100), root.Compliance.AssessedPercent) + + ctrl := suite.controlNode(catKey, s.downstreamSSP.String()) + suite.Require().NotNil(ctrl.SSP) + suite.Equal(PostureInherited, ctrl.SSP.Posture) + suite.Require().NotNil(ctrl.SSP.Leverage) + suite.Equal("active", ctrl.SSP.Leverage.Status) + suite.Equal("full", ctrl.SSP.Leverage.Satisfaction) + suite.Equal(2, ctrl.SSP.Leverage.TotalResponsibilities) + suite.Equal(0, ctrl.SSP.Leverage.OutstandingCount) + + // (2) Global roots: cross-SSP breakdown counts inherited. + globalRoot := suite.catalogRoot(catKey, "") + suite.Require().NotNil(globalRoot.SSPBreakdown) + suite.Equal(1, globalRoot.SSPBreakdown.Inherited) + suite.Equal(1, globalRoot.Compliance.Inherited) + + // (3) /ssps drawer: the downstream row is inherited + carries the leverage badge. + var sspRows struct { + Data []LineageSSPRow `json:"data"` + } + suite.get(suite.handler().SSPDetail, "/x", controlKey, "", &sspRows) + var downstreamRow *LineageSSPRow + for i := range sspRows.Data { + if sspRows.Data[i].SSPID == s.downstreamSSP.String() { + downstreamRow = &sspRows.Data[i] + } + } + suite.Require().NotNil(downstreamRow) + suite.Equal(PostureInherited, downstreamRow.Posture) + suite.Require().NotNil(downstreamRow.Leverage) + suite.Equal("active", downstreamRow.Leverage.Status) + + // (4) /leverage drawer: Contract C shape with upstream title + responsibility posture. + var lev struct { + Data []LineageLeverageRow `json:"data"` + } + suite.get(suite.handler().LeverageDetail, "/x", controlKey, "", &lev) + suite.Require().Len(lev.Data, 1) + row := lev.Data[0] + suite.Equal(s.downstreamSSP.String(), row.SSPID) + suite.Equal("Downstream SSP", row.SSPTitle) + suite.Require().Len(row.Links, 1) + link := row.Links[0] + suite.Equal("AC-2", link.ControlID) + suite.Equal(s.providedUUID, link.ProvidedUuid) + suite.Equal(s.upstreamSSP, link.InheritedFrom.UpstreamSSPID) + suite.Equal("Platform SSP", link.InheritedFrom.UpstreamSSPTitle) + suite.Equal("Managed Postgres", link.InheritedFrom.OfferingTitle) + suite.Equal(2, link.InheritedFrom.OfferingVersion) + suite.Equal(relational.SSPLeverageSatisfactionFull, link.Satisfaction) + suite.Equal(relational.SSPLeverageStatusActive, link.Status) + suite.Require().Len(link.Responsibilities, 2) + suite.Len(link.OutstandingResponsibilities, 0) + suite.Require().NotNil(link.ResponsibilityPosture) + suite.Contains(link.ResponsibilityPosture, s.resp1) + suite.Contains(link.ResponsibilityPosture, s.resp2) + + // sspId filter narrows to the one downstream SSP (and non-matching filter empties). + var filtered struct { + Data []LineageLeverageRow `json:"data"` + } + suite.get(suite.handler().LeverageDetail, "/x", controlKey, s.downstreamSSP.String(), &filtered) + suite.Require().Len(filtered.Data, 1) +} + +// TestInheritedDriftDropsCredit: a drifted link earns no credit and reads attention, +// but the leverage badge still surfaces with the drifted status and its open drift risk. +func (suite *LineageLeverageSuite) TestInheritedDriftDropsCredit() { + s := suite.seedInherited() + suite.Require().NoError(suite.DB.Model(&relational.SSPLeverageLink{}). + Where("id = ?", s.linkID).Update("status", relational.SSPLeverageStatusDrifted).Error) + // An open drift risk keyed the same way the projection reads it. + driftRiskID := uuid.New() + suite.Require().NoError(suite.DB.Create(&riskrel.Risk{ + UUIDModel: relational.UUIDModel{ID: &driftRiskID}, + SSPID: s.downstreamSSP, + Title: "drift", + Status: string(riskrel.RiskStatusOpen), + DedupeKey: leverage.DriftDedupeKey(s.linkID), + }).Error) + + catKey := s.catID.String() + root := suite.catalogRoot(catKey, s.downstreamSSP.String()) + suite.Require().NotNil(root.PostureCounts) + suite.Equal(0, root.PostureCounts.Inherited) + suite.Equal(1, root.PostureCounts.Attention) + suite.Equal(0, root.Compliance.Inherited) + suite.Equal(1, root.Compliance.Unknown) + + ctrl := suite.controlNode(catKey, s.downstreamSSP.String()) + suite.Require().NotNil(ctrl.SSP) + suite.Equal(PostureAttention, ctrl.SSP.Posture) + suite.Require().NotNil(ctrl.SSP.Leverage) + suite.Equal("drifted", ctrl.SSP.Leverage.Status) + + // The /leverage drawer surfaces the open drift risk id. + var lev struct { + Data []LineageLeverageRow `json:"data"` + } + suite.get(suite.handler().LeverageDetail, "/x", "control:"+s.catID.String()+"/AC-2", "", &lev) + suite.Require().Len(lev.Data, 1) + suite.Require().Len(lev.Data[0].Links, 1) + suite.Require().NotNil(lev.Data[0].Links[0].DriftRiskID) + suite.Equal(driftRiskID, *lev.Data[0].Links[0].DriftRiskID) +} + +// TestInheritedEvidenceWins: decisive downstream evidence overrides inherited credit. +func (suite *LineageLeverageSuite) TestInheritedEvidenceWins() { + s := suite.seedInherited() + now := time.Now().UTC() + + // An SSP-scoped filter on AC-2 with a not-satisfied observation. + f := relational.Filter{ + Name: "ac2-filter", + SSPID: &s.downstreamSSP, + Filter: datatypes.NewJSONType(labelfilter.Filter{ + Scope: &labelfilter.Scope{Condition: &labelfilter.Condition{Label: "ctrl", Operator: "=", Value: "ac-2"}}, + }), + } + suite.Require().NoError(suite.DB.Create(&f).Error) + suite.Require().NoError(suite.DB.Exec( + "INSERT INTO filter_controls (filter_id, control_catalog_id, control_id) VALUES (?, ?, ?)", + f.ID, s.catID, "AC-2").Error) + evID := uuid.New() + suite.Require().NoError(suite.DB.Create(&relational.Evidence{ + UUIDModel: relational.UUIDModel{ID: &evID}, + UUID: uuid.New(), + Title: "ev", + Start: now, End: now, Expires: &now, + Status: datatypes.NewJSONType(oscalTypes_1_1_3.ObjectiveStatus{State: "not-satisfied", Reason: "auto"}), + }).Error) + suite.Require().NoError(suite.DB.Exec( + "INSERT INTO labels (name, value) VALUES ('ctrl', 'ac-2') ON CONFLICT DO NOTHING").Error) + suite.Require().NoError(suite.DB.Exec( + "INSERT INTO evidence_labels (evidence_id, labels_name, labels_value) VALUES (?, 'ctrl', 'ac-2')", evID).Error) + + ctrl := suite.controlNode(s.catID.String(), s.downstreamSSP.String()) + suite.Require().NotNil(ctrl.SSP) + suite.Equal(PostureNotSatisfied, ctrl.SSP.Posture) + // Leverage badge is still attached (links exist), even though evidence won. + suite.Require().NotNil(ctrl.SSP.Leverage) + + root := suite.catalogRoot(s.catID.String(), s.downstreamSSP.String()) + suite.Equal(0, root.Compliance.Inherited) + suite.Equal(1, root.Compliance.NotSatisfied) +} + +// TestInheritedPartialDropsCredit: dropping a satisfied row makes live derivation +// partial, so no credit is granted even though the stored column was never rewritten. +func (suite *LineageLeverageSuite) TestInheritedPartialDropsCredit() { + s := suite.seedInherited() + // Delete one satisfied row -> only 1 of 2 responsibilities covered -> partial. + suite.Require().NoError(suite.DB.Delete(&relational.SatisfiedControlImplementationResponsibility{}, "id = ?", s.sat2ID).Error) + + ctrl := suite.controlNode(s.catID.String(), s.downstreamSSP.String()) + suite.Require().NotNil(ctrl.SSP) + suite.Equal(PostureAttention, ctrl.SSP.Posture) + suite.Require().NotNil(ctrl.SSP.Leverage) + suite.Equal("partial", ctrl.SSP.Leverage.Satisfaction) + suite.Equal(1, ctrl.SSP.Leverage.OutstandingCount) + + root := suite.catalogRoot(s.catID.String(), s.downstreamSSP.String()) + suite.Equal(0, root.Compliance.Inherited) + suite.Equal(1, root.Compliance.Unknown) +} diff --git a/internal/api/handler/lineage_test.go b/internal/api/handler/lineage_test.go index c9a6d2dd..cc8a205a 100644 --- a/internal/api/handler/lineage_test.go +++ b/internal/api/handler/lineage_test.go @@ -125,26 +125,38 @@ func TestDerivePosture(t *testing.T) { inProfile bool evidence string impl string + inherited bool want string }{ // Scope wins over everything: an out-of-profile control is never a problem. - {"out of profile beats all", false, notSat, na, PostureOutOfScope}, + {"out of profile beats all", false, notSat, na, false, PostureOutOfScope}, + // Out-of-scope beats inherited credit too: an un-profiled control is excluded + // regardless of leverage. + {"out of profile beats inherited", false, "unknown", "", true, PostureOutOfScope}, // Decisive evidence beats declared status (the user's "evidence wins" rule): // even a not-applicable control with stale failing evidence stays red. - {"not-satisfied evidence wins over not-applicable", true, notSat, na, PostureNotSatisfied}, - {"satisfied evidence wins over declared implemented", true, sat, "implemented", PostureSatisfied}, - // No decisive evidence -> declared status decides problem vs expected. - {"no evidence + not-applicable is muted", true, "unknown", na, PostureNotApplicable}, - {"no evidence + planned is muted", true, "unknown", planned, PosturePlanned}, - {"no evidence + implemented is attention", true, "unknown", "implemented", PostureAttention}, - {"no evidence + partial is attention", true, "unknown", "partial", PostureAttention}, - {"no evidence + alternative is attention", true, "unknown", "alternative", PostureAttention}, - {"no evidence + undeclared is attention", true, "unknown", "", PostureAttention}, + {"not-satisfied evidence wins over not-applicable", true, notSat, na, false, PostureNotSatisfied}, + {"satisfied evidence wins over declared implemented", true, sat, "implemented", false, PostureSatisfied}, + // Evidence wins over inherited credit in both directions. + {"not-satisfied evidence wins over inherited", true, notSat, "", true, PostureNotSatisfied}, + {"satisfied evidence wins over inherited", true, sat, "", true, PostureSatisfied}, + // No decisive evidence + inherited credit -> inherited, above not-applicable/planned. + {"no evidence + inherited credit is inherited", true, "unknown", "", true, PostureInherited}, + {"inherited beats not-applicable", true, "unknown", na, true, PostureInherited}, + {"inherited beats planned", true, "unknown", planned, true, PostureInherited}, + // No decisive evidence, no credit -> declared status decides problem vs expected. + {"credit=false + not-applicable stays not-applicable", true, "unknown", na, false, PostureNotApplicable}, + {"no evidence + not-applicable is muted", true, "unknown", na, false, PostureNotApplicable}, + {"no evidence + planned is muted", true, "unknown", planned, false, PosturePlanned}, + {"no evidence + implemented is attention", true, "unknown", "implemented", false, PostureAttention}, + {"no evidence + partial is attention", true, "unknown", "partial", false, PostureAttention}, + {"no evidence + alternative is attention", true, "unknown", "alternative", false, PostureAttention}, + {"no evidence + undeclared is attention", true, "unknown", "", false, PostureAttention}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - if got := derivePosture(tc.inProfile, tc.evidence, tc.impl); got != tc.want { - t.Errorf("derivePosture(%v, %q, %q) = %q, want %q", tc.inProfile, tc.evidence, tc.impl, got, tc.want) + if got := derivePosture(tc.inProfile, tc.evidence, tc.impl, tc.inherited); got != tc.want { + t.Errorf("derivePosture(%v, %q, %q, %v) = %q, want %q", tc.inProfile, tc.evidence, tc.impl, tc.inherited, got, tc.want) } }) } diff --git a/internal/api/handler/oscal/profile_compliance.go b/internal/api/handler/oscal/profile_compliance.go index d8b9b468..3daec170 100644 --- a/internal/api/handler/oscal/profile_compliance.go +++ b/internal/api/handler/oscal/profile_compliance.go @@ -11,6 +11,7 @@ import ( "github.com/compliance-framework/api/internal/api" "github.com/compliance-framework/api/internal/api/handler" "github.com/compliance-framework/api/internal/converters/labelfilter" + "github.com/compliance-framework/api/internal/service/leverage" "github.com/compliance-framework/api/internal/service/relational" "github.com/google/uuid" "github.com/labstack/echo/v4" @@ -32,10 +33,15 @@ type ProfileComplianceScope struct { } type ProfileComplianceSummary struct { - TotalControls int `json:"totalControls"` - Satisfied int `json:"satisfied"` - NotSatisfied int `json:"notSatisfied"` - Unknown int `json:"unknown"` + TotalControls int `json:"totalControls"` + Satisfied int `json:"satisfied"` + NotSatisfied int `json:"notSatisfied"` + Unknown int `json:"unknown"` + // Inherited counts controls credited to an upstream leverage link (all links active, + // all live-derived full, no decisive downstream evidence). It counts as compliant in + // CompliancePercent and as assessed in AssessedPercent; it is populated only when + // sspId is supplied, and is emitted as 0 (never omitted) otherwise. + Inherited int `json:"inherited"` CompliancePct int `json:"compliancePercent"` AssessedPct int `json:"assessedPercent"` ImplementedTotal *int `json:"implementedControls,omitempty"` @@ -54,7 +60,10 @@ type ProfileComplianceGroup struct { Satisfied int `json:"satisfied"` NotSatisfied int `json:"notSatisfied"` Unknown int `json:"unknown"` - CompliancePct int `json:"compliancePercent"` + // Inherited counts inherited-credited controls in this group; it counts as compliant + // in CompliancePercent (same rule as the summary). + Inherited int `json:"inherited"` + CompliancePct int `json:"compliancePercent"` } type ProfileComplianceControl struct { @@ -66,6 +75,10 @@ type ProfileComplianceControl struct { Implemented *bool `json:"implemented,omitempty"` StatusCounts []ProfileComplianceStatusCount `json:"statusCounts"` ComputedStatus string `json:"computedStatus"` + // Leverage carries the badge/tooltip payload for an inherited (or leverage-linked but + // uncredited) control. Present only when sspId is supplied and the control has at + // least one leverage link; omitted otherwise. + Leverage *ProfileComplianceControlLeverage `json:"leverage,omitempty"` } type ProfileComplianceStatusCount struct { @@ -73,6 +86,30 @@ type ProfileComplianceStatusCount struct { Status string `json:"status"` } +// ProfileComplianceControlLeverage is the per-control leverage summary. Inherited is +// true when the control earned inherited credit (all links active, all live-derived +// full) — independent of whether evidence overrode the computed status. Status is the +// worst link status; Satisfaction is full iff every link is full. +type ProfileComplianceControlLeverage struct { + Inherited bool `json:"inherited"` + Satisfaction relational.SSPLeverageSatisfaction `json:"satisfaction"` + Status relational.SSPLeverageStatus `json:"status"` + Links int `json:"links"` + OutstandingCount int `json:"outstandingCount"` + TotalResponsibilities int `json:"totalResponsibilities"` + InheritedFrom []ProfileComplianceLeverageOrigin `json:"inheritedFrom"` +} + +// ProfileComplianceLeverageOrigin names one upstream capability an inherited control +// draws from (deduped by upstream SSP + offering). +type ProfileComplianceLeverageOrigin struct { + UpstreamSSPID uuid.UUID `json:"upstreamSspId"` + UpstreamSSPTitle string `json:"upstreamSspTitle"` + OfferingID uuid.UUID `json:"offeringId"` + OfferingTitle string `json:"offeringTitle"` + OfferingVersion int `json:"offeringVersion"` +} + type profileControlKey struct { CatalogID uuid.UUID ControlID string @@ -93,12 +130,14 @@ type profileComplianceGroupAccumulator struct { Satisfied int NotSatisfied int Unknown int + Inherited int } // ComplianceProgress godoc // // @Summary Get compliance progress for a Profile // @Description Returns aggregated compliance progress for controls in a Profile, including summary, optional per-control rows, and group rollups. +// @Description When sspId is supplied, controls inherited from an upstream leverage link (all links active, all live-derived full, no decisive downstream evidence, in scope) are credited to a distinct "inherited" bucket and carry a leverage badge payload. Inherited counts as compliant in compliancePercent and as assessed in assessedPercent; evidence always wins over inherited credit. Without sspId, inherited is 0 and no leverage payloads are emitted. // @Tags Profile // @Param id path string true "Profile ID" // @Param includeControls query bool false "Include per-control breakdown (default true)" @@ -166,6 +205,7 @@ func (h *ProfileHandler) ComplianceProgress(ctx echo.Context) error { sspImplementedControls := map[string]struct{}{} hasImplementationScope := false var sspIDForFilters *uuid.UUID + var leverageAggByControl map[leverage.ControlKey]leverage.ControlAggregate sspIDParam := strings.TrimSpace(ctx.QueryParam("sspId")) if sspIDParam != "" { sspID, parseErr := uuid.Parse(sspIDParam) @@ -183,6 +223,15 @@ func (h *ProfileHandler) ComplianceProgress(ctx echo.Context) error { return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } hasImplementationScope = true + + // Leverage credit is only computed when an SSP is in scope: inherited-ness is a + // property of a downstream SSP's links, not of the profile alone. + summaries, leverageErr := leverage.Summarize(h.db, &sspID) + if leverageErr != nil { + h.sugar.Errorw("failed to summarize leverage links for SSP", "sspID", sspID, "error", leverageErr) + return ctx.JSON(http.StatusInternalServerError, api.NewError(leverageErr)) + } + leverageAggByControl = leverage.AggregateByControl(summaries) } filtersByControl, err := h.loadFiltersByControl(scopeControls, sspIDForFilters) @@ -197,6 +246,7 @@ func (h *ProfileHandler) ComplianceProgress(ctx echo.Context) error { satisfied := 0 notSatisfied := 0 unknown := 0 + inherited := 0 implementedControls := 0 for _, scopedControl := range scopeControls { @@ -208,11 +258,31 @@ func (h *ProfileHandler) ComplianceProgress(ctx echo.Context) error { } computedStatus := computeProfileControlStatus(statusCounts) + + // Leverage credit: when this control has leverage links for the in-scope SSP, + // attach the badge payload; if evidence was inconclusive (unknown) and the links + // earn credit, promote it to the inherited bucket. Evidence always wins — a + // satisfied/not-satisfied computedStatus is never overwritten. + var controlLeverage *ProfileComplianceControlLeverage + if hasImplementationScope { + if agg, ok := leverageAggByControl[leverage.ControlKey{ + SSPID: *sspIDForFilters, + ControlID: leverage.NormalizeControlID(scopedControl.ControlID), + }]; ok { + controlLeverage = buildProfileControlLeverage(agg) + if computedStatus == "unknown" && agg.Credit { + computedStatus = "inherited" + } + } + } + switch computedStatus { case "satisfied": satisfied++ case "not-satisfied": notSatisfied++ + case "inherited": + inherited++ default: unknown++ } @@ -232,6 +302,8 @@ func (h *ProfileHandler) ComplianceProgress(ctx echo.Context) error { group.Satisfied++ case "not-satisfied": group.NotSatisfied++ + case "inherited": + group.Inherited++ default: group.Unknown++ } @@ -245,6 +317,7 @@ func (h *ProfileHandler) ComplianceProgress(ctx echo.Context) error { GroupTitle: scopedControl.GroupTitle, StatusCounts: statusCounts, ComputedStatus: computedStatus, + Leverage: controlLeverage, } if hasImplementationScope { @@ -266,8 +339,9 @@ func (h *ProfileHandler) ComplianceProgress(ctx echo.Context) error { Satisfied: satisfied, NotSatisfied: notSatisfied, Unknown: unknown, - CompliancePct: computePercent(satisfied, len(scopeControls)), - AssessedPct: computePercent(satisfied+notSatisfied, len(scopeControls)), + Inherited: inherited, + CompliancePct: computePercent(satisfied+inherited, len(scopeControls)), + AssessedPct: computePercent(satisfied+notSatisfied+inherited, len(scopeControls)), } if hasImplementationScope { summary.ImplementedTotal = &implementedControls @@ -283,7 +357,8 @@ func (h *ProfileHandler) ComplianceProgress(ctx echo.Context) error { Satisfied: group.Satisfied, NotSatisfied: group.NotSatisfied, Unknown: group.Unknown, - CompliancePct: computePercent(group.Satisfied, group.TotalControls), + Inherited: group.Inherited, + CompliancePct: computePercent(group.Satisfied+group.Inherited, group.TotalControls), }) } @@ -428,100 +503,28 @@ func (h *ProfileHandler) getStatusCountsForFilters(filters []labelfilter.Filter) } // getStatusCountsForFilters is the package-level form of the method above, taking db -// explicitly so ResponsibilityPosture can reuse the exact same evidence status-count -// logic without needing a ProfileHandler receiver. +// explicitly. It delegates to relational.EvidenceStatusCountsForFilters (the extracted +// shared definition) and maps the neutral relational.StatusCount rows onto this +// package's ProfileComplianceStatusCount wire type. func getStatusCountsForFilters(db *gorm.DB, filters []labelfilter.Filter) ([]ProfileComplianceStatusCount, error) { - if len(filters) == 0 { - return []ProfileComplianceStatusCount{}, nil - } - - latestQuery := db.Session(&gorm.Session{}) - latestQuery = relational.GetLatestEvidenceStreamsQuery(latestQuery) - query, err := relational.GetEvidenceSearchByFilterQuery(latestQuery, db, filters...) + rows, err := relational.EvidenceStatusCountsForFilters(db, filters) if err != nil { return nil, err } - rows := []ProfileComplianceStatusCount{} - if err := query.Model(&relational.Evidence{}). - Select("count(DISTINCT uuid) as count, status->>'state' as status"). - Group("status->>'state'"). - Scan(&rows).Error; err != nil { - return nil, err + result := make([]ProfileComplianceStatusCount, len(rows)) + for i, r := range rows { + result[i] = ProfileComplianceStatusCount{Count: r.Count, Status: r.Status} } - - sort.Slice(rows, func(i, j int) bool { - return rows[i].Status < rows[j].Status - }) - - return rows, nil + return result, nil } -// ResponsibilityPosture computes per-responsibility compliance posture (satisfied / -// not-satisfied / unknown) for a downstream SSP, using the same status-count/evidence -// logic as control-keyed posture (computeProfileControlStatus), but keyed by -// responsibility_uuid via filter_responsibilities instead of by (catalogId, controlId) -// via filter_controls (BCH-1339). Feeds the Inherited Capability projection -// (SSPLeverageHandler.LeveragedControls, BCH-1338 Phase 2) so a downstream can see -// whether an inherited responsibility is actually backed by satisfying evidence, not -// just recorded as satisfied at subscribe time. Every requested uuid is always present -// in the returned map — defaulting to "unknown" when no filter targets it — never an -// absent key, matching bulkResolveUpstreamResponsibilities's convention in -// ssp_leverage.go. +// ResponsibilityPosture preserves the pre-extraction call site and signature; the +// implementation moved to the neutral leverage package so package handler (lineage) +// can share it without importing oscal. See leverage.ResponsibilityPosture for the +// full contract. func ResponsibilityPosture(db *gorm.DB, downstreamSSPID uuid.UUID, responsibilityUUIDs []uuid.UUID) (map[uuid.UUID]string, error) { - posture := make(map[uuid.UUID]string, len(responsibilityUUIDs)) - for _, id := range responsibilityUUIDs { - posture[id] = "unknown" - } - if len(responsibilityUUIDs) == 0 { - return posture, nil - } - - var links []relational.FilterResponsibility - if err := db.Where("ssp_id = ? AND responsibility_uuid IN ?", downstreamSSPID, responsibilityUUIDs). - Find(&links).Error; err != nil { - return nil, err - } - if len(links) == 0 { - return posture, nil - } - - filterIDs := make([]uuid.UUID, 0, len(links)) - seenFilterIDs := make(map[uuid.UUID]bool, len(links)) - for _, link := range links { - if !seenFilterIDs[link.FilterID] { - seenFilterIDs[link.FilterID] = true - filterIDs = append(filterIDs, link.FilterID) - } - } - - var filters []relational.Filter - if err := db.Where("id IN ?", filterIDs).Find(&filters).Error; err != nil { - return nil, err - } - filterByID := make(map[uuid.UUID]relational.Filter, len(filters)) - for _, f := range filters { - filterByID[*f.ID] = f - } - - filtersByResponsibility := make(map[uuid.UUID][]labelfilter.Filter, len(links)) - for _, link := range links { - f, ok := filterByID[link.FilterID] - if !ok { - continue - } - filtersByResponsibility[link.ResponsibilityUUID] = append(filtersByResponsibility[link.ResponsibilityUUID], f.Filter.Data()) - } - - for respID, filterList := range filtersByResponsibility { - statusCounts, err := getStatusCountsForFilters(db, filterList) - if err != nil { - return nil, err - } - posture[respID] = computeProfileControlStatus(statusCounts) - } - - return posture, nil + return leverage.ResponsibilityPosture(db, downstreamSSPID, responsibilityUUIDs) } func (h *ProfileHandler) loadImplementedControlsForSSP(sspID uuid.UUID) (map[string]struct{}, error) { @@ -545,25 +548,11 @@ func (h *ProfileHandler) loadImplementedControlsForSSP(sspID uuid.UUID) (map[str } func computeProfileControlStatus(rows []ProfileComplianceStatusCount) string { - hasSatisfied := false - for _, row := range rows { - if row.Count <= 0 { - continue - } - - switch strings.ToLower(strings.TrimSpace(row.Status)) { - case "not-satisfied": - return "not-satisfied" - case "satisfied": - hasSatisfied = true - } - } - - if hasSatisfied { - return "satisfied" + counts := make([]relational.StatusCount, len(rows)) + for i, r := range rows { + counts[i] = relational.StatusCount{Count: r.Count, Status: r.Status} } - - return "unknown" + return relational.CollapseEvidenceStatus(counts) } func computePercent(part, total int) int { @@ -573,3 +562,28 @@ func computePercent(part, total int) int { return int(math.Round((float64(part) / float64(total)) * 100)) } + +// buildProfileControlLeverage maps a leverage.ControlAggregate onto the wire shape for +// a control's leverage badge. Inherited reflects credit-worthiness (all links active, +// all full), independent of whether evidence overrode the control's computed status. +func buildProfileControlLeverage(agg leverage.ControlAggregate) *ProfileComplianceControlLeverage { + origins := make([]ProfileComplianceLeverageOrigin, 0, len(agg.InheritedFrom)) + for _, o := range agg.InheritedFrom { + origins = append(origins, ProfileComplianceLeverageOrigin{ + UpstreamSSPID: o.UpstreamSSPID, + UpstreamSSPTitle: o.UpstreamSSPTitle, + OfferingID: o.OfferingID, + OfferingTitle: o.OfferingTitle, + OfferingVersion: o.OfferingVersion, + }) + } + return &ProfileComplianceControlLeverage{ + Inherited: agg.Credit, + Satisfaction: agg.Satisfaction, + Status: agg.Status, + Links: agg.Links, + OutstandingCount: agg.OutstandingCount, + TotalResponsibilities: agg.TotalResponsibilities, + InheritedFrom: origins, + } +} diff --git a/internal/api/handler/oscal/profile_compliance_leverage_integration_test.go b/internal/api/handler/oscal/profile_compliance_leverage_integration_test.go new file mode 100644 index 00000000..e11b3b92 --- /dev/null +++ b/internal/api/handler/oscal/profile_compliance_leverage_integration_test.go @@ -0,0 +1,200 @@ +//go:build integration + +package oscal + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "time" + + "github.com/compliance-framework/api/internal/service/relational" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "go.uber.org/zap" +) + +// profileLeverageSeed carries the ids of a compliance-progress leverage scenario. +type profileLeverageSeed struct { + profileID uuid.UUID + downstreamSSP uuid.UUID + upstreamSSP uuid.UUID + offeringID uuid.UUID +} + +// seedProfileLeverage creates a profile carrying AC-2, a downstream SSP, and an active +// leverage link inheriting AC-2 from an upstream "Platform SSP" offering with two +// responsibilities. satisfyBoth controls whether both responsibilities are satisfied +// (credit) or only one (partial, no credit). +func (suite *ProfileIntegrationSuite) seedProfileLeverage(satisfyBoth bool) profileLeverageSeed { + suite.Require().NoError(suite.Migrator.Refresh()) + now := time.Now().UTC() + + catID := uuid.New() + control := relational.Control{CatalogID: catID, ID: "AC-2", Title: "Account Management"} + suite.Require().NoError(suite.DB.Create(&control).Error) + + profileID := uuid.New() + profile := relational.Profile{ + UUIDModel: relational.UUIDModel{ID: &profileID}, + Metadata: relational.Metadata{Title: "P", Version: "1.0.0", OscalVersion: "1.1.3"}, + } + suite.Require().NoError(suite.DB.Create(&profile).Error) + suite.Require().NoError(suite.DB.Model(&profile).Association("Controls").Append(&control)) + + downstreamSSP := uuid.New() + suite.Require().NoError(suite.DB.Create(&relational.SystemSecurityPlan{ + UUIDModel: relational.UUIDModel{ID: &downstreamSSP}, + Metadata: relational.Metadata{Title: "Downstream SSP", Version: "1.0.0", OscalVersion: "1.1.3", LastModified: &now}, + }).Error) + + upstreamSSP := uuid.New() + suite.Require().NoError(suite.DB.Create(&relational.SystemSecurityPlan{ + UUIDModel: relational.UUIDModel{ID: &upstreamSSP}, + Metadata: relational.Metadata{Title: "Platform SSP", Version: "1.0.0", OscalVersion: "1.1.3", LastModified: &now}, + }).Error) + offeringID := uuid.New() + suite.Require().NoError(suite.DB.Create(&relational.SSPExportOffering{ + UUIDModel: relational.UUIDModel{ID: &offeringID}, + SSPID: upstreamSSP, + Title: "Managed Postgres", + Version: 2, + Status: relational.SSPExportOfferingStatusPublished, + }).Error) + + exportID := uuid.New() + providedUUID := uuid.New() + suite.Require().NoError(suite.DB.Create(&relational.ProvidedControlImplementation{ + UUIDModel: relational.UUIDModel{ID: &providedUUID}, + Description: "provided", + ExportId: exportID, + }).Error) + resp1, resp2 := uuid.New(), uuid.New() + for _, r := range []uuid.UUID{resp1, resp2} { + r := r + suite.Require().NoError(suite.DB.Create(&relational.ControlImplementationResponsibility{ + UUIDModel: relational.UUIDModel{ID: &r}, + Description: "resp " + r.String(), + ProvidedUuid: providedUUID, + ExportId: exportID, + }).Error) + } + + byComponentID := uuid.New() + inheritedUUID := uuid.New() + suite.Require().NoError(suite.DB.Create(&relational.InheritedControlImplementation{ + UUIDModel: relational.UUIDModel{ID: &inheritedUUID}, + ProvidedUuid: providedUUID, + Description: "inherited", + ByComponentId: byComponentID, + }).Error) + linkID := uuid.New() + suite.Require().NoError(suite.DB.Create(&relational.SSPLeverageLink{ + UUIDModel: relational.UUIDModel{ID: &linkID}, + DownstreamSSPID: downstreamSSP, + UpstreamSSPID: upstreamSSP, + OfferingID: offeringID, + OfferingVersion: 2, + ControlID: "AC-2", + ProvidedUUID: providedUUID, + InheritedUUID: inheritedUUID, + Satisfaction: relational.SSPLeverageSatisfactionFull, + Status: relational.SSPLeverageStatusActive, + }).Error) + + toSatisfy := []uuid.UUID{resp1, resp2} + if !satisfyBoth { + toSatisfy = []uuid.UUID{resp1} + } + for _, r := range toSatisfy { + id := uuid.New() + suite.Require().NoError(suite.DB.Create(&relational.SatisfiedControlImplementationResponsibility{ + UUIDModel: relational.UUIDModel{ID: &id}, + ResponsibilityUuid: r, + Description: "sat", + ByComponentId: byComponentID, + }).Error) + } + + return profileLeverageSeed{profileID: profileID, downstreamSSP: downstreamSSP, upstreamSSP: upstreamSSP, offeringID: offeringID} +} + +func (suite *ProfileIntegrationSuite) complianceProgress(profileID, sspID string) ProfileComplianceProgress { + url := "/profiles/" + profileID + "/compliance-progress" + if sspID != "" { + url += "?sspId=" + sspID + } + e := echo.New() + req := httptest.NewRequest(http.MethodGet, url, nil) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + ctx.SetParamNames("id") + ctx.SetParamValues(profileID) + suite.Require().NoError(NewProfileHandler(zap.NewNop().Sugar(), suite.DB).ComplianceProgress(ctx)) + suite.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + var resp struct { + Data ProfileComplianceProgress `json:"data"` + } + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + return resp.Data +} + +// TestComplianceProgressInheritedCredit: with sspId, an inherited control lands in the +// inherited bucket, carries the leverage badge, and counts as compliant + assessed. +func (suite *ProfileIntegrationSuite) TestComplianceProgressInheritedCredit() { + s := suite.seedProfileLeverage(true) + data := suite.complianceProgress(s.profileID.String(), s.downstreamSSP.String()) + + suite.Equal(1, data.Summary.TotalControls) + suite.Equal(1, data.Summary.Inherited) + suite.Equal(0, data.Summary.Satisfied) + suite.Equal(0, data.Summary.NotSatisfied) + suite.Equal(0, data.Summary.Unknown) + suite.Equal(100, data.Summary.CompliancePct) + suite.Equal(100, data.Summary.AssessedPct) + + suite.Require().Len(data.Controls, 1) + ctrl := data.Controls[0] + suite.Equal("inherited", ctrl.ComputedStatus) + suite.Require().NotNil(ctrl.Leverage) + suite.True(ctrl.Leverage.Inherited) + suite.Equal(relational.SSPLeverageSatisfactionFull, ctrl.Leverage.Satisfaction) + suite.Equal(relational.SSPLeverageStatusActive, ctrl.Leverage.Status) + suite.Equal(1, ctrl.Leverage.Links) + suite.Equal(2, ctrl.Leverage.TotalResponsibilities) + suite.Equal(0, ctrl.Leverage.OutstandingCount) + suite.Require().Len(ctrl.Leverage.InheritedFrom, 1) + suite.Equal(s.upstreamSSP, ctrl.Leverage.InheritedFrom[0].UpstreamSSPID) + suite.Equal("Platform SSP", ctrl.Leverage.InheritedFrom[0].UpstreamSSPTitle) + suite.Equal("Managed Postgres", ctrl.Leverage.InheritedFrom[0].OfferingTitle) + suite.Equal(2, ctrl.Leverage.InheritedFrom[0].OfferingVersion) +} + +// TestComplianceProgressPartialNoCredit: a partially-satisfied link earns no credit — +// the control stays unknown but still surfaces its partial leverage badge. +func (suite *ProfileIntegrationSuite) TestComplianceProgressPartialNoCredit() { + s := suite.seedProfileLeverage(false) + data := suite.complianceProgress(s.profileID.String(), s.downstreamSSP.String()) + + suite.Equal(0, data.Summary.Inherited) + suite.Equal(1, data.Summary.Unknown) + + suite.Require().Len(data.Controls, 1) + ctrl := data.Controls[0] + suite.Equal("unknown", ctrl.ComputedStatus) + suite.Require().NotNil(ctrl.Leverage) + suite.False(ctrl.Leverage.Inherited) + suite.Equal(relational.SSPLeverageSatisfactionPartial, ctrl.Leverage.Satisfaction) + suite.Equal(1, ctrl.Leverage.OutstandingCount) +} + +// TestComplianceProgressNoSSPNoLeverage: without sspId, no leverage payloads are +// emitted and inherited stays 0. +func (suite *ProfileIntegrationSuite) TestComplianceProgressNoSSPNoLeverage() { + s := suite.seedProfileLeverage(true) + data := suite.complianceProgress(s.profileID.String(), "") + + suite.Equal(0, data.Summary.Inherited) + suite.Require().Len(data.Controls, 1) + suite.Nil(data.Controls[0].Leverage) +} diff --git a/internal/api/handler/oscal/ssp_leverage.go b/internal/api/handler/oscal/ssp_leverage.go index fe891634..dc028d40 100644 --- a/internal/api/handler/oscal/ssp_leverage.go +++ b/internal/api/handler/oscal/ssp_leverage.go @@ -18,6 +18,7 @@ import ( "github.com/compliance-framework/api/internal/api/handler" "github.com/compliance-framework/api/internal/api/middleware" "github.com/compliance-framework/api/internal/authz" + "github.com/compliance-framework/api/internal/service/leverage" "github.com/compliance-framework/api/internal/service/relational" "github.com/compliance-framework/api/internal/service/relational/risks" ) @@ -51,13 +52,11 @@ func isUniqueViolation(err error) bool { return errors.As(err, &pgErr) && pgErr.Code == "23505" } -// upstreamResponsibility is the minimal shape both the catalog exposure (BCH-1338 task -// 004) and the subscribe handler (task 005) need: enough to let a downstream subscriber -// pick specific responsibility UUIDs to satisfy, and to compute full/partial coverage. -type upstreamResponsibility struct { - ResponsibilityUUID uuid.UUID `json:"responsibilityUuid"` - Description string `json:"description"` -} +// upstreamResponsibility is an alias to the neutral leverage.Responsibility type; the +// definition moved to internal/service/leverage so package handler can share it. The +// JSON tags (responsibilityUuid, description) are unchanged, so every wire surface +// reached through this name is byte-for-byte identical. +type upstreamResponsibility = leverage.Responsibility // resolveUpstreamResponsibilities finds every ControlImplementationResponsibility that // responsibility-maps to the ProvidedControlImplementation identified by providedUUID. @@ -71,54 +70,13 @@ func resolveUpstreamResponsibilities(db *gorm.DB, providedUUID uuid.UUID) ([]ups return byProvided[providedUUID], nil } -// bulkResolveUpstreamResponsibilities is the batched form of resolveUpstreamResponsibilities: -// two queries total regardless of how many providedUUIDs are requested, rather than two -// queries per item (the catalog list and the leveraged-controls projection each resolve -// responsibilities for many items/links in one request). The two-step lookup is unavoidable -// because ControlImplementationResponsibility and ProvidedControlImplementation are siblings -// under Export with no direct FK between them — only the shared OSCAL-level provided-uuid -// value — so responsibilities are scoped by (export_id, provided_uuid) pairs, not -// provided_uuid alone, since provided-uuid values are only unique within a single upstream's -// Export. providedUUIDs with no matching ProvidedControlImplementation row (e.g. the upstream -// row was since deleted) map to an empty slice, not an error or a missing key. +// bulkResolveUpstreamResponsibilities delegates to +// leverage.BulkResolveUpstreamResponsibilities, preserving this call site's name and +// signature (5 oscal call sites: ssp_leverage.go, ssp_by_components.go ×2, +// ssp_export_offerings.go ×2). See the leverage function for the two-step lookup +// strategy and its (export_id, provided_uuid) scoping. func bulkResolveUpstreamResponsibilities(db *gorm.DB, providedUUIDs []uuid.UUID) (map[uuid.UUID][]upstreamResponsibility, error) { - result := make(map[uuid.UUID][]upstreamResponsibility, len(providedUUIDs)) - for _, id := range providedUUIDs { - result[id] = []upstreamResponsibility{} - } - if len(providedUUIDs) == 0 { - return result, nil - } - - var provided []relational.ProvidedControlImplementation - if err := db.Where("id IN ?", providedUUIDs).Find(&provided).Error; err != nil { - return nil, err - } - if len(provided) == 0 { - return result, nil - } - exportIDByProvided := make(map[uuid.UUID]uuid.UUID, len(provided)) - for _, p := range provided { - exportIDByProvided[*p.ID] = p.ExportId - } - - var responsibilities []relational.ControlImplementationResponsibility - if err := db.Where("provided_uuid IN ?", providedUUIDs).Find(&responsibilities).Error; err != nil { - return nil, err - } - for _, r := range responsibilities { - // Scope by export_id too: provided-uuid values are only unique within a single - // upstream's Export, so a bulk provided_uuid-only match could in principle pick - // up a same-valued responsibility from an unrelated Export. - if exportIDByProvided[r.ProvidedUuid] != r.ExportId { - continue - } - result[r.ProvidedUuid] = append(result[r.ProvidedUuid], upstreamResponsibility{ - ResponsibilityUUID: *r.ID, - Description: r.Description, - }) - } - return result, nil + return leverage.BulkResolveUpstreamResponsibilities(db, providedUUIDs) } // uniqueUUIDs extracts the deduplicated set of UUIDs from items, preserving first-seen @@ -137,22 +95,11 @@ func uniqueUUIDs[T any](items []T, extract func(T) uuid.UUID) []uuid.UUID { return result } -// deriveSatisfaction is the single definition of "full iff every upstream -// responsibility has a matching downstream satisfied" (vacuously full when full is -// empty), shared by Subscribe (computing the satisfaction to store on a new leverage -// link) and LeveragedControls (recomputing it live rather than trusting the stored -// value). Returns the subset of full not covered by satisfiedUUIDs as outstanding. +// deriveSatisfaction delegates to leverage.DeriveSatisfaction, preserving this call +// site's name and signature (Subscribe, the satisfied CRUD resync, ReAttest, and the +// projection all share it, so satisfaction is derived in exactly one place). func deriveSatisfaction(full []upstreamResponsibility, satisfiedUUIDs map[uuid.UUID]bool) (relational.SSPLeverageSatisfaction, []upstreamResponsibility) { - outstanding := make([]upstreamResponsibility, 0) - for _, r := range full { - if !satisfiedUUIDs[r.ResponsibilityUUID] { - outstanding = append(outstanding, r) - } - } - if len(outstanding) == 0 { - return relational.SSPLeverageSatisfactionFull, outstanding - } - return relational.SSPLeverageSatisfactionPartial, outstanding + return leverage.DeriveSatisfaction(full, satisfiedUUIDs) } // SSPLeverageHandler serves the downstream side of BCH-1338 Phase 2: subscribing to a @@ -1091,172 +1038,17 @@ func (h *SSPLeverageHandler) LeveragedControls(ctx echo.Context) error { return ctx.JSON(http.StatusOK, handler.GenericDataListResponse[leveragedControlResponse]{Data: result}) } -// leveragedControlProjection is one downstream leverage link with everything the read -// models need already resolved: the live-recomputed satisfaction (never the link's cached -// value), the outstanding responsibilities, the evidence-backed posture, the open drift -// risk, the offering title, and the by-component + inherited row the link hangs off. -// -// Both the /leveraged-controls endpoint and the shared-responsibility rollup read this, so -// satisfaction is derived in exactly one place and neither surface can drift from the other. -type leveragedControlProjection struct { - Link relational.SSPLeverageLink - OfferingTitle string - ByComponentID uuid.UUID - // Inherited is the downstream's own InheritedControlImplementation row this link - // created; nil only if it has since been deleted out from under the link. - Inherited *relational.InheritedControlImplementation - Satisfaction relational.SSPLeverageSatisfaction - Outstanding []upstreamResponsibility - // Responsibilities is the FULL upstream responsibility set under this link (uuid + - // description), so downstream surfaces can label every responsibility — including ones - // already satisfied — with the upstream's own text. Outstanding is the subset of this - // with no matching downstream satisfied entry. - Responsibilities []upstreamResponsibility - Posture map[uuid.UUID]string - DriftRiskID *uuid.UUID -} +// leveragedControlProjection is an alias to leverage.Projection; the definition moved +// to the neutral leverage package so package handler (lineage) can read the same +// projection. Field names and types are unchanged, so every consumer here and in +// ssp_shared_responsibility.go is untouched. +type leveragedControlProjection = leverage.Projection -// projectLeveragedControls builds the projection for every leverage link on one downstream -// SSP in six queries at THIS level, independent of link count — the batching that replaced -// this code's original four-queries-per-link loop, preserved here so neither caller can -// regress it into an N+1. In particular the ResponsibilityPosture(db, sspID, -// allResponsibilityUUIDs) call below must stay a single call for every uuid, never one per -// link. -// -// The end-to-end cost is ~6 + N, not six: ResponsibilityPosture finishes with a -// per-responsibility loop (profile_compliance.go, getStatusCountsForFilters) that issues one -// evidence aggregate — count(DISTINCT uuid) grouped by status->>state, over the -// latest-evidence-stream subquery and the label-filter joins — for every responsibility -// carrying at least one FilterResponsibility link. Those N queries are the expensive ones, and -// both /leveraged-controls and GET /:id/shared-responsibility pay them. Hoisting that loop -// rewrites evidence aggregation and belongs in its own change. +// projectLeveragedControls delegates to leverage.Project, preserving this call site's +// name and signature (used by LeveragedControls and collectInheritedSharedResponsibility). +// The six-query batching lives in leverage.Project; see its doc for the N+1 constraint. func projectLeveragedControls(db *gorm.DB, sspID uuid.UUID) ([]leveragedControlProjection, error) { - var links []relational.SSPLeverageLink - if err := db.Where("downstream_ssp_id = ?", sspID).Order("id ASC").Find(&links).Error; err != nil { - return nil, fmt.Errorf("failed to list leverage links: %w", err) - } - if len(links) == 0 { - return []leveragedControlProjection{}, nil - } - - offeringIDs := uniqueUUIDs(links, func(l relational.SSPLeverageLink) uuid.UUID { return l.OfferingID }) - var offerings []relational.SSPExportOffering - if err := db.Select("id, title").Where("id IN ?", offeringIDs).Find(&offerings).Error; err != nil { - return nil, fmt.Errorf("failed to load offerings for leverage links: %w", err) - } - offeringTitleByID := make(map[uuid.UUID]string, len(offerings)) - for _, o := range offerings { - offeringTitleByID[*o.ID] = o.Title - } - - inheritedIDs := uniqueUUIDs(links, func(l relational.SSPLeverageLink) uuid.UUID { return l.InheritedUUID }) - var inheritedRows []relational.InheritedControlImplementation - if err := db.Where("id IN ?", inheritedIDs).Find(&inheritedRows).Error; err != nil { - return nil, fmt.Errorf("failed to load inherited control implementations for leverage links: %w", err) - } - inheritedByID := make(map[uuid.UUID]relational.InheritedControlImplementation, len(inheritedRows)) - for _, i := range inheritedRows { - inheritedByID[*i.ID] = i - } - - providedUUIDs := uniqueUUIDs(links, func(l relational.SSPLeverageLink) uuid.UUID { return l.ProvidedUUID }) - fullSetByProvided, err := bulkResolveUpstreamResponsibilities(db, providedUUIDs) - if err != nil { - return nil, fmt.Errorf("failed to resolve upstream responsibilities for leverage links: %w", err) - } - - byComponentIDs := uniqueUUIDs(inheritedRows, func(i relational.InheritedControlImplementation) uuid.UUID { return i.ByComponentId }) - var satisfiedRows []relational.SatisfiedControlImplementationResponsibility - if len(byComponentIDs) > 0 { - if err := db.Where("by_component_id IN ?", byComponentIDs).Find(&satisfiedRows).Error; err != nil { - return nil, fmt.Errorf("failed to load satisfied responsibilities for leverage links: %w", err) - } - } - satisfiedByComponent := make(map[uuid.UUID]map[uuid.UUID]bool, len(byComponentIDs)) - for _, s := range satisfiedRows { - if satisfiedByComponent[s.ByComponentId] == nil { - satisfiedByComponent[s.ByComponentId] = make(map[uuid.UUID]bool) - } - satisfiedByComponent[s.ByComponentId][s.ResponsibilityUuid] = true - } - - // Batch every responsibility uuid under every link's provided-uuid into a single - // ResponsibilityPosture call, rather than one call per link. - var allResponsibilityUUIDs []uuid.UUID - for _, full := range fullSetByProvided { - for _, r := range full { - allResponsibilityUUIDs = append(allResponsibilityUUIDs, r.ResponsibilityUUID) - } - } - posture, err := ResponsibilityPosture(db, sspID, allResponsibilityUUIDs) - if err != nil { - return nil, fmt.Errorf("failed to compute responsibility posture for leverage links: %w", 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 := db.Select("id, dedupe_key"). - Where("ssp_id = ? AND dedupe_key IN ? AND status != ?", sspID, dedupeKeys, risks.RiskStatusClosed). - Find(&driftRisks).Error; err != nil { - return nil, fmt.Errorf("failed to load drift risks for leverage links: %w", err) - } - for _, r := range driftRisks { - if linkID, ok := dedupeKeyToLinkID[r.DedupeKey]; ok { - driftRiskIDByLinkID[linkID] = *r.ID - } - } - } - - result := make([]leveragedControlProjection, 0, len(links)) - for _, link := range links { - var inherited *relational.InheritedControlImplementation - var byComponentID uuid.UUID - if row, ok := inheritedByID[link.InheritedUUID]; ok { - inherited = &row - byComponentID = row.ByComponentId - } - - full := fullSetByProvided[link.ProvidedUUID] - satisfaction, outstanding := deriveSatisfaction(full, satisfiedByComponent[byComponentID]) - - linkPosture := make(map[uuid.UUID]string, len(full)) - for _, r := range full { - linkPosture[r.ResponsibilityUUID] = posture[r.ResponsibilityUUID] - } - - var driftRiskID *uuid.UUID - if id, ok := driftRiskIDByLinkID[*link.ID]; ok { - driftRiskID = &id - } - - result = append(result, leveragedControlProjection{ - Link: link, - OfferingTitle: offeringTitleByID[link.OfferingID], - ByComponentID: byComponentID, - Inherited: inherited, - Satisfaction: satisfaction, - Outstanding: outstanding, - Responsibilities: full, - Posture: linkPosture, - DriftRiskID: driftRiskID, - }) - } - - return result, nil + return leverage.Project(db, sspID) } // ReAttest clears drift on a leverage link (BCH-1341): bumps OfferingVersion to the diff --git a/internal/api/handler/oscal/ssp_leverage_drift.go b/internal/api/handler/oscal/ssp_leverage_drift.go index cab808a2..40f6c9de 100644 --- a/internal/api/handler/oscal/ssp_leverage_drift.go +++ b/internal/api/handler/oscal/ssp_leverage_drift.go @@ -13,6 +13,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" + "github.com/compliance-framework/api/internal/service/leverage" "github.com/compliance-framework/api/internal/service/relational" "github.com/compliance-framework/api/internal/service/relational/risks" ) @@ -63,13 +64,12 @@ func enqueueLeverageDriftNotificationsAsync(ctx echo.Context, sugar *zap.Sugared enqueueLeverageDriftNotifications(enqueueCtx, sugar, jobEnqueuer, links) } -// computeDedupeKeyForLeverageDrift returns the dedupe key for the drift risk -// associated with a single SSPLeverageLink. One risk per leverage link: the link -// itself is the natural, deterministic scope (no risk template is involved, unlike -// evidence-driven risks), and the key is directly parseable back to the link that -// produced it without needing a separate link table. +// computeDedupeKeyForLeverageDrift delegates to leverage.DriftDedupeKey, preserving +// this call site's name. Keeping the drift writer (applyDriftToLink) and the +// projection reader on the one shared "leverage-drift:%s" definition is the point of +// the extraction. func computeDedupeKeyForLeverageDrift(linkID uuid.UUID) string { - return fmt.Sprintf("leverage-drift:%s", linkID) + return leverage.DriftDedupeKey(linkID) } // applyDriftToLink flips an active SSPLeverageLink to drifted and creates (or reopens) diff --git a/internal/service/leverage/leverage_test.go b/internal/service/leverage/leverage_test.go new file mode 100644 index 00000000..545fed6a --- /dev/null +++ b/internal/service/leverage/leverage_test.go @@ -0,0 +1,228 @@ +package leverage + +import ( + "testing" + + "github.com/google/uuid" + + "github.com/compliance-framework/api/internal/service/relational" +) + +func statement(s string) *string { return &s } + +// summary is a small helper to build a LinkSummary for one control in one SSP. +func summary(sspID, upstream, offering uuid.UUID, controlID string, stmt *string, status relational.SSPLeverageStatus, sat relational.SSPLeverageSatisfaction, outstanding, total int) LinkSummary { + return LinkSummary{ + LinkID: uuid.New(), + DownstreamSSPID: sspID, + UpstreamSSPID: upstream, + OfferingID: offering, + UpstreamSSPTitle: "Upstream", + OfferingTitle: "Offering", + OfferingVersion: 2, + ControlID: controlID, + StatementID: stmt, + Status: status, + Satisfaction: sat, + OutstandingCount: outstanding, + TotalResponsibilities: total, + } +} + +func TestNormalizeControlID(t *testing.T) { + cases := map[string]string{ + "ac-2": "AC-2", + " ac-2 ": "AC-2", + "AC-2": "AC-2", + "Ac-2_smt": "AC-2_SMT", + } + for in, want := range cases { + if got := NormalizeControlID(in); got != want { + t.Errorf("NormalizeControlID(%q) = %q, want %q", in, got, want) + } + } +} + +func TestDriftDedupeKey(t *testing.T) { + id := uuid.MustParse("11111111-1111-1111-1111-111111111111") + want := "leverage-drift:11111111-1111-1111-1111-111111111111" + if got := DriftDedupeKey(id); got != want { + t.Errorf("DriftDedupeKey = %q, want %q", got, want) + } +} + +func TestDeriveSatisfactionVacuousFull(t *testing.T) { + // An empty responsibility set is vacuously full: nothing outstanding. + sat, outstanding := DeriveSatisfaction(nil, map[uuid.UUID]bool{}) + if sat != relational.SSPLeverageSatisfactionFull { + t.Errorf("empty set: got %q, want full", sat) + } + if len(outstanding) != 0 { + t.Errorf("empty set: expected no outstanding, got %d", len(outstanding)) + } + + r1, r2 := uuid.New(), uuid.New() + full := []Responsibility{{ResponsibilityUUID: r1}, {ResponsibilityUUID: r2}} + // All satisfied -> full. + sat, outstanding = DeriveSatisfaction(full, map[uuid.UUID]bool{r1: true, r2: true}) + if sat != relational.SSPLeverageSatisfactionFull || len(outstanding) != 0 { + t.Errorf("all satisfied: got %q / %d outstanding, want full / 0", sat, len(outstanding)) + } + // One missing -> partial, that one outstanding. + sat, outstanding = DeriveSatisfaction(full, map[uuid.UUID]bool{r1: true}) + if sat != relational.SSPLeverageSatisfactionPartial || len(outstanding) != 1 || outstanding[0].ResponsibilityUUID != r2 { + t.Errorf("one missing: got %q / %v, want partial / [r2]", sat, outstanding) + } +} + +func TestAggregateByControl_CreditAllActiveAllFull(t *testing.T) { + ssp := uuid.New() + up := uuid.New() + off := uuid.New() + summaries := []LinkSummary{ + summary(ssp, up, off, "AC-2", nil, relational.SSPLeverageStatusActive, relational.SSPLeverageSatisfactionFull, 0, 3), + } + agg := AggregateByControl(summaries) + got, ok := agg[ControlKey{SSPID: ssp, ControlID: "AC-2"}] + if !ok { + t.Fatal("expected an aggregate for AC-2") + } + if !got.Credit { + t.Error("expected credit for a single active+full link") + } + if got.Links != 1 || got.TotalResponsibilities != 3 || got.OutstandingCount != 0 { + t.Errorf("unexpected counts: %+v", got) + } + if got.Status != relational.SSPLeverageStatusActive || got.Satisfaction != relational.SSPLeverageSatisfactionFull { + t.Errorf("unexpected status/satisfaction: %+v", got) + } +} + +func TestAggregateByControl_NoCreditOnPartialOrNonActive(t *testing.T) { + ssp := uuid.New() + up := uuid.New() + off := uuid.New() + + noCredit := []struct { + name string + status relational.SSPLeverageStatus + sat relational.SSPLeverageSatisfaction + }{ + {"drifted", relational.SSPLeverageStatusDrifted, relational.SSPLeverageSatisfactionFull}, + {"revoked", relational.SSPLeverageStatusRevoked, relational.SSPLeverageSatisfactionFull}, + {"superseded", relational.SSPLeverageStatusSuperseded, relational.SSPLeverageSatisfactionFull}, + {"partial", relational.SSPLeverageStatusActive, relational.SSPLeverageSatisfactionPartial}, + } + for _, tc := range noCredit { + t.Run(tc.name, func(t *testing.T) { + agg := AggregateByControl([]LinkSummary{ + summary(ssp, up, off, "AC-2", nil, tc.status, tc.sat, 1, 3), + }) + if agg[ControlKey{SSPID: ssp, ControlID: "AC-2"}].Credit { + t.Errorf("%s must not earn credit", tc.name) + } + }) + } +} + +func TestAggregateByControl_WorstStatusPrecedence(t *testing.T) { + ssp := uuid.New() + up := uuid.New() + off := uuid.New() + // active + superseded + revoked + drifted -> worst is drifted. + summaries := []LinkSummary{ + summary(ssp, up, off, "AC-2", nil, relational.SSPLeverageStatusActive, relational.SSPLeverageSatisfactionFull, 0, 1), + summary(ssp, up, off, "AC-2", nil, relational.SSPLeverageStatusSuperseded, relational.SSPLeverageSatisfactionFull, 0, 1), + summary(ssp, up, off, "AC-2", nil, relational.SSPLeverageStatusRevoked, relational.SSPLeverageSatisfactionFull, 0, 1), + summary(ssp, up, off, "AC-2", nil, relational.SSPLeverageStatusDrifted, relational.SSPLeverageSatisfactionFull, 0, 1), + } + got := AggregateByControl(summaries)[ControlKey{SSPID: ssp, ControlID: "AC-2"}] + if got.Status != relational.SSPLeverageStatusDrifted { + t.Errorf("worst status = %q, want drifted", got.Status) + } + if got.Credit { + t.Error("a non-active link must strip credit") + } +} + +func TestAggregateByControl_StatementScopedCollapseAndCaseFold(t *testing.T) { + ssp := uuid.New() + up := uuid.New() + off := uuid.New() + // A control-scoped link plus a statement-scoped link with differently-cased id + // collapse into one control key; counts sum. + summaries := []LinkSummary{ + summary(ssp, up, off, "ac-2", nil, relational.SSPLeverageStatusActive, relational.SSPLeverageSatisfactionFull, 0, 2), + summary(ssp, up, off, "AC-2", statement("ac-2_smt.a"), relational.SSPLeverageStatusActive, relational.SSPLeverageSatisfactionFull, 0, 3), + } + agg := AggregateByControl(summaries) + if len(agg) != 1 { + t.Fatalf("expected 1 control key, got %d", len(agg)) + } + got := agg[ControlKey{SSPID: ssp, ControlID: "AC-2"}] + if got.Links != 2 || got.TotalResponsibilities != 5 || got.OutstandingCount != 0 { + t.Errorf("collapse sums wrong: %+v", got) + } + if !got.Credit { + t.Error("both active+full should keep credit") + } +} + +func TestAggregateByControl_OriginDedupeAndSums(t *testing.T) { + ssp := uuid.New() + up1 := uuid.New() + up2 := uuid.New() + off1 := uuid.New() + off2 := uuid.New() + // Two links from the same (upstream, offering) dedupe to one origin; a third from a + // different pair adds a second. Outstanding/total sum across all links. + summaries := []LinkSummary{ + summary(ssp, up1, off1, "AC-2", nil, relational.SSPLeverageStatusActive, relational.SSPLeverageSatisfactionPartial, 1, 3), + summary(ssp, up1, off1, "AC-2", statement("ac-2_smt.a"), relational.SSPLeverageStatusActive, relational.SSPLeverageSatisfactionFull, 0, 2), + summary(ssp, up2, off2, "AC-2", nil, relational.SSPLeverageStatusActive, relational.SSPLeverageSatisfactionFull, 0, 1), + } + got := AggregateByControl(summaries)[ControlKey{SSPID: ssp, ControlID: "AC-2"}] + if len(got.InheritedFrom) != 2 { + t.Errorf("expected 2 deduped origins, got %d", len(got.InheritedFrom)) + } + if got.OutstandingCount != 1 || got.TotalResponsibilities != 6 { + t.Errorf("sums wrong: outstanding=%d total=%d", got.OutstandingCount, got.TotalResponsibilities) + } + if got.Satisfaction != relational.SSPLeverageSatisfactionPartial { + t.Errorf("any partial link -> partial aggregate, got %q", got.Satisfaction) + } + if got.Credit { + t.Error("a partial link strips credit") + } +} + +func TestAggregateByControl_SeparateSSPsAndControls(t *testing.T) { + sspA := uuid.New() + sspB := uuid.New() + up := uuid.New() + off := uuid.New() + summaries := []LinkSummary{ + summary(sspA, up, off, "AC-2", nil, relational.SSPLeverageStatusActive, relational.SSPLeverageSatisfactionFull, 0, 1), + summary(sspB, up, off, "AC-2", nil, relational.SSPLeverageStatusActive, relational.SSPLeverageSatisfactionFull, 0, 1), + summary(sspA, up, off, "AC-3", nil, relational.SSPLeverageStatusActive, relational.SSPLeverageSatisfactionFull, 0, 1), + } + agg := AggregateByControl(summaries) + if len(agg) != 3 { + t.Fatalf("expected 3 distinct (ssp, control) keys, got %d", len(agg)) + } + for _, key := range []ControlKey{ + {SSPID: sspA, ControlID: "AC-2"}, + {SSPID: sspB, ControlID: "AC-2"}, + {SSPID: sspA, ControlID: "AC-3"}, + } { + if !agg[key].Credit { + t.Errorf("expected credit for %+v", key) + } + } +} + +func TestAggregateByControl_Empty(t *testing.T) { + if got := AggregateByControl(nil); len(got) != 0 { + t.Errorf("expected empty map, got %d entries", len(got)) + } +} diff --git a/internal/service/leverage/posture.go b/internal/service/leverage/posture.go new file mode 100644 index 00000000..2dfaf4ac --- /dev/null +++ b/internal/service/leverage/posture.go @@ -0,0 +1,75 @@ +package leverage + +import ( + "github.com/google/uuid" + "gorm.io/gorm" + + "github.com/compliance-framework/api/internal/converters/labelfilter" + "github.com/compliance-framework/api/internal/service/relational" +) + +// ResponsibilityPosture computes per-responsibility compliance posture (satisfied / +// not-satisfied / unknown) for a downstream SSP, using the same evidence +// status-count/collapse logic as control-keyed posture +// (relational.CollapseEvidenceStatus), but keyed by responsibility_uuid via +// filter_responsibilities instead of by (catalogId, controlId) via filter_controls +// (BCH-1339). Feeds the Inherited Capability projection so a downstream can see +// whether an inherited responsibility is actually backed by satisfying evidence, not +// just recorded as satisfied at subscribe time. Every requested uuid is always +// present in the returned map — defaulting to "unknown" when no filter targets it — +// never an absent key, matching BulkResolveUpstreamResponsibilities's convention. +func ResponsibilityPosture(db *gorm.DB, downstreamSSPID uuid.UUID, responsibilityUUIDs []uuid.UUID) (map[uuid.UUID]string, error) { + posture := make(map[uuid.UUID]string, len(responsibilityUUIDs)) + for _, id := range responsibilityUUIDs { + posture[id] = "unknown" + } + if len(responsibilityUUIDs) == 0 { + return posture, nil + } + + var links []relational.FilterResponsibility + if err := db.Where("ssp_id = ? AND responsibility_uuid IN ?", downstreamSSPID, responsibilityUUIDs). + Find(&links).Error; err != nil { + return nil, err + } + if len(links) == 0 { + return posture, nil + } + + filterIDs := make([]uuid.UUID, 0, len(links)) + seenFilterIDs := make(map[uuid.UUID]bool, len(links)) + for _, link := range links { + if !seenFilterIDs[link.FilterID] { + seenFilterIDs[link.FilterID] = true + filterIDs = append(filterIDs, link.FilterID) + } + } + + var filters []relational.Filter + if err := db.Where("id IN ?", filterIDs).Find(&filters).Error; err != nil { + return nil, err + } + filterByID := make(map[uuid.UUID]relational.Filter, len(filters)) + for _, f := range filters { + filterByID[*f.ID] = f + } + + filtersByResponsibility := make(map[uuid.UUID][]labelfilter.Filter, len(links)) + for _, link := range links { + f, ok := filterByID[link.FilterID] + if !ok { + continue + } + filtersByResponsibility[link.ResponsibilityUUID] = append(filtersByResponsibility[link.ResponsibilityUUID], f.Filter.Data()) + } + + for respID, filterList := range filtersByResponsibility { + statusCounts, err := relational.EvidenceStatusCountsForFilters(db, filterList) + if err != nil { + return nil, err + } + posture[respID] = relational.CollapseEvidenceStatus(statusCounts) + } + + return posture, nil +} diff --git a/internal/service/leverage/project.go b/internal/service/leverage/project.go new file mode 100644 index 00000000..6c5e7047 --- /dev/null +++ b/internal/service/leverage/project.go @@ -0,0 +1,198 @@ +package leverage + +import ( + "fmt" + + "github.com/google/uuid" + "gorm.io/gorm" + + "github.com/compliance-framework/api/internal/service/relational" + "github.com/compliance-framework/api/internal/service/relational/risks" +) + +// Project builds the Projection for every leverage link on one downstream SSP in six +// queries at THIS level, independent of link count — the batching that replaced this +// code's original four-queries-per-link loop, preserved here so no caller can +// regress it into an N+1. In particular the ResponsibilityPosture(db, sspID, +// allResponsibilityUUIDs) call below must stay a single call for every uuid, never +// one per link. +// +// The end-to-end cost is ~6 + N, not six: ResponsibilityPosture finishes with a +// per-responsibility loop (relational.EvidenceStatusCountsForFilters) that issues one +// evidence aggregate — count(DISTINCT uuid) grouped by status->>state, over the +// latest-evidence-stream subquery and the label-filter joins — for every +// responsibility carrying at least one FilterResponsibility link. Those N queries are +// the expensive ones, and both /leveraged-controls and GET /:id/shared-responsibility +// pay them. Hoisting that loop rewrites evidence aggregation and belongs in its own +// change. +func Project(db *gorm.DB, sspID uuid.UUID) ([]Projection, error) { + var links []relational.SSPLeverageLink + if err := db.Where("downstream_ssp_id = ?", sspID).Order("id ASC").Find(&links).Error; err != nil { + return nil, fmt.Errorf("failed to list leverage links: %w", err) + } + if len(links) == 0 { + return []Projection{}, nil + } + return projectLinks(db, sspID, links) +} + +// projectLinks is the shared body of Project (one downstream SSP) and ProjectForControl +// (one control-id across every downstream SSP). It assumes all links belong to the +// single downstream SSP identified by sspID — ResponsibilityPosture and the drift-risk +// lookup are both scoped to it — so ProjectForControl calls it once per SSP group. +func projectLinks(db *gorm.DB, sspID uuid.UUID, links []relational.SSPLeverageLink) ([]Projection, error) { + offeringIDs := uniqueUUIDs(links, func(l relational.SSPLeverageLink) uuid.UUID { return l.OfferingID }) + var offerings []relational.SSPExportOffering + if err := db.Select("id, title").Where("id IN ?", offeringIDs).Find(&offerings).Error; err != nil { + return nil, fmt.Errorf("failed to load offerings for leverage links: %w", err) + } + offeringTitleByID := make(map[uuid.UUID]string, len(offerings)) + for _, o := range offerings { + offeringTitleByID[*o.ID] = o.Title + } + + inheritedIDs := uniqueUUIDs(links, func(l relational.SSPLeverageLink) uuid.UUID { return l.InheritedUUID }) + var inheritedRows []relational.InheritedControlImplementation + if err := db.Where("id IN ?", inheritedIDs).Find(&inheritedRows).Error; err != nil { + return nil, fmt.Errorf("failed to load inherited control implementations for leverage links: %w", err) + } + inheritedByID := make(map[uuid.UUID]relational.InheritedControlImplementation, len(inheritedRows)) + for _, i := range inheritedRows { + inheritedByID[*i.ID] = i + } + + providedUUIDs := uniqueUUIDs(links, func(l relational.SSPLeverageLink) uuid.UUID { return l.ProvidedUUID }) + fullSetByProvided, err := BulkResolveUpstreamResponsibilities(db, providedUUIDs) + if err != nil { + return nil, fmt.Errorf("failed to resolve upstream responsibilities for leverage links: %w", err) + } + + byComponentIDs := uniqueUUIDs(inheritedRows, func(i relational.InheritedControlImplementation) uuid.UUID { return i.ByComponentId }) + var satisfiedRows []relational.SatisfiedControlImplementationResponsibility + if len(byComponentIDs) > 0 { + if err := db.Where("by_component_id IN ?", byComponentIDs).Find(&satisfiedRows).Error; err != nil { + return nil, fmt.Errorf("failed to load satisfied responsibilities for leverage links: %w", err) + } + } + satisfiedByComponent := make(map[uuid.UUID]map[uuid.UUID]bool, len(byComponentIDs)) + for _, s := range satisfiedRows { + if satisfiedByComponent[s.ByComponentId] == nil { + satisfiedByComponent[s.ByComponentId] = make(map[uuid.UUID]bool) + } + satisfiedByComponent[s.ByComponentId][s.ResponsibilityUuid] = true + } + + // Batch every responsibility uuid under every link's provided-uuid into a single + // ResponsibilityPosture call, rather than one call per link. + var allResponsibilityUUIDs []uuid.UUID + for _, full := range fullSetByProvided { + for _, r := range full { + allResponsibilityUUIDs = append(allResponsibilityUUIDs, r.ResponsibilityUUID) + } + } + posture, err := ResponsibilityPosture(db, sspID, allResponsibilityUUIDs) + if err != nil { + return nil, fmt.Errorf("failed to compute responsibility posture for leverage links: %w", err) + } + + // Batch-resolve every drifted link's open drift risk in one query, keyed by the + // dedupe_key convention DriftDedupeKey/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 := DriftDedupeKey(*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 := db.Select("id, dedupe_key"). + Where("ssp_id = ? AND dedupe_key IN ? AND status != ?", sspID, dedupeKeys, risks.RiskStatusClosed). + Find(&driftRisks).Error; err != nil { + return nil, fmt.Errorf("failed to load drift risks for leverage links: %w", err) + } + for _, r := range driftRisks { + if linkID, ok := dedupeKeyToLinkID[r.DedupeKey]; ok { + driftRiskIDByLinkID[linkID] = *r.ID + } + } + } + + result := make([]Projection, 0, len(links)) + for _, link := range links { + var inherited *relational.InheritedControlImplementation + var byComponentID uuid.UUID + if row, ok := inheritedByID[link.InheritedUUID]; ok { + inherited = &row + byComponentID = row.ByComponentId + } + + full := fullSetByProvided[link.ProvidedUUID] + satisfaction, outstanding := DeriveSatisfaction(full, satisfiedByComponent[byComponentID]) + + linkPosture := make(map[uuid.UUID]string, len(full)) + for _, r := range full { + linkPosture[r.ResponsibilityUUID] = posture[r.ResponsibilityUUID] + } + + var driftRiskID *uuid.UUID + if id, ok := driftRiskIDByLinkID[*link.ID]; ok { + driftRiskID = &id + } + + result = append(result, Projection{ + Link: link, + OfferingTitle: offeringTitleByID[link.OfferingID], + ByComponentID: byComponentID, + Inherited: inherited, + Satisfaction: satisfaction, + Outstanding: outstanding, + Responsibilities: full, + Posture: linkPosture, + DriftRiskID: driftRiskID, + }) + } + + return result, nil +} + +// ProjectForControl builds the Projection for every downstream SSP holding at least +// one leverage link for controlID (UPPER-folded match, no catalog id — the same +// keying every leverage read uses). It groups the links by downstream SSP and runs +// the same batched resolution projectLinks performs per SSP, so ResponsibilityPosture +// and the drift-risk lookup (both SSP-scoped) run once per involved downstream SSP — +// bounded by subscriber count, acceptable because this backs the drawer endpoint only. +// The returned map is keyed by downstream SSP id. +func ProjectForControl(db *gorm.DB, controlID string) (map[uuid.UUID][]Projection, error) { + var links []relational.SSPLeverageLink + if err := db.Where("UPPER(control_id) = UPPER(?)", controlID).Order("id ASC").Find(&links).Error; err != nil { + return nil, fmt.Errorf("failed to list leverage links for control: %w", err) + } + result := make(map[uuid.UUID][]Projection) + if len(links) == 0 { + return result, nil + } + + linksBySSP := make(map[uuid.UUID][]relational.SSPLeverageLink) + sspOrder := make([]uuid.UUID, 0) + for _, link := range links { + if _, seen := linksBySSP[link.DownstreamSSPID]; !seen { + sspOrder = append(sspOrder, link.DownstreamSSPID) + } + linksBySSP[link.DownstreamSSPID] = append(linksBySSP[link.DownstreamSSPID], link) + } + + for _, sspID := range sspOrder { + projections, err := projectLinks(db, sspID, linksBySSP[sspID]) + if err != nil { + return nil, err + } + result[sspID] = projections + } + return result, nil +} diff --git a/internal/service/leverage/resolve.go b/internal/service/leverage/resolve.go new file mode 100644 index 00000000..9132c653 --- /dev/null +++ b/internal/service/leverage/resolve.go @@ -0,0 +1,90 @@ +package leverage + +import ( + "fmt" + + "github.com/google/uuid" + "gorm.io/gorm" + + "github.com/compliance-framework/api/internal/service/relational" +) + +// BulkResolveUpstreamResponsibilities is the batched resolver: two queries total +// regardless of how many providedUUIDs are requested, rather than two queries per +// item (the catalog list and the leveraged-controls projection each resolve +// responsibilities for many items/links in one request). The two-step lookup is +// unavoidable because ControlImplementationResponsibility and +// ProvidedControlImplementation are siblings under Export with no direct FK between +// them — only the shared OSCAL-level provided-uuid value — so responsibilities are +// scoped by (export_id, provided_uuid) pairs, not provided_uuid alone, since +// provided-uuid values are only unique within a single upstream's Export. +// providedUUIDs with no matching ProvidedControlImplementation row (e.g. the +// upstream row was since deleted) map to an empty slice, not an error or a missing +// key. +func BulkResolveUpstreamResponsibilities(db *gorm.DB, providedUUIDs []uuid.UUID) (map[uuid.UUID][]Responsibility, error) { + result := make(map[uuid.UUID][]Responsibility, len(providedUUIDs)) + for _, id := range providedUUIDs { + result[id] = []Responsibility{} + } + if len(providedUUIDs) == 0 { + return result, nil + } + + var provided []relational.ProvidedControlImplementation + if err := db.Where("id IN ?", providedUUIDs).Find(&provided).Error; err != nil { + return nil, err + } + if len(provided) == 0 { + return result, nil + } + exportIDByProvided := make(map[uuid.UUID]uuid.UUID, len(provided)) + for _, p := range provided { + exportIDByProvided[*p.ID] = p.ExportId + } + + var responsibilities []relational.ControlImplementationResponsibility + if err := db.Where("provided_uuid IN ?", providedUUIDs).Find(&responsibilities).Error; err != nil { + return nil, err + } + for _, r := range responsibilities { + // Scope by export_id too: provided-uuid values are only unique within a single + // upstream's Export, so a bulk provided_uuid-only match could in principle pick + // up a same-valued responsibility from an unrelated Export. + if exportIDByProvided[r.ProvidedUuid] != r.ExportId { + continue + } + result[r.ProvidedUuid] = append(result[r.ProvidedUuid], Responsibility{ + ResponsibilityUUID: *r.ID, + Description: r.Description, + }) + } + return result, nil +} + +// DeriveSatisfaction is the single definition of "full iff every upstream +// responsibility has a matching downstream satisfied" (vacuously full when full is +// empty), shared by Subscribe (computing the satisfaction to store on a new leverage +// link) and every read path (recomputing it live rather than trusting the stored +// value). Returns the subset of full not covered by satisfiedUUIDs as outstanding. +func DeriveSatisfaction(full []Responsibility, satisfiedUUIDs map[uuid.UUID]bool) (relational.SSPLeverageSatisfaction, []Responsibility) { + outstanding := make([]Responsibility, 0) + for _, r := range full { + if !satisfiedUUIDs[r.ResponsibilityUUID] { + outstanding = append(outstanding, r) + } + } + if len(outstanding) == 0 { + return relational.SSPLeverageSatisfactionFull, outstanding + } + return relational.SSPLeverageSatisfactionPartial, outstanding +} + +// DriftDedupeKey returns the dedupe key for the drift risk associated with a single +// SSPLeverageLink. One risk per leverage link: the link itself is the natural, +// deterministic scope (no risk template is involved, unlike evidence-driven risks), +// and the key is directly parseable back to the link that produced it without +// needing a separate link table. The "leverage-drift:%s" format keeps the drift +// writer (applyDriftToLink) and the projection reader in lockstep. +func DriftDedupeKey(linkID uuid.UUID) string { + return fmt.Sprintf("leverage-drift:%s", linkID) +} diff --git a/internal/service/leverage/summary.go b/internal/service/leverage/summary.go new file mode 100644 index 00000000..1f9edbac --- /dev/null +++ b/internal/service/leverage/summary.go @@ -0,0 +1,263 @@ +package leverage + +import ( + "strings" + + "github.com/google/uuid" + "gorm.io/gorm" + + "github.com/compliance-framework/api/internal/service/relational" +) + +// LinkSummary is one leverage link reduced to what the cross-SSP compliance and +// lineage read surfaces need: the identity of the link, the upstream it came from +// (with resolved titles), its lifecycle Status, and its LIVE-derived Satisfaction and +// responsibility counts. Satisfaction is recomputed from the current satisfied rows +// via DeriveSatisfaction — never the link's stored satisfaction column, which can rot +// when the upstream changes its responsibility set. +type LinkSummary struct { + LinkID uuid.UUID + DownstreamSSPID uuid.UUID + UpstreamSSPID uuid.UUID + OfferingID uuid.UUID + + UpstreamSSPTitle string + OfferingTitle string + OfferingVersion int + + ControlID string // as stored (casing preserved); folded only when aggregating + StatementID *string + + Status relational.SSPLeverageStatus + Satisfaction relational.SSPLeverageSatisfaction + + OutstandingCount int + TotalResponsibilities int +} + +// Summarize builds a LinkSummary for every leverage link, live-deriving satisfaction, +// in ~7 bulk queries regardless of link count. sspID nil summarizes every downstream +// SSP (lineage global scope); non-nil restricts to that one downstream SSP. +// +// It is deliberately lighter than Project: no per-responsibility posture and no drift +// risk id (those back the drawer/detail views, not the counts), so it stays cheap +// enough to run on every lineage engine build. +func Summarize(db *gorm.DB, sspID *uuid.UUID) ([]LinkSummary, error) { + var links []relational.SSPLeverageLink + query := db.Order("id ASC") + if sspID != nil { + query = query.Where("downstream_ssp_id = ?", *sspID) + } + if err := query.Find(&links).Error; err != nil { + return nil, err + } + if len(links) == 0 { + return []LinkSummary{}, nil + } + + // Inherited rows resolve each link's by-component (the anchor its satisfied rows + // hang off). + inheritedIDs := uniqueUUIDs(links, func(l relational.SSPLeverageLink) uuid.UUID { return l.InheritedUUID }) + var inheritedRows []relational.InheritedControlImplementation + if err := db.Where("id IN ?", inheritedIDs).Find(&inheritedRows).Error; err != nil { + return nil, err + } + byComponentByInherited := make(map[uuid.UUID]uuid.UUID, len(inheritedRows)) + for _, i := range inheritedRows { + byComponentByInherited[*i.ID] = i.ByComponentId + } + + // Full upstream responsibility set per provided-uuid (2 queries inside). + providedUUIDs := uniqueUUIDs(links, func(l relational.SSPLeverageLink) uuid.UUID { return l.ProvidedUUID }) + fullSetByProvided, err := BulkResolveUpstreamResponsibilities(db, providedUUIDs) + if err != nil { + return nil, err + } + + // Satisfied rows per by-component, for the live satisfaction derivation. + byComponentIDs := uniqueUUIDs(inheritedRows, func(i relational.InheritedControlImplementation) uuid.UUID { return i.ByComponentId }) + var satisfiedRows []relational.SatisfiedControlImplementationResponsibility + if len(byComponentIDs) > 0 { + if err := db.Where("by_component_id IN ?", byComponentIDs).Find(&satisfiedRows).Error; err != nil { + return nil, err + } + } + satisfiedByComponent := make(map[uuid.UUID]map[uuid.UUID]bool, len(byComponentIDs)) + for _, s := range satisfiedRows { + if satisfiedByComponent[s.ByComponentId] == nil { + satisfiedByComponent[s.ByComponentId] = make(map[uuid.UUID]bool) + } + satisfiedByComponent[s.ByComponentId][s.ResponsibilityUuid] = true + } + + // Offering titles. + offeringIDs := uniqueUUIDs(links, func(l relational.SSPLeverageLink) uuid.UUID { return l.OfferingID }) + var offerings []relational.SSPExportOffering + if err := db.Select("id, title").Where("id IN ?", offeringIDs).Find(&offerings).Error; err != nil { + return nil, err + } + offeringTitleByID := make(map[uuid.UUID]string, len(offerings)) + for _, o := range offerings { + offeringTitleByID[*o.ID] = o.Title + } + + // Upstream SSP titles (Preload Metadata mirrors collectInheritedSharedResponsibility). + upstreamIDs := uniqueUUIDs(links, func(l relational.SSPLeverageLink) uuid.UUID { return l.UpstreamSSPID }) + var upstreams []relational.SystemSecurityPlan + if err := db.Preload("Metadata").Where("id IN ?", upstreamIDs).Find(&upstreams).Error; err != nil { + return nil, err + } + upstreamTitleByID := make(map[uuid.UUID]string, len(upstreams)) + for _, s := range upstreams { + upstreamTitleByID[*s.ID] = s.Metadata.Title + } + + result := make([]LinkSummary, 0, len(links)) + for _, link := range links { + byComponentID := byComponentByInherited[link.InheritedUUID] + full := fullSetByProvided[link.ProvidedUUID] + satisfaction, outstanding := DeriveSatisfaction(full, satisfiedByComponent[byComponentID]) + + result = append(result, LinkSummary{ + LinkID: *link.ID, + DownstreamSSPID: link.DownstreamSSPID, + UpstreamSSPID: link.UpstreamSSPID, + OfferingID: link.OfferingID, + UpstreamSSPTitle: upstreamTitleByID[link.UpstreamSSPID], + OfferingTitle: offeringTitleByID[link.OfferingID], + OfferingVersion: link.OfferingVersion, + ControlID: link.ControlID, + StatementID: link.StatementID, + Status: link.Status, + Satisfaction: satisfaction, + OutstandingCount: len(outstanding), + TotalResponsibilities: len(full), + }) + } + return result, nil +} + +// ControlKey identifies a (downstream SSP, control-id) pair. ControlID must be +// UPPER-folded and trimmed via NormalizeControlID before use as a key — leverage links +// are matched by control-id alone (no catalog id), the established precedent shared with +// implStatusBySSP and implemented requirements. +type ControlKey struct { + SSPID uuid.UUID + ControlID string +} + +// NormalizeControlID folds a control-id to the canonical key form (trimmed, upper). +func NormalizeControlID(controlID string) string { + return strings.ToUpper(strings.TrimSpace(controlID)) +} + +// Origin is one upstream capability an inherited control draws from, deduped by +// (UpstreamSSPID, OfferingID) within a ControlAggregate. +type Origin struct { + UpstreamSSPID uuid.UUID + UpstreamSSPTitle string + OfferingID uuid.UUID + OfferingTitle string + OfferingVersion int +} + +// ControlAggregate is the per-control rollup of every leverage link sharing a +// ControlKey — the unit the compliance and lineage surfaces read. +type ControlAggregate struct { + Links int + // Credit is the pinned inherited-credit rule at the leverage layer: ≥1 link AND + // every link Status == active AND every link (live-derived) Satisfaction == full. + // The evidence-wins and in-scope conditions are applied by the consumer, not here. + Credit bool + // Status is the worst link status: drifted > revoked > superseded > active. + Status relational.SSPLeverageStatus + // Satisfaction is full iff every link is full, else partial. + Satisfaction relational.SSPLeverageSatisfaction + // OutstandingCount and TotalResponsibilities are summed across links. + OutstandingCount int + TotalResponsibilities int + // InheritedFrom is the deduped set of upstream origins, in first-seen order. + InheritedFrom []Origin +} + +// statusRank orders leverage statuses so the worst (highest rank) wins the display +// status: drifted > revoked > superseded > active. +func statusRank(s relational.SSPLeverageStatus) int { + switch s { + case relational.SSPLeverageStatusDrifted: + return 3 + case relational.SSPLeverageStatusRevoked: + return 2 + case relational.SSPLeverageStatusSuperseded: + return 1 + default: // active + return 0 + } +} + +// AggregateByControl folds link summaries into per-control aggregates. Pure and +// unit-testable: statement-scoped and control-scoped links for the same control-id +// collapse into one ControlKey, statuses take the worst, satisfaction is full only if +// all links are full, counts sum, origins dedupe by (upstream SSP, offering), and +// Credit holds only when every link is active and full. +func AggregateByControl(summaries []LinkSummary) map[ControlKey]ControlAggregate { + type acc struct { + agg ControlAggregate + allActive bool + allFull bool + seenOrig map[[2]uuid.UUID]bool + } + byKey := make(map[ControlKey]*acc) + + for _, s := range summaries { + key := ControlKey{SSPID: s.DownstreamSSPID, ControlID: NormalizeControlID(s.ControlID)} + a := byKey[key] + if a == nil { + a = &acc{ + agg: ControlAggregate{Status: relational.SSPLeverageStatusActive}, + allActive: true, + allFull: true, + seenOrig: make(map[[2]uuid.UUID]bool), + } + byKey[key] = a + } + + a.agg.Links++ + a.agg.OutstandingCount += s.OutstandingCount + a.agg.TotalResponsibilities += s.TotalResponsibilities + + if statusRank(s.Status) > statusRank(a.agg.Status) { + a.agg.Status = s.Status + } + if s.Status != relational.SSPLeverageStatusActive { + a.allActive = false + } + if s.Satisfaction != relational.SSPLeverageSatisfactionFull { + a.allFull = false + } + + origKey := [2]uuid.UUID{s.UpstreamSSPID, s.OfferingID} + if !a.seenOrig[origKey] { + a.seenOrig[origKey] = true + a.agg.InheritedFrom = append(a.agg.InheritedFrom, Origin{ + UpstreamSSPID: s.UpstreamSSPID, + UpstreamSSPTitle: s.UpstreamSSPTitle, + OfferingID: s.OfferingID, + OfferingTitle: s.OfferingTitle, + OfferingVersion: s.OfferingVersion, + }) + } + } + + result := make(map[ControlKey]ControlAggregate, len(byKey)) + for key, a := range byKey { + if a.allFull { + a.agg.Satisfaction = relational.SSPLeverageSatisfactionFull + } else { + a.agg.Satisfaction = relational.SSPLeverageSatisfactionPartial + } + a.agg.Credit = a.agg.Links > 0 && a.allActive && a.allFull + result[key] = a.agg + } + return result +} diff --git a/internal/service/leverage/types.go b/internal/service/leverage/types.go new file mode 100644 index 00000000..f132b3f3 --- /dev/null +++ b/internal/service/leverage/types.go @@ -0,0 +1,70 @@ +// Package leverage is the neutral home for the cross-SSP leveraged-authorization +// projection and aggregation. It was extracted from internal/api/handler/oscal so +// that internal/api/handler (which oscal imports, not the other way round) can read +// the same leverage data without an import cycle — the compliance-progress endpoint +// (package oscal) and the lineage engine (package handler) both build on it. +// +// It may import relational, relational/risks and converters/labelfilter; it must NOT +// import anything under internal/api. +package leverage + +import ( + "github.com/google/uuid" + + "github.com/compliance-framework/api/internal/service/relational" +) + +// Responsibility is the minimal upstream-responsibility shape both the catalog +// exposure and the subscribe/projection paths need: enough to let a downstream +// subscriber pick specific responsibility UUIDs to satisfy, and to compute +// full/partial coverage. Its JSON tags are the wire contract (responsibilityUuid, +// description); oscal aliases upstreamResponsibility to it so those surfaces are +// unchanged. +type Responsibility struct { + ResponsibilityUUID uuid.UUID `json:"responsibilityUuid"` + Description string `json:"description"` +} + +// Projection is one downstream leverage link with everything the read models need +// already resolved: the live-recomputed satisfaction (never the link's cached +// value), the outstanding responsibilities, the evidence-backed posture, the open +// drift risk, the offering title, and the by-component + inherited row the link +// hangs off. +// +// Both the /leveraged-controls endpoint and the shared-responsibility rollup read +// this, so satisfaction is derived in exactly one place and neither surface can +// drift from the other. oscal aliases leveragedControlProjection to it. +type Projection struct { + Link relational.SSPLeverageLink + OfferingTitle string + ByComponentID uuid.UUID + // Inherited is the downstream's own InheritedControlImplementation row this link + // created; nil only if it has since been deleted out from under the link. + Inherited *relational.InheritedControlImplementation + Satisfaction relational.SSPLeverageSatisfaction + Outstanding []Responsibility + // Responsibilities is the FULL upstream responsibility set under this link (uuid + + // description), so downstream surfaces can label every responsibility — including + // ones already satisfied — with the upstream's own text. Outstanding is the subset + // of this with no matching downstream satisfied entry. + Responsibilities []Responsibility + Posture map[uuid.UUID]string + DriftRiskID *uuid.UUID +} + +// uniqueUUIDs extracts the deduplicated set of UUIDs from items, preserving +// first-seen order — used to build IN-clause batches from a list of rows that may +// repeat the same referenced id (e.g. several leverage links pointing at the same +// offering). A copy of the oscal helper of the same name; oscal keeps its own. +func uniqueUUIDs[T any](items []T, extract func(T) uuid.UUID) []uuid.UUID { + seen := make(map[uuid.UUID]bool, len(items)) + result := make([]uuid.UUID, 0, len(items)) + for _, item := range items { + id := extract(item) + if !seen[id] { + seen[id] = true + result = append(result, id) + } + } + return result +} diff --git a/internal/service/relational/evidence.go b/internal/service/relational/evidence.go index 0d90ee3e..d14ca7dd 100644 --- a/internal/service/relational/evidence.go +++ b/internal/service/relational/evidence.go @@ -2,6 +2,7 @@ package relational import ( "errors" + "sort" "strings" "time" @@ -68,6 +69,67 @@ type StatusCount struct { Status string `json:"status"` } +// EvidenceStatusCountsForFilters counts DISTINCT evidence streams grouped by status +// state over the latest evidence in each stream, for the given label filters. It is +// the neutral home of the logic oscal.getStatusCountsForFilters used to own inline — +// hoisted here (its natural home: the StatusCount comment above already notes it +// mirrors profile_compliance.go) so both the compliance endpoint and the leverage +// package can share one definition without an import cycle. Empty filters yield an +// empty (non-nil) slice, never a full-table scan. +func EvidenceStatusCountsForFilters(db *gorm.DB, filters []labelfilter.Filter) ([]StatusCount, error) { + if len(filters) == 0 { + return []StatusCount{}, nil + } + + latestQuery := db.Session(&gorm.Session{}) + latestQuery = GetLatestEvidenceStreamsQuery(latestQuery) + query, err := GetEvidenceSearchByFilterQuery(latestQuery, db, filters...) + if err != nil { + return nil, err + } + + rows := []StatusCount{} + if err := query.Model(&Evidence{}). + Select("count(DISTINCT uuid) as count, status->>'state' as status"). + Group("status->>'state'"). + Scan(&rows).Error; err != nil { + return nil, err + } + + sort.Slice(rows, func(i, j int) bool { + return rows[i].Status < rows[j].Status + }) + + return rows, nil +} + +// CollapseEvidenceStatus reduces a set of evidence status counts to a single control +// posture: not-satisfied wins over satisfied wins over unknown, case/space-folded, +// with zero-count rows ignored. It is the neutral home of the logic +// oscal.computeProfileControlStatus used to own inline — shared with the leverage +// package's per-responsibility posture so both surfaces collapse identically. +func CollapseEvidenceStatus(rows []StatusCount) string { + hasSatisfied := false + for _, row := range rows { + if row.Count <= 0 { + continue + } + + switch strings.ToLower(strings.TrimSpace(row.Status)) { + case "not-satisfied": + return "not-satisfied" + case "satisfied": + hasSatisfied = true + } + } + + if hasSatisfied { + return "satisfied" + } + + return "unknown" +} + // latestEvidenceStreamsCTE builds the "latest evidence per stream" set as a // MATERIALIZED CTE named `latest(id, uuid, state)` using a loose index scan: // distinct stream uuids + a lateral pick of the most-recent row per uuid over the From c92247f7d5bfee776c228b8730250cff18ca2a2b Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:48:25 -0300 Subject: [PATCH 2/3] self-review: rename lineageEngine.leverage -> leverageByControl (no shadowing) --- internal/api/handler/lineage.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/api/handler/lineage.go b/internal/api/handler/lineage.go index b45d142b..f5777c97 100644 --- a/internal/api/handler/lineage.go +++ b/internal/api/handler/lineage.go @@ -494,7 +494,7 @@ type lineageEngine struct { // Loaded once per engine build: single-SSP scope keys only the selected SSP; // global scope keys every downstream SSP. Keyed by control-id only, the same // precedent as implStatusBySSP. - leverage map[leverage.ControlKey]leverage.ControlAggregate + leverageByControl map[leverage.ControlKey]leverage.ControlAggregate // Global multi-SSP scope (loaded only when sspID is nil): the full SSP list, each // SSP's title, and each SSP's resolved profile controls (scopeKey set), for the @@ -551,7 +551,7 @@ func (h *LineageHandler) buildEngine(sspID, componentID *uuid.UUID) (*lineageEng profileControlsBySSP: map[uuid.UUID]map[string]struct{}{}, closureCache: map[relational.ControlRef][]relational.ControlRef{}, assessCache: map[assessKey]sspAssessment{}, - leverage: map[leverage.ControlKey]leverage.ControlAggregate{}, + leverageByControl: map[leverage.ControlKey]leverage.ControlAggregate{}, } if err := e.loadCatalogs(h.db); err != nil { @@ -998,7 +998,7 @@ func (e *lineageEngine) loadLeverage(db *gorm.DB) error { if err != nil { return err } - e.leverage = leverage.AggregateByControl(summaries) + e.leverageByControl = leverage.AggregateByControl(summaries) return nil } @@ -1006,7 +1006,7 @@ func (e *lineageEngine) loadLeverage(db *gorm.DB) error { // UPPER-folded control-id only (no catalog id — the established leverage precedent). // ok is false when the SSP holds no leverage link for that control-id. func (e *lineageEngine) leverageAgg(ref relational.ControlRef, sspID uuid.UUID) (leverage.ControlAggregate, bool) { - agg, ok := e.leverage[leverage.ControlKey{SSPID: sspID, ControlID: leverage.NormalizeControlID(ref.ControlID)}] + agg, ok := e.leverageByControl[leverage.ControlKey{SSPID: sspID, ControlID: leverage.NormalizeControlID(ref.ControlID)}] return agg, ok } From d48dcb9f79e9092fcd8edce1b311dffe5576b698 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:18:58 -0300 Subject: [PATCH 3/3] fix: address review feedback (drawer engine cost, vacuous-credit gate) --- internal/api/handler/lineage_leverage.go | 75 ++++++++++++---------- internal/service/leverage/leverage_test.go | 18 ++++++ internal/service/leverage/summary.go | 10 ++- 3 files changed, 67 insertions(+), 36 deletions(-) diff --git a/internal/api/handler/lineage_leverage.go b/internal/api/handler/lineage_leverage.go index 9c25d5b7..1d10b665 100644 --- a/internal/api/handler/lineage_leverage.go +++ b/internal/api/handler/lineage_leverage.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/labstack/echo/v4" + "gorm.io/gorm" "github.com/compliance-framework/api/internal/api" svc "github.com/compliance-framework/api/internal/service" @@ -93,19 +94,21 @@ func (h *LineageHandler) LeverageDetail(ctx echo.Context) error { } ref := relational.ControlRef{CatalogID: catalogID, ControlID: subID} - // Build a global engine only to resolve downstream SSP titles and validate the - // control exists (its catalog type is registered). Rejected alternative: having the - // UI call per-SSP /oscal/system-security-plans/:id/leveraged-controls would be N - // requests and would require ssp:read on each SSP — the read guard here is the same - // as /ssps. - engine, err := h.buildEngine(nil, nil) - if err != nil { - h.sugar.Errorw("failed to build lineage engine", "error", err) + // Existence 404: the control must be a real catalog control. A targeted lookup — NOT + // a full lineage engine build, which would load the entire latest-evidence corpus + // (loadCompliance -> LoadLatestEvidenceStreams) on every drawer open just for two + // fields. Rejected alternative: having the UI call per-SSP + // /oscal/system-security-plans/:id/leveraged-controls would be N requests and would + // require ssp:read on each SSP — the read guard here is the same as /ssps. + if err := h.db.Select("id"). + Where("catalog_id = ? AND id = ?", ref.CatalogID, ref.ControlID). + First(&relational.Control{}).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(errors.New("control not found"))) + } + h.sugar.Errorw("failed to look up control", "error", err) return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } - if _, ok := engine.controlCatalogType[ref]; !ok { - return ctx.JSON(http.StatusNotFound, api.NewError(errors.New("control not found"))) - } // Leverage matches by control-id only (no catalog id), the established precedent. projectionsBySSP, err := leverage.ProjectForControl(h.db, subID) @@ -114,10 +117,11 @@ func (h *LineageHandler) LeverageDetail(ctx echo.Context) error { return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } - // Upstream SSP titles: one query, mirroring collectInheritedSharedResponsibility. - upstreamTitleByID, err := h.upstreamSSPTitles(projectionsBySSP, sspFilter) + // Downstream + upstream SSP titles in one Preload query, keyed on the ids + // ProjectForControl already surfaces (mirrors collectInheritedSharedResponsibility). + sspTitleByID, err := h.leverageSSPTitles(projectionsBySSP, sspFilter) if err != nil { - h.sugar.Errorw("failed to resolve upstream SSP titles", "error", err) + h.sugar.Errorw("failed to resolve SSP titles", "error", err) return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } @@ -139,7 +143,7 @@ func (h *LineageHandler) LeverageDetail(ctx echo.Context) error { StatementID: p.Link.StatementID, InheritedFrom: LineageLeverageInheritedFrom{ UpstreamSSPID: p.Link.UpstreamSSPID, - UpstreamSSPTitle: upstreamTitleByID[p.Link.UpstreamSSPID], + UpstreamSSPTitle: sspTitleByID[p.Link.UpstreamSSPID], OfferingID: p.Link.OfferingID, OfferingTitle: p.OfferingTitle, OfferingVersion: p.Link.OfferingVersion, @@ -156,7 +160,7 @@ func (h *LineageHandler) LeverageDetail(ctx echo.Context) error { } rows = append(rows, LineageLeverageRow{ SSPID: sspID.String(), - SSPTitle: engine.sspTitles[sspID], + SSPTitle: sspTitleByID[sspID], Links: links, }) } @@ -175,38 +179,43 @@ func (h *LineageHandler) LeverageDetail(ctx echo.Context) error { return ctx.JSON(http.StatusOK, svc.NewListResponse(rows, int64(len(rows)), 1, limit)) } -// upstreamSSPTitles resolves the Metadata.Title of every distinct upstream SSP across -// the projections (optionally narrowed to one downstream SSP), in a single Preload -// query — mirrors collectInheritedSharedResponsibility's upstream-title resolution. -func (h *LineageHandler) upstreamSSPTitles(projectionsBySSP map[uuid.UUID][]leverage.Projection, sspFilter *uuid.UUID) (map[uuid.UUID]string, error) { +// leverageSSPTitles resolves the Metadata.Title of every SSP the drawer names — the +// downstream SSPs (the projection keys) and their upstream SSPs (each link's +// UpstreamSSPID) — in a single Preload query, optionally narrowed to one downstream +// SSP. Mirrors collectInheritedSharedResponsibility's title resolution. +func (h *LineageHandler) leverageSSPTitles(projectionsBySSP map[uuid.UUID][]leverage.Projection, sspFilter *uuid.UUID) (map[uuid.UUID]string, error) { seen := map[uuid.UUID]struct{}{} - upstreamIDs := make([]uuid.UUID, 0) + ids := make([]uuid.UUID, 0) + add := func(id uuid.UUID) { + if _, dup := seen[id]; dup { + return + } + seen[id] = struct{}{} + ids = append(ids, id) + } for sspID, projections := range projectionsBySSP { if sspFilter != nil && sspID != *sspFilter { continue } + add(sspID) // downstream for _, p := range projections { - if _, dup := seen[p.Link.UpstreamSSPID]; dup { - continue - } - seen[p.Link.UpstreamSSPID] = struct{}{} - upstreamIDs = append(upstreamIDs, p.Link.UpstreamSSPID) + add(p.Link.UpstreamSSPID) // upstream } } - titles := make(map[uuid.UUID]string, len(upstreamIDs)) - if len(upstreamIDs) == 0 { + titles := make(map[uuid.UUID]string, len(ids)) + if len(ids) == 0 { return titles, nil } - var upstreams []relational.SystemSecurityPlan - if err := h.db.Preload("Metadata").Where("id IN ?", upstreamIDs).Find(&upstreams).Error; err != nil { + var ssps []relational.SystemSecurityPlan + if err := h.db.Preload("Metadata").Where("id IN ?", ids).Find(&ssps).Error; err != nil { return nil, err } - for i := range upstreams { - if upstreams[i].ID == nil { + for i := range ssps { + if ssps[i].ID == nil { continue } - titles[*upstreams[i].ID] = upstreams[i].Metadata.Title + titles[*ssps[i].ID] = ssps[i].Metadata.Title } return titles, nil } diff --git a/internal/service/leverage/leverage_test.go b/internal/service/leverage/leverage_test.go index 545fed6a..b6d874bc 100644 --- a/internal/service/leverage/leverage_test.go +++ b/internal/service/leverage/leverage_test.go @@ -125,6 +125,24 @@ func TestAggregateByControl_NoCreditOnPartialOrNonActive(t *testing.T) { } } +func TestAggregateByControl_ZeroResponsibilitiesNoCredit(t *testing.T) { + ssp := uuid.New() + up := uuid.New() + off := uuid.New() + // A dangling / empty link: active + vacuously-full but zero responsibilities must + // NOT earn credit (it would otherwise inflate compliance before drift runs). + agg := AggregateByControl([]LinkSummary{ + summary(ssp, up, off, "AC-2", nil, relational.SSPLeverageStatusActive, relational.SSPLeverageSatisfactionFull, 0, 0), + }) + got := agg[ControlKey{SSPID: ssp, ControlID: "AC-2"}] + if got.Credit { + t.Error("a zero-responsibility link must not earn credit") + } + if got.TotalResponsibilities != 0 { + t.Errorf("expected 0 total responsibilities, got %d", got.TotalResponsibilities) + } +} + func TestAggregateByControl_WorstStatusPrecedence(t *testing.T) { ssp := uuid.New() up := uuid.New() diff --git a/internal/service/leverage/summary.go b/internal/service/leverage/summary.go index 1f9edbac..7d0e470a 100644 --- a/internal/service/leverage/summary.go +++ b/internal/service/leverage/summary.go @@ -165,8 +165,12 @@ type Origin struct { // ControlKey — the unit the compliance and lineage surfaces read. type ControlAggregate struct { Links int - // Credit is the pinned inherited-credit rule at the leverage layer: ≥1 link AND - // every link Status == active AND every link (live-derived) Satisfaction == full. + // Credit is the inherited-credit rule at the leverage layer: ≥1 link AND every link + // Status == active AND every link (live-derived) Satisfaction == full AND + // TotalResponsibilities > 0. The last clause denies credit to a link whose upstream + // responsibilities resolve to empty — a dangling link (upstream Provided/responsibility + // rows deleted, so BulkResolve returns []) or a genuinely zero-responsibility offering + // — which would otherwise silently inflate compliance before drift detection runs. // The evidence-wins and in-scope conditions are applied by the consumer, not here. Credit bool // Status is the worst link status: drifted > revoked > superseded > active. @@ -256,7 +260,7 @@ func AggregateByControl(summaries []LinkSummary) map[ControlKey]ControlAggregate } else { a.agg.Satisfaction = relational.SSPLeverageSatisfactionPartial } - a.agg.Credit = a.agg.Links > 0 && a.allActive && a.allFull + a.agg.Credit = a.agg.Links > 0 && a.allActive && a.allFull && a.agg.TotalResponsibilities > 0 result[key] = a.agg } return result