From 2d5158883212c4cb54756ed4045f21a8cc73dc77 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:03:37 -0300 Subject: [PATCH 01/13] implement: statement-anchored shared responsibility --- docs/docs.go | 1939 +++++++++++++++-- docs/swagger.json | 1939 +++++++++++++++-- docs/swagger.yaml | 1621 ++++++++++++-- internal/api/handler/oscal/import.go | 51 +- .../handler/oscal/import_integration_test.go | 199 ++ .../api/handler/oscal/import_unit_test.go | 161 ++ .../api/handler/oscal/ssp_by_components.go | 1001 +++++++++ .../api/handler/oscal/ssp_export_offerings.go | 316 ++- .../oscal/ssp_export_offerings_test.go | 85 +- internal/api/handler/oscal/ssp_leverage.go | 337 ++- ...ssp_leverage_allowlist_integration_test.go | 10 +- .../ssp_leverage_drift_integration_test.go | 19 +- .../api/handler/oscal/ssp_leverage_test.go | 105 +- .../handler/oscal/ssp_leverage_unit_test.go | 57 +- .../oscal/ssp_shared_responsibility.go | 414 ++++ .../oscal/ssp_shared_responsibility_test.go | 1032 +++++++++ .../handler/oscal/system_security_plans.go | 388 ++-- internal/service/migrator.go | 126 ++ internal/service/migrator_test.go | 91 + .../relational/system_security_plan.go | 10 +- 20 files changed, 9115 insertions(+), 786 deletions(-) create mode 100644 internal/api/handler/oscal/import_integration_test.go create mode 100644 internal/api/handler/oscal/import_unit_test.go create mode 100644 internal/api/handler/oscal/ssp_by_components.go create mode 100644 internal/api/handler/oscal/ssp_shared_responsibility.go create mode 100644 internal/api/handler/oscal/ssp_shared_responsibility_test.go diff --git a/docs/docs.go b/docs/docs.go index 9b95a508..ff17c4a4 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -16217,6 +16217,58 @@ const docTemplate = `{ ] } }, + "/oscal/ssp-export-offerings/by-control/{controlId}": { + "get": { + "description": "Cross-SSP catalog of what is exported for a control, by whom, and against\nwhich statement — every published offering item whose control-id matches\n(case-insensitively), with the offering, upstream SSP, component, provided\ncapability and responsibility set all resolved server-side. Honours the same\ntrust boundary as the flat catalog: gated by ssp-export-offering:read only,\nnever ssp:read on the upstream SSP. Pass downstreamSspId to narrow the result\nto offerings that SSP is actually allow-listed to subscribe to (BCH-1342).", + "produces": [ + "application/json" + ], + "tags": [ + "SSP Export Offerings" + ], + "summary": "List every published export offering for one control", + "parameters": [ + { + "type": "string", + "description": "Control ID (e.g. AC-2)", + "name": "controlId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Only return offerings this downstream SSP may subscribe to", + "name": "downstreamSspId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-oscal_ControlExportOffer" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/oscal/ssp-export-offerings/{id}": { "get": { "description": "Retrieves a single published export offering by its own ID (no parent SSP scoping). Draft/deprecated/revoked offerings are only visible via the SSP-nested curation routes.", @@ -16271,7 +16323,7 @@ const docTemplate = `{ }, "/oscal/ssp-export-offerings/{id}/subscribe": { "post": { - "description": "Records, on the downstream SSP named in the request body, an OSCAL\ninherited-control-implementation and (optionally) satisfied-responsibility\nentries per chosen offering item, plus one leveraged-authorization for the\nwhole request — all in a single atomic write. Never checks ssp:read on the\nupstream SSP: the trust boundary is that subscribing to a published offering\nonly requires ssp-export-offering:subscribe on the offering and ssp:update on\nthe downstream SSP.", + "description": "Records, on the downstream SSP named in the request body, an OSCAL\ninherited-control-implementation and (optionally) satisfied-responsibility\nentries per chosen offering item, plus one leveraged-authorization for the\nwhole request — all in a single atomic write. Never checks ssp:read on the\nupstream SSP: the trust boundary is that subscribing to a published offering\nonly requires ssp-export-offering:subscribe on the offering and ssp:update on\nthe downstream SSP.\n\nEvery subscribed item must be statement-anchored: a legacy offering item with\nno statement-id is rejected with 422. The materialized downstream tree is\nalways requirement -\u003e statement -\u003e by-component -\u003e inherited + satisfied, and is\nreported back in meta.created (each row flagged created:true when inserted,\nfalse when an existing row was reused) so the caller can render newly-created\nrequirements without re-walking the SSP. The data payload is unchanged.", "consumes": [ "application/json" ], @@ -16331,6 +16383,12 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -17366,8 +17424,73 @@ const docTemplate = `{ } }, "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}": { + "get": { + "description": "Deprecated: requirement-anchored by-components are legacy — read-only here so\nthe UI can display and wind them down. New by-components must be created\nagainst a statement.\n\nReturns the by-component with its export (including provided and\nresponsibilities), inherited and satisfied entries, responsible-roles and\nimplementation-status.", + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Get a by-component within an implemented requirement", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ByComponent" + } + }, + "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": [] + } + ] + }, "put": { - "description": "Updates an existing by-component that belongs to an implemented requirement for a given SSP.", + "description": "Deprecated: requirement-anchored by-components are legacy — the statement is\nthe canonical anchor for shared responsibility. Use\nPUT .../statements/{stmtId}/by-components/{byComponentId} instead. This route\nremains so existing requirement-anchored rows can be edited and wound down;\nthere is no requirement-level POST.\n\nUpdates metadata only — description, props, links, set-parameters, remarks,\nimplementation-status and responsible-roles. Any export, inherited or\nsatisfied entries in the body are IGNORED (they have their own sub-resource\nroutes); component-uuid is immutable. Previously this blind-Saved a struct\nrebuilt from the request body, which zeroed every omitted field and upserted\nnested associations with no cascade cleanup.", "consumes": [ "application/json" ], @@ -17378,6 +17501,7 @@ const docTemplate = `{ "System Security Plans" ], "summary": "Update a by-component within an implemented requirement", + "deprecated": true, "parameters": [ { "type": "string", @@ -17435,12 +17559,76 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } } - } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "delete": { + "description": "Deprecated: requirement-anchored by-components are legacy. This delete exists\npurely so legacy and orphaned requirement-anchored rows can be wound down —\nthere is deliberately no requirement-level POST to create new ones.\n\nCascades exactly like the statement-level delete: the by-component's own\nresponsible-roles, its inherited and satisfied entries (each with their\nresponsible-roles), and its export with nested provided/responsibilities are all\nremoved, along with the responsible_role_parties join rows.", + "tags": [ + "System Security Plans" + ], + "summary": "Delete a by-component within an implemented requirement", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "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": [] + } + ] } }, "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export": { "get": { - "description": "Retrieves the Export (with nested Provided and Responsibilities) for a by-component within an implemented requirement.", + "description": "Retrieves the Export (with nested Provided and Responsibilities) for a by-component within an implemented requirement.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", "produces": [ "application/json" ], @@ -17448,6 +17636,7 @@ const docTemplate = `{ "System Security Plans" ], "summary": "Get the export for a control-level by-component", + "deprecated": true, "parameters": [ { "type": "string", @@ -17504,7 +17693,7 @@ const docTemplate = `{ ] }, "put": { - "description": "Updates the scalar fields of an existing Export for a by-component within an implemented requirement. Provided and Responsibilities entries are managed via their own routes.", + "description": "Updates the scalar fields of an existing Export for a by-component within an implemented requirement. Provided and Responsibilities entries are managed via their own routes.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", "consumes": [ "application/json" ], @@ -17515,6 +17704,7 @@ const docTemplate = `{ "System Security Plans" ], "summary": "Update the export for a control-level by-component", + "deprecated": true, "parameters": [ { "type": "string", @@ -17580,7 +17770,7 @@ const docTemplate = `{ ] }, "post": { - "description": "Creates the Export for a by-component within an implemented requirement. A by-component may have at most one Export.", + "description": "Creates the Export for a by-component within an implemented requirement. A by-component may have at most one Export.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", "consumes": [ "application/json" ], @@ -17591,6 +17781,7 @@ const docTemplate = `{ "System Security Plans" ], "summary": "Create the export for a control-level by-component", + "deprecated": true, "parameters": [ { "type": "string", @@ -17662,11 +17853,12 @@ const docTemplate = `{ ] }, "delete": { - "description": "Deletes the Export (and its Provided/Responsibilities entries) for a by-component within an implemented requirement.", + "description": "Deletes the Export (and its Provided/Responsibilities entries) for a by-component within an implemented requirement.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", "tags": [ "System Security Plans" ], "summary": "Delete the export for a control-level by-component", + "deprecated": true, "parameters": [ { "type": "string", @@ -17721,8 +17913,73 @@ const docTemplate = `{ } }, "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/provided": { + "get": { + "description": "Deprecated: use the statement-level equivalent. Requirement-anchored exports are legacy.", + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "List the provided entries on a control-level by-component's export", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_ProvidedControlImplementation" + } + }, + "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": [] + } + ] + }, "post": { - "description": "Creates a ProvidedControlImplementation entry under the Export of a by-component within an implemented requirement.", + "description": "Creates a ProvidedControlImplementation entry under the Export of a by-component within an implemented requirement.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", "consumes": [ "application/json" ], @@ -17733,6 +17990,7 @@ const docTemplate = `{ "System Security Plans" ], "summary": "Create a provided entry on a control-level by-component's export", + "deprecated": true, "parameters": [ { "type": "string", @@ -17800,7 +18058,7 @@ const docTemplate = `{ }, "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/provided/{providedId}": { "put": { - "description": "Replaces an existing ProvidedControlImplementation entry under the Export of a by-component within an implemented requirement.", + "description": "Replaces an existing ProvidedControlImplementation entry under the Export of a by-component within an implemented requirement.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", "consumes": [ "application/json" ], @@ -17811,6 +18069,7 @@ const docTemplate = `{ "System Security Plans" ], "summary": "Update a provided entry on a control-level by-component's export", + "deprecated": true, "parameters": [ { "type": "string", @@ -17883,11 +18142,12 @@ const docTemplate = `{ ] }, "delete": { - "description": "Deletes an existing ProvidedControlImplementation entry under the Export of a by-component within an implemented requirement.", + "description": "Deletes an existing ProvidedControlImplementation entry under the Export of a by-component within an implemented requirement.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", "tags": [ "System Security Plans" ], "summary": "Delete a provided entry on a control-level by-component's export", + "deprecated": true, "parameters": [ { "type": "string", @@ -17949,18 +18209,16 @@ const docTemplate = `{ } }, "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/responsibilities": { - "post": { - "description": "Creates a ControlImplementationResponsibility entry under the Export of a by-component within an implemented requirement.", - "consumes": [ - "application/json" - ], + "get": { + "description": "Deprecated: use the statement-level equivalent. Requirement-anchored exports are legacy.", "produces": [ "application/json" ], "tags": [ "System Security Plans" ], - "summary": "Create a responsibility entry on a control-level by-component's export", + "summary": "List the responsibility entries on a control-level by-component's export", + "deprecated": true, "parameters": [ { "type": "string", @@ -17982,21 +18240,89 @@ const docTemplate = `{ "name": "byComponentId", "in": "path", "required": true - }, - { - "description": "Responsibility data", - "name": "responsibility", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementationResponsibility" - } } ], "responses": { - "201": { - "description": "Created", - "schema": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_ControlImplementationResponsibility" + } + }, + "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": [] + } + ] + }, + "post": { + "description": "Creates a ControlImplementationResponsibility entry under the Export of a by-component within an implemented requirement.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Create a responsibility entry on a control-level by-component's export", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + }, + { + "description": "Responsibility data", + "name": "responsibility", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementationResponsibility" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ControlImplementationResponsibility" } }, @@ -18028,7 +18354,7 @@ const docTemplate = `{ }, "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/responsibilities/{responsibilityId}": { "put": { - "description": "Replaces an existing ControlImplementationResponsibility entry under the Export of a by-component within an implemented requirement.", + "description": "Replaces an existing ControlImplementationResponsibility entry under the Export of a by-component within an implemented requirement.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", "consumes": [ "application/json" ], @@ -18039,6 +18365,7 @@ const docTemplate = `{ "System Security Plans" ], "summary": "Update a responsibility entry on a control-level by-component's export", + "deprecated": true, "parameters": [ { "type": "string", @@ -18111,11 +18438,12 @@ const docTemplate = `{ ] }, "delete": { - "description": "Deletes an existing ControlImplementationResponsibility entry under the Export of a by-component within an implemented requirement.", + "description": "Deletes an existing ControlImplementationResponsibility entry under the Export of a by-component within an implemented requirement.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", "tags": [ "System Security Plans" ], "summary": "Delete a responsibility entry on a control-level by-component's export", + "deprecated": true, "parameters": [ { "type": "string", @@ -18448,6 +18776,70 @@ const docTemplate = `{ } }, "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components": { + "get": { + "description": "Returns every by-component attached to the statement, each with its export\n(including provided and responsibilities), inherited and satisfied entries,\nresponsible-roles and implementation-status.", + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "List the by-components on a statement", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_ByComponent" + } + }, + "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": [] + } + ] + }, "post": { "description": "Create a by-component within an existing statement within an implemented requirement for a given SSP.", "consumes": [ @@ -18521,8 +18913,79 @@ const docTemplate = `{ } }, "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}": { + "get": { + "description": "Returns the by-component with its export (including provided and\nresponsibilities), inherited and satisfied entries, responsible-roles and\nimplementation-status — everything needed to refetch one by-component after\nediting any of its sub-resources.", + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Get a by-component within a statement", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ByComponent" + } + }, + "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": [] + } + ] + }, "put": { - "description": "Updates a by-component within an existing statement within an implemented requirement for a given SSP.", + "description": "Updates metadata only — description, props, links, set-parameters, remarks,\nimplementation-status and responsible-roles. Any export, inherited or\nsatisfied entries in the body are IGNORED: those subtrees are managed through\ntheir own sub-resource routes, which enforce the leverage bookkeeping this\nroute cannot. component-uuid is immutable. Previously this blind-Saved a\nstruct rebuilt from the request body, which zeroed every omitted field and\nupserted nested associations with no cascade cleanup.", "consumes": [ "application/json" ], @@ -18597,7 +19060,12 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } } - } + }, + "security": [ + { + "OAuth2Password": [] + } + ] }, "delete": { "description": "Deletes a by-component within an existing statement within an implemented requirement for a given SSP.", @@ -18823,19 +19291,812 @@ const docTemplate = `{ "OAuth2Password": [] } ] - }, - "post": { - "description": "Creates the Export for a by-component within a statement. A by-component may have at most one Export.", - "consumes": [ - "application/json" - ], + }, + "post": { + "description": "Creates the Export for a by-component within a statement. A by-component may have at most one Export.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Create the export for a statement-level by-component", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + }, + { + "description": "Export data", + "name": "export", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.Export" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_Export" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "delete": { + "description": "Deletes the Export (and its Provided/Responsibilities entries) for a by-component within a statement.", + "tags": [ + "System Security Plans" + ], + "summary": "Delete the export for a statement-level by-component", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "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": [] + } + ] + } + }, + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/provided": { + "get": { + "description": "Retrieves every ProvidedControlImplementation under the Export of a by-component within a statement.", + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "List the provided entries on a statement-level by-component's export", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_ProvidedControlImplementation" + } + }, + "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": [] + } + ] + }, + "post": { + "description": "Creates a ProvidedControlImplementation entry under the Export of a by-component within a statement.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Create a provided entry on a statement-level by-component's export", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + }, + { + "description": "Provided data", + "name": "provided", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.ProvidedControlImplementation" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ProvidedControlImplementation" + } + }, + "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": [] + } + ] + } + }, + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/provided/{providedId}": { + "put": { + "description": "Replaces an existing ProvidedControlImplementation entry under the Export of a by-component within a statement.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Update a provided entry on a statement-level by-component's export", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Provided entry ID", + "name": "providedId", + "in": "path", + "required": true + }, + { + "description": "Provided data", + "name": "provided", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.ProvidedControlImplementation" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ProvidedControlImplementation" + } + }, + "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": [] + } + ] + }, + "delete": { + "description": "Deletes an existing ProvidedControlImplementation entry under the Export of a by-component within a statement.", + "tags": [ + "System Security Plans" + ], + "summary": "Delete a provided entry on a statement-level by-component's export", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Provided entry ID", + "name": "providedId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "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": [] + } + ] + } + }, + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/responsibilities": { + "get": { + "description": "Retrieves every ControlImplementationResponsibility under the Export of a by-component within a statement.", + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "List the responsibility entries on a statement-level by-component's export", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_ControlImplementationResponsibility" + } + }, + "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": [] + } + ] + }, + "post": { + "description": "Creates a ControlImplementationResponsibility entry under the Export of a by-component within a statement.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Create a responsibility entry on a statement-level by-component's export", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + }, + { + "description": "Responsibility data", + "name": "responsibility", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementationResponsibility" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ControlImplementationResponsibility" + } + }, + "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": [] + } + ] + } + }, + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/responsibilities/{responsibilityId}": { + "put": { + "description": "Replaces an existing ControlImplementationResponsibility entry under the Export of a by-component within a statement.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Update a responsibility entry on a statement-level by-component's export", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Responsibility entry ID", + "name": "responsibilityId", + "in": "path", + "required": true + }, + { + "description": "Responsibility data", + "name": "responsibility", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementationResponsibility" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ControlImplementationResponsibility" + } + }, + "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": [] + } + ] + }, + "delete": { + "description": "Deletes an existing ControlImplementationResponsibility entry under the Export of a by-component within a statement.", + "tags": [ + "System Security Plans" + ], + "summary": "Delete a responsibility entry on a statement-level by-component's export", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Responsibility entry ID", + "name": "responsibilityId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "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": [] + } + ] + } + }, + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/inherited": { + "get": { + "description": "Retrieves what this system consumes from an upstream under this statement.", "produces": [ "application/json" ], "tags": [ "System Security Plans" ], - "summary": "Create the export for a statement-level by-component", + "summary": "List the inherited control implementations on a statement-level by-component", "parameters": [ { "type": "string", @@ -18864,22 +20125,13 @@ const docTemplate = `{ "name": "byComponentId", "in": "path", "required": true - }, - { - "description": "Export data", - "name": "export", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/oscalTypes_1_1_3.Export" - } } ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_Export" + "$ref": "#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_InheritedControlImplementation" } }, "400": { @@ -18894,12 +20146,6 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, - "409": { - "description": "Conflict", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -18913,12 +20159,18 @@ const docTemplate = `{ } ] }, - "delete": { - "description": "Deletes the Export (and its Provided/Responsibilities entries) for a by-component within a statement.", + "post": { + "description": "Records that this system consumes an upstream's provided capability under this\nstatement. Hand-authored entries carry no leverage link; entries created by\nSubscribe do, and are protected from deletion (409) while that subscription lives.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "tags": [ "System Security Plans" ], - "summary": "Delete the export for a statement-level by-component", + "summary": "Create an inherited control implementation on a statement-level by-component", "parameters": [ { "type": "string", @@ -18947,11 +20199,23 @@ const docTemplate = `{ "name": "byComponentId", "in": "path", "required": true + }, + { + "description": "Inherited data", + "name": "inherited", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.InheritedControlImplementation" + } } ], "responses": { - "204": { - "description": "No Content" + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_InheritedControlImplementation" + } }, "400": { "description": "Bad Request", @@ -18979,9 +20243,9 @@ const docTemplate = `{ ] } }, - "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/provided": { - "post": { - "description": "Creates a ProvidedControlImplementation entry under the Export of a by-component within a statement.", + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/inherited/{inheritedId}": { + "put": { + "description": "Metadata only — description, props, links and responsible-roles. provided-uuid\nis immutable and a body attempting to change it is rejected with 400: it is the\nidentity the leverage link and the drift detector join on.", "consumes": [ "application/json" ], @@ -18991,7 +20255,7 @@ const docTemplate = `{ "tags": [ "System Security Plans" ], - "summary": "Create a provided entry on a statement-level by-component's export", + "summary": "Update an inherited control implementation on a statement-level by-component", "parameters": [ { "type": "string", @@ -19022,20 +20286,27 @@ const docTemplate = `{ "required": true }, { - "description": "Provided data", - "name": "provided", + "type": "string", + "description": "Inherited ID", + "name": "inheritedId", + "in": "path", + "required": true + }, + { + "description": "Inherited data", + "name": "inherited", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/oscalTypes_1_1_3.ProvidedControlImplementation" + "$ref": "#/definitions/oscalTypes_1_1_3.InheritedControlImplementation" } } ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ProvidedControlImplementation" + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_InheritedControlImplementation" } }, "400": { @@ -19062,21 +20333,13 @@ const docTemplate = `{ "OAuth2Password": [] } ] - } - }, - "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/provided/{providedId}": { - "put": { - "description": "Replaces an existing ProvidedControlImplementation entry under the Export of a by-component within a statement.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], + }, + "delete": { + "description": "Hand-authored entries delete freely. An entry created by a subscription is owned\nby that subscription — an SSPLeverageLink still references it, and both drift\ndetection and notifications read through that reference — so deleting it returns\n409; unsubscribe instead.", "tags": [ "System Security Plans" ], - "summary": "Update a provided entry on a statement-level by-component's export", + "summary": "Delete an inherited control implementation on a statement-level by-component", "parameters": [ { "type": "string", @@ -19108,27 +20371,15 @@ const docTemplate = `{ }, { "type": "string", - "description": "Provided entry ID", - "name": "providedId", + "description": "Inherited ID", + "name": "inheritedId", "in": "path", "required": true - }, - { - "description": "Provided data", - "name": "provided", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/oscalTypes_1_1_3.ProvidedControlImplementation" - } } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ProvidedControlImplementation" - } + "204": { + "description": "No Content" }, "400": { "description": "Bad Request", @@ -19142,6 +20393,12 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -19154,13 +20411,18 @@ const docTemplate = `{ "OAuth2Password": [] } ] - }, - "delete": { - "description": "Deletes an existing ProvidedControlImplementation entry under the Export of a by-component within a statement.", + } + }, + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/satisfied": { + "get": { + "description": "Retrieves how this system discharges its upstream's responsibilities under this statement.", + "produces": [ + "application/json" + ], "tags": [ "System Security Plans" ], - "summary": "Delete a provided entry on a statement-level by-component's export", + "summary": "List the satisfied responsibilities on a statement-level by-component", "parameters": [ { "type": "string", @@ -19189,18 +20451,14 @@ const docTemplate = `{ "name": "byComponentId", "in": "path", "required": true - }, - { - "type": "string", - "description": "Provided entry ID", - "name": "providedId", - "in": "path", - "required": true } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_SatisfiedControlImplementationResponsibility" + } }, "400": { "description": "Bad Request", @@ -19226,11 +20484,9 @@ const docTemplate = `{ "OAuth2Password": [] } ] - } - }, - "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/responsibilities": { + }, "post": { - "description": "Creates a ControlImplementationResponsibility entry under the Export of a by-component within a statement.", + "description": "Records that this system discharges one of its upstream's responsibilities. The\nresponsibility-uuid must resolve to a ControlImplementationResponsibility on an\nExport this by-component actually inherits from (400 otherwise). If the owning\nSSPLeverageLink exists, its cached Satisfaction is re-derived in the same\ntransaction, so partial can flip to full atomically with the write that causes it.", "consumes": [ "application/json" ], @@ -19240,7 +20496,7 @@ const docTemplate = `{ "tags": [ "System Security Plans" ], - "summary": "Create a responsibility entry on a statement-level by-component's export", + "summary": "Create a satisfied responsibility on a statement-level by-component", "parameters": [ { "type": "string", @@ -19271,12 +20527,12 @@ const docTemplate = `{ "required": true }, { - "description": "Responsibility data", - "name": "responsibility", + "description": "Satisfied data", + "name": "satisfied", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementationResponsibility" + "$ref": "#/definitions/oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility" } } ], @@ -19284,7 +20540,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ControlImplementationResponsibility" + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_SatisfiedControlImplementationResponsibility" } }, "400": { @@ -19313,9 +20569,9 @@ const docTemplate = `{ ] } }, - "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/responsibilities/{responsibilityId}": { + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/satisfied/{satisfiedId}": { "put": { - "description": "Replaces an existing ControlImplementationResponsibility entry under the Export of a by-component within a statement.", + "description": "Metadata only — description, remarks, props, links and responsible-roles.\nresponsibility-uuid is immutable and a body attempting to change it is rejected\nwith 400: it is what deriveSatisfaction and the drift detector match on. Because\nthe identity can't change, the owning link's Satisfaction cannot change either,\nso no re-derivation is needed here.", "consumes": [ "application/json" ], @@ -19325,7 +20581,7 @@ const docTemplate = `{ "tags": [ "System Security Plans" ], - "summary": "Update a responsibility entry on a statement-level by-component's export", + "summary": "Update a satisfied responsibility on a statement-level by-component", "parameters": [ { "type": "string", @@ -19357,18 +20613,18 @@ const docTemplate = `{ }, { "type": "string", - "description": "Responsibility entry ID", - "name": "responsibilityId", + "description": "Satisfied ID", + "name": "satisfiedId", "in": "path", "required": true }, { - "description": "Responsibility data", - "name": "responsibility", + "description": "Satisfied data", + "name": "satisfied", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementationResponsibility" + "$ref": "#/definitions/oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility" } } ], @@ -19376,7 +20632,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ControlImplementationResponsibility" + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_SatisfiedControlImplementationResponsibility" } }, "400": { @@ -19405,11 +20661,11 @@ const docTemplate = `{ ] }, "delete": { - "description": "Deletes an existing ControlImplementationResponsibility entry under the Export of a by-component within a statement.", + "description": "Removes the entry and, in the same transaction, re-derives the owning\nSSPLeverageLink's cached Satisfaction — so dropping the last satisfied entry\nflips full back to partial atomically rather than leaving the link's bookkeeping\nstale for the drift detector to read.", "tags": [ "System Security Plans" ], - "summary": "Delete a responsibility entry on a statement-level by-component's export", + "summary": "Delete a satisfied responsibility on a statement-level by-component", "parameters": [ { "type": "string", @@ -19441,8 +20697,8 @@ const docTemplate = `{ }, { "type": "string", - "description": "Responsibility entry ID", - "name": "responsibilityId", + "description": "Satisfied ID", + "name": "satisfiedId", "in": "path", "required": true } @@ -21847,6 +23103,64 @@ const docTemplate = `{ ] } }, + "/oscal/system-security-plans/{id}/shared-responsibility": { + "get": { + "description": "A flat, statement-keyed projection of this SSP's shared-responsibility posture:\nwhat it exports (with the provided capabilities and the responsibilities it\npushes onto consumers, and whether each is actually offered), what it inherits\nfrom upstreams (with live-recomputed satisfaction and link status), how it\ndischarges upstream responsibilities, and any legacy requirement-anchored\nby-components still to be migrated.\n\nThe inherits arm reuses the same batched projection GET /leveraged-controls\nserves, so the two can never disagree about satisfaction. Optionally filter the\nwhole rollup to one control with controlId.", + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Roll up everything one SSP provides, inherits and satisfies", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Only include rows for this control", + "name": "controlId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscal_SharedResponsibilityRollup" + } + }, + "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": [] + } + ] + } + }, "/oscal/system-security-plans/{id}/system-characteristics": { "get": { "description": "Retrieves the System Characteristics for a given System Security Plan.", @@ -33450,6 +34764,19 @@ const docTemplate = `{ "meta": {} } }, + "handler.GenericDataListResponse-oscalTypes_1_1_3_ByComponent": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/oscalTypes_1_1_3.ByComponent" + } + }, + "meta": {} + } + }, "handler.GenericDataListResponse-oscalTypes_1_1_3_Capability": { "type": "object", "properties": { @@ -33502,6 +34829,19 @@ const docTemplate = `{ "meta": {} } }, + "handler.GenericDataListResponse-oscalTypes_1_1_3_ControlImplementationResponsibility": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementationResponsibility" + } + }, + "meta": {} + } + }, "handler.GenericDataListResponse-oscalTypes_1_1_3_ControlImplementationSet": { "type": "object", "properties": { @@ -33632,6 +34972,19 @@ const docTemplate = `{ "meta": {} } }, + "handler.GenericDataListResponse-oscalTypes_1_1_3_InheritedControlImplementation": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/oscalTypes_1_1_3.InheritedControlImplementation" + } + }, + "meta": {} + } + }, "handler.GenericDataListResponse-oscalTypes_1_1_3_InventoryItem": { "type": "object", "properties": { @@ -33684,27 +35037,40 @@ const docTemplate = `{ "meta": {} } }, - "handler.GenericDataListResponse-oscalTypes_1_1_3_PlanOfActionAndMilestones": { + "handler.GenericDataListResponse-oscalTypes_1_1_3_PlanOfActionAndMilestones": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestones" + } + }, + "meta": {} + } + }, + "handler.GenericDataListResponse-oscalTypes_1_1_3_PoamItem": { "type": "object", "properties": { "data": { "description": "Items from the list response", "type": "array", "items": { - "$ref": "#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestones" + "$ref": "#/definitions/oscalTypes_1_1_3.PoamItem" } }, "meta": {} } }, - "handler.GenericDataListResponse-oscalTypes_1_1_3_PoamItem": { + "handler.GenericDataListResponse-oscalTypes_1_1_3_ProvidedControlImplementation": { "type": "object", "properties": { "data": { "description": "Items from the list response", "type": "array", "items": { - "$ref": "#/definitions/oscalTypes_1_1_3.PoamItem" + "$ref": "#/definitions/oscalTypes_1_1_3.ProvidedControlImplementation" } }, "meta": {} @@ -33762,6 +35128,19 @@ const docTemplate = `{ "meta": {} } }, + "handler.GenericDataListResponse-oscalTypes_1_1_3_SatisfiedControlImplementationResponsibility": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility" + } + }, + "meta": {} + } + }, "handler.GenericDataListResponse-oscalTypes_1_1_3_SystemComponent": { "type": "object", "properties": { @@ -33814,6 +35193,19 @@ const docTemplate = `{ "meta": {} } }, + "handler.GenericDataListResponse-oscal_ControlExportOffer": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/oscal.ControlExportOffer" + } + }, + "meta": {} + } + }, "handler.GenericDataListResponse-oscal_InventoryItemWithSource": { "type": "object", "properties": { @@ -34798,6 +36190,19 @@ const docTemplate = `{ } } }, + "handler.GenericDataResponse-oscalTypes_1_1_3_InheritedControlImplementation": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/oscalTypes_1_1_3.InheritedControlImplementation" + } + ] + } + } + }, "handler.GenericDataResponse-oscalTypes_1_1_3_InventoryItem": { "type": "object", "properties": { @@ -35032,6 +36437,19 @@ const docTemplate = `{ } } }, + "handler.GenericDataResponse-oscalTypes_1_1_3_SatisfiedControlImplementationResponsibility": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility" + } + ] + } + } + }, "handler.GenericDataResponse-oscalTypes_1_1_3_Statement": { "type": "object", "properties": { @@ -35227,6 +36645,19 @@ const docTemplate = `{ } } }, + "handler.GenericDataResponse-oscal_SharedResponsibilityRollup": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/oscal.SharedResponsibilityRollup" + } + ] + } + } + }, "handler.GenericDataResponse-oscal_acceptDashboardSuggestionsResponse": { "type": "object", "properties": { @@ -38193,6 +39624,53 @@ const docTemplate = `{ } } }, + "oscal.ControlExportOffer": { + "type": "object", + "properties": { + "componentTitle": { + "type": "string" + }, + "componentUuid": { + "type": "string" + }, + "controlId": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "offeringId": { + "type": "string" + }, + "offeringStatus": { + "$ref": "#/definitions/relational.SSPExportOfferingStatus" + }, + "offeringTitle": { + "type": "string" + }, + "offeringVersion": { + "type": "integer" + }, + "provided": { + "$ref": "#/definitions/oscal.controlExportProvided" + }, + "responsibilities": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.controlExportResponsibility" + } + }, + "statementId": { + "type": "string" + }, + "upstreamSspId": { + "type": "string" + }, + "upstreamSspTitle": { + "type": "string" + } + } + }, "oscal.CreateInventoryItemRequest": { "type": "object", "properties": { @@ -38447,6 +39925,161 @@ const docTemplate = `{ "oscal.ProfileHandler": { "type": "object" }, + "oscal.SharedResponsibilityInherits": { + "type": "object", + "properties": { + "byComponentUuid": { + "type": "string" + }, + "controlId": { + "type": "string" + }, + "description": { + "type": "string" + }, + "inheritedUuid": { + "type": "string" + }, + "leverageLinkId": { + "type": "string" + }, + "offeringId": { + "type": "string" + }, + "offeringVersion": { + "type": "integer" + }, + "providedUuid": { + "type": "string" + }, + "satisfaction": { + "$ref": "#/definitions/relational.SSPLeverageSatisfaction" + }, + "statementId": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/relational.SSPLeverageStatus" + }, + "upstreamSspId": { + "type": "string" + }, + "upstreamSspTitle": { + "type": "string" + } + } + }, + "oscal.SharedResponsibilityLegacy": { + "type": "object", + "properties": { + "byComponentUuid": { + "type": "string" + }, + "controlId": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "oscal.SharedResponsibilityProvides": { + "type": "object", + "properties": { + "byComponentUuid": { + "type": "string" + }, + "componentTitle": { + "type": "string" + }, + "componentUuid": { + "type": "string" + }, + "controlId": { + "type": "string" + }, + "exportUuid": { + "type": "string" + }, + "offered": { + "description": "Offered reports whether an offering item on this SSP already points at one of these\nprovided-uuids — i.e. whether the capability is actually published for a downstream to\nfind, as opposed to merely existing in the control-implementation tree.", + "type": "boolean" + }, + "provided": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.controlExportProvided" + } + }, + "responsibilities": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.controlExportResponsibility" + } + }, + "statementId": { + "type": "string" + } + } + }, + "oscal.SharedResponsibilityRollup": { + "type": "object", + "properties": { + "inherits": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.SharedResponsibilityInherits" + } + }, + "legacy": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.SharedResponsibilityLegacy" + } + }, + "provides": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.SharedResponsibilityProvides" + } + }, + "satisfies": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.SharedResponsibilitySatisfies" + } + } + } + }, + "oscal.SharedResponsibilitySatisfies": { + "type": "object", + "properties": { + "byComponentUuid": { + "type": "string" + }, + "controlId": { + "type": "string" + }, + "description": { + "type": "string" + }, + "responsibilityUuid": { + "type": "string" + }, + "responsibleRoles": { + "type": "array", + "items": { + "$ref": "#/definitions/oscalTypes_1_1_3.ResponsibleRole" + } + }, + "satisfiedUuid": { + "type": "string" + }, + "statementId": { + "type": "string" + } + } + }, "oscal.SystemComponentRequest": { "type": "object", "properties": { @@ -38606,6 +40239,31 @@ const docTemplate = `{ } } }, + "oscal.controlExportProvided": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, + "oscal.controlExportResponsibility": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "providedUuid": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, "oscal.controlSuggestionResultResponse": { "type": "object", "properties": { @@ -38642,6 +40300,7 @@ const docTemplate = `{ "type": "string" }, "statementId": { + "description": "StatementID is required on every write. The statement is the canonical anchor for\nshared responsibility: a control is too coarse to attribute a provided capability\nagainst, and a requirement-anchored item leaves the downstream unable to say which\nclause the upstream discharges. Still a *string (not string) because the DB column\nstays nullable for legacy rows that pre-date this constraint — see\nmigrateBackfillOfferingItemStatementIDs.", "type": "string" } } @@ -43986,7 +45645,7 @@ const docTemplate = `{ } }, "parentID": { - "description": "As ByComponent can be found in Implemented Requirements \u0026 Statements, using GORM polymorphism to tell us where to attach", + "description": "As ByComponent can be found in Implemented Requirements \u0026 Statements, using GORM polymorphism to tell us where to attach.\n(parent_id, parent_type) is the lookup key for every by-component resolution — the\nresolveByComponentFor* walkers, findOrCreateByComponent, and the shared-responsibility\nrollup all filter on it — so it carries a composite index rather than being an\nunindexed scan.", "type": "string" }, "parentType": { diff --git a/docs/swagger.json b/docs/swagger.json index d43204c9..190d5d09 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -16211,6 +16211,58 @@ ] } }, + "/oscal/ssp-export-offerings/by-control/{controlId}": { + "get": { + "description": "Cross-SSP catalog of what is exported for a control, by whom, and against\nwhich statement — every published offering item whose control-id matches\n(case-insensitively), with the offering, upstream SSP, component, provided\ncapability and responsibility set all resolved server-side. Honours the same\ntrust boundary as the flat catalog: gated by ssp-export-offering:read only,\nnever ssp:read on the upstream SSP. Pass downstreamSspId to narrow the result\nto offerings that SSP is actually allow-listed to subscribe to (BCH-1342).", + "produces": [ + "application/json" + ], + "tags": [ + "SSP Export Offerings" + ], + "summary": "List every published export offering for one control", + "parameters": [ + { + "type": "string", + "description": "Control ID (e.g. AC-2)", + "name": "controlId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Only return offerings this downstream SSP may subscribe to", + "name": "downstreamSspId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-oscal_ControlExportOffer" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/oscal/ssp-export-offerings/{id}": { "get": { "description": "Retrieves a single published export offering by its own ID (no parent SSP scoping). Draft/deprecated/revoked offerings are only visible via the SSP-nested curation routes.", @@ -16265,7 +16317,7 @@ }, "/oscal/ssp-export-offerings/{id}/subscribe": { "post": { - "description": "Records, on the downstream SSP named in the request body, an OSCAL\ninherited-control-implementation and (optionally) satisfied-responsibility\nentries per chosen offering item, plus one leveraged-authorization for the\nwhole request — all in a single atomic write. Never checks ssp:read on the\nupstream SSP: the trust boundary is that subscribing to a published offering\nonly requires ssp-export-offering:subscribe on the offering and ssp:update on\nthe downstream SSP.", + "description": "Records, on the downstream SSP named in the request body, an OSCAL\ninherited-control-implementation and (optionally) satisfied-responsibility\nentries per chosen offering item, plus one leveraged-authorization for the\nwhole request — all in a single atomic write. Never checks ssp:read on the\nupstream SSP: the trust boundary is that subscribing to a published offering\nonly requires ssp-export-offering:subscribe on the offering and ssp:update on\nthe downstream SSP.\n\nEvery subscribed item must be statement-anchored: a legacy offering item with\nno statement-id is rejected with 422. The materialized downstream tree is\nalways requirement -\u003e statement -\u003e by-component -\u003e inherited + satisfied, and is\nreported back in meta.created (each row flagged created:true when inserted,\nfalse when an existing row was reused) so the caller can render newly-created\nrequirements without re-walking the SSP. The data payload is unchanged.", "consumes": [ "application/json" ], @@ -16325,6 +16377,12 @@ "$ref": "#/definitions/api.Error" } }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -17360,8 +17418,73 @@ } }, "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}": { + "get": { + "description": "Deprecated: requirement-anchored by-components are legacy — read-only here so\nthe UI can display and wind them down. New by-components must be created\nagainst a statement.\n\nReturns the by-component with its export (including provided and\nresponsibilities), inherited and satisfied entries, responsible-roles and\nimplementation-status.", + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Get a by-component within an implemented requirement", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ByComponent" + } + }, + "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": [] + } + ] + }, "put": { - "description": "Updates an existing by-component that belongs to an implemented requirement for a given SSP.", + "description": "Deprecated: requirement-anchored by-components are legacy — the statement is\nthe canonical anchor for shared responsibility. Use\nPUT .../statements/{stmtId}/by-components/{byComponentId} instead. This route\nremains so existing requirement-anchored rows can be edited and wound down;\nthere is no requirement-level POST.\n\nUpdates metadata only — description, props, links, set-parameters, remarks,\nimplementation-status and responsible-roles. Any export, inherited or\nsatisfied entries in the body are IGNORED (they have their own sub-resource\nroutes); component-uuid is immutable. Previously this blind-Saved a struct\nrebuilt from the request body, which zeroed every omitted field and upserted\nnested associations with no cascade cleanup.", "consumes": [ "application/json" ], @@ -17372,6 +17495,7 @@ "System Security Plans" ], "summary": "Update a by-component within an implemented requirement", + "deprecated": true, "parameters": [ { "type": "string", @@ -17429,12 +17553,76 @@ "$ref": "#/definitions/api.Error" } } - } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "delete": { + "description": "Deprecated: requirement-anchored by-components are legacy. This delete exists\npurely so legacy and orphaned requirement-anchored rows can be wound down —\nthere is deliberately no requirement-level POST to create new ones.\n\nCascades exactly like the statement-level delete: the by-component's own\nresponsible-roles, its inherited and satisfied entries (each with their\nresponsible-roles), and its export with nested provided/responsibilities are all\nremoved, along with the responsible_role_parties join rows.", + "tags": [ + "System Security Plans" + ], + "summary": "Delete a by-component within an implemented requirement", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "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": [] + } + ] } }, "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export": { "get": { - "description": "Retrieves the Export (with nested Provided and Responsibilities) for a by-component within an implemented requirement.", + "description": "Retrieves the Export (with nested Provided and Responsibilities) for a by-component within an implemented requirement.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", "produces": [ "application/json" ], @@ -17442,6 +17630,7 @@ "System Security Plans" ], "summary": "Get the export for a control-level by-component", + "deprecated": true, "parameters": [ { "type": "string", @@ -17498,7 +17687,7 @@ ] }, "put": { - "description": "Updates the scalar fields of an existing Export for a by-component within an implemented requirement. Provided and Responsibilities entries are managed via their own routes.", + "description": "Updates the scalar fields of an existing Export for a by-component within an implemented requirement. Provided and Responsibilities entries are managed via their own routes.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", "consumes": [ "application/json" ], @@ -17509,6 +17698,7 @@ "System Security Plans" ], "summary": "Update the export for a control-level by-component", + "deprecated": true, "parameters": [ { "type": "string", @@ -17574,7 +17764,7 @@ ] }, "post": { - "description": "Creates the Export for a by-component within an implemented requirement. A by-component may have at most one Export.", + "description": "Creates the Export for a by-component within an implemented requirement. A by-component may have at most one Export.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", "consumes": [ "application/json" ], @@ -17585,6 +17775,7 @@ "System Security Plans" ], "summary": "Create the export for a control-level by-component", + "deprecated": true, "parameters": [ { "type": "string", @@ -17656,11 +17847,12 @@ ] }, "delete": { - "description": "Deletes the Export (and its Provided/Responsibilities entries) for a by-component within an implemented requirement.", + "description": "Deletes the Export (and its Provided/Responsibilities entries) for a by-component within an implemented requirement.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", "tags": [ "System Security Plans" ], "summary": "Delete the export for a control-level by-component", + "deprecated": true, "parameters": [ { "type": "string", @@ -17715,8 +17907,73 @@ } }, "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/provided": { + "get": { + "description": "Deprecated: use the statement-level equivalent. Requirement-anchored exports are legacy.", + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "List the provided entries on a control-level by-component's export", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_ProvidedControlImplementation" + } + }, + "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": [] + } + ] + }, "post": { - "description": "Creates a ProvidedControlImplementation entry under the Export of a by-component within an implemented requirement.", + "description": "Creates a ProvidedControlImplementation entry under the Export of a by-component within an implemented requirement.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", "consumes": [ "application/json" ], @@ -17727,6 +17984,7 @@ "System Security Plans" ], "summary": "Create a provided entry on a control-level by-component's export", + "deprecated": true, "parameters": [ { "type": "string", @@ -17794,7 +18052,7 @@ }, "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/provided/{providedId}": { "put": { - "description": "Replaces an existing ProvidedControlImplementation entry under the Export of a by-component within an implemented requirement.", + "description": "Replaces an existing ProvidedControlImplementation entry under the Export of a by-component within an implemented requirement.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", "consumes": [ "application/json" ], @@ -17805,6 +18063,7 @@ "System Security Plans" ], "summary": "Update a provided entry on a control-level by-component's export", + "deprecated": true, "parameters": [ { "type": "string", @@ -17877,11 +18136,12 @@ ] }, "delete": { - "description": "Deletes an existing ProvidedControlImplementation entry under the Export of a by-component within an implemented requirement.", + "description": "Deletes an existing ProvidedControlImplementation entry under the Export of a by-component within an implemented requirement.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", "tags": [ "System Security Plans" ], "summary": "Delete a provided entry on a control-level by-component's export", + "deprecated": true, "parameters": [ { "type": "string", @@ -17943,18 +18203,16 @@ } }, "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/responsibilities": { - "post": { - "description": "Creates a ControlImplementationResponsibility entry under the Export of a by-component within an implemented requirement.", - "consumes": [ - "application/json" - ], + "get": { + "description": "Deprecated: use the statement-level equivalent. Requirement-anchored exports are legacy.", "produces": [ "application/json" ], "tags": [ "System Security Plans" ], - "summary": "Create a responsibility entry on a control-level by-component's export", + "summary": "List the responsibility entries on a control-level by-component's export", + "deprecated": true, "parameters": [ { "type": "string", @@ -17976,21 +18234,89 @@ "name": "byComponentId", "in": "path", "required": true - }, - { - "description": "Responsibility data", - "name": "responsibility", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementationResponsibility" - } } ], "responses": { - "201": { - "description": "Created", - "schema": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_ControlImplementationResponsibility" + } + }, + "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": [] + } + ] + }, + "post": { + "description": "Creates a ControlImplementationResponsibility entry under the Export of a by-component within an implemented requirement.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Create a responsibility entry on a control-level by-component's export", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + }, + { + "description": "Responsibility data", + "name": "responsibility", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementationResponsibility" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ControlImplementationResponsibility" } }, @@ -18022,7 +18348,7 @@ }, "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/responsibilities/{responsibilityId}": { "put": { - "description": "Replaces an existing ControlImplementationResponsibility entry under the Export of a by-component within an implemented requirement.", + "description": "Replaces an existing ControlImplementationResponsibility entry under the Export of a by-component within an implemented requirement.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", "consumes": [ "application/json" ], @@ -18033,6 +18359,7 @@ "System Security Plans" ], "summary": "Update a responsibility entry on a control-level by-component's export", + "deprecated": true, "parameters": [ { "type": "string", @@ -18105,11 +18432,12 @@ ] }, "delete": { - "description": "Deletes an existing ControlImplementationResponsibility entry under the Export of a by-component within an implemented requirement.", + "description": "Deletes an existing ControlImplementationResponsibility entry under the Export of a by-component within an implemented requirement.\n\nDeprecated: requirement-anchored exports are legacy. Shared responsibility is\ntracked per statement — use the statement-level equivalent under\n.../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so\nexisting requirement-anchored exports can be read and wound down.", "tags": [ "System Security Plans" ], "summary": "Delete a responsibility entry on a control-level by-component's export", + "deprecated": true, "parameters": [ { "type": "string", @@ -18442,6 +18770,70 @@ } }, "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components": { + "get": { + "description": "Returns every by-component attached to the statement, each with its export\n(including provided and responsibilities), inherited and satisfied entries,\nresponsible-roles and implementation-status.", + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "List the by-components on a statement", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_ByComponent" + } + }, + "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": [] + } + ] + }, "post": { "description": "Create a by-component within an existing statement within an implemented requirement for a given SSP.", "consumes": [ @@ -18515,8 +18907,79 @@ } }, "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}": { + "get": { + "description": "Returns the by-component with its export (including provided and\nresponsibilities), inherited and satisfied entries, responsible-roles and\nimplementation-status — everything needed to refetch one by-component after\nediting any of its sub-resources.", + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Get a by-component within a statement", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ByComponent" + } + }, + "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": [] + } + ] + }, "put": { - "description": "Updates a by-component within an existing statement within an implemented requirement for a given SSP.", + "description": "Updates metadata only — description, props, links, set-parameters, remarks,\nimplementation-status and responsible-roles. Any export, inherited or\nsatisfied entries in the body are IGNORED: those subtrees are managed through\ntheir own sub-resource routes, which enforce the leverage bookkeeping this\nroute cannot. component-uuid is immutable. Previously this blind-Saved a\nstruct rebuilt from the request body, which zeroed every omitted field and\nupserted nested associations with no cascade cleanup.", "consumes": [ "application/json" ], @@ -18591,7 +19054,12 @@ "$ref": "#/definitions/api.Error" } } - } + }, + "security": [ + { + "OAuth2Password": [] + } + ] }, "delete": { "description": "Deletes a by-component within an existing statement within an implemented requirement for a given SSP.", @@ -18817,19 +19285,812 @@ "OAuth2Password": [] } ] - }, - "post": { - "description": "Creates the Export for a by-component within a statement. A by-component may have at most one Export.", - "consumes": [ - "application/json" - ], + }, + "post": { + "description": "Creates the Export for a by-component within a statement. A by-component may have at most one Export.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Create the export for a statement-level by-component", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + }, + { + "description": "Export data", + "name": "export", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.Export" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_Export" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "delete": { + "description": "Deletes the Export (and its Provided/Responsibilities entries) for a by-component within a statement.", + "tags": [ + "System Security Plans" + ], + "summary": "Delete the export for a statement-level by-component", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "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": [] + } + ] + } + }, + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/provided": { + "get": { + "description": "Retrieves every ProvidedControlImplementation under the Export of a by-component within a statement.", + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "List the provided entries on a statement-level by-component's export", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_ProvidedControlImplementation" + } + }, + "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": [] + } + ] + }, + "post": { + "description": "Creates a ProvidedControlImplementation entry under the Export of a by-component within a statement.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Create a provided entry on a statement-level by-component's export", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + }, + { + "description": "Provided data", + "name": "provided", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.ProvidedControlImplementation" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ProvidedControlImplementation" + } + }, + "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": [] + } + ] + } + }, + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/provided/{providedId}": { + "put": { + "description": "Replaces an existing ProvidedControlImplementation entry under the Export of a by-component within a statement.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Update a provided entry on a statement-level by-component's export", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Provided entry ID", + "name": "providedId", + "in": "path", + "required": true + }, + { + "description": "Provided data", + "name": "provided", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.ProvidedControlImplementation" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ProvidedControlImplementation" + } + }, + "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": [] + } + ] + }, + "delete": { + "description": "Deletes an existing ProvidedControlImplementation entry under the Export of a by-component within a statement.", + "tags": [ + "System Security Plans" + ], + "summary": "Delete a provided entry on a statement-level by-component's export", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Provided entry ID", + "name": "providedId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "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": [] + } + ] + } + }, + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/responsibilities": { + "get": { + "description": "Retrieves every ControlImplementationResponsibility under the Export of a by-component within a statement.", + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "List the responsibility entries on a statement-level by-component's export", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_ControlImplementationResponsibility" + } + }, + "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": [] + } + ] + }, + "post": { + "description": "Creates a ControlImplementationResponsibility entry under the Export of a by-component within a statement.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Create a responsibility entry on a statement-level by-component's export", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + }, + { + "description": "Responsibility data", + "name": "responsibility", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementationResponsibility" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ControlImplementationResponsibility" + } + }, + "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": [] + } + ] + } + }, + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/responsibilities/{responsibilityId}": { + "put": { + "description": "Replaces an existing ControlImplementationResponsibility entry under the Export of a by-component within a statement.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Update a responsibility entry on a statement-level by-component's export", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Responsibility entry ID", + "name": "responsibilityId", + "in": "path", + "required": true + }, + { + "description": "Responsibility data", + "name": "responsibility", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementationResponsibility" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ControlImplementationResponsibility" + } + }, + "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": [] + } + ] + }, + "delete": { + "description": "Deletes an existing ControlImplementationResponsibility entry under the Export of a by-component within a statement.", + "tags": [ + "System Security Plans" + ], + "summary": "Delete a responsibility entry on a statement-level by-component's export", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "By-Component ID", + "name": "byComponentId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Responsibility entry ID", + "name": "responsibilityId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "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": [] + } + ] + } + }, + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/inherited": { + "get": { + "description": "Retrieves what this system consumes from an upstream under this statement.", "produces": [ "application/json" ], "tags": [ "System Security Plans" ], - "summary": "Create the export for a statement-level by-component", + "summary": "List the inherited control implementations on a statement-level by-component", "parameters": [ { "type": "string", @@ -18858,22 +20119,13 @@ "name": "byComponentId", "in": "path", "required": true - }, - { - "description": "Export data", - "name": "export", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/oscalTypes_1_1_3.Export" - } } ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_Export" + "$ref": "#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_InheritedControlImplementation" } }, "400": { @@ -18888,12 +20140,6 @@ "$ref": "#/definitions/api.Error" } }, - "409": { - "description": "Conflict", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -18907,12 +20153,18 @@ } ] }, - "delete": { - "description": "Deletes the Export (and its Provided/Responsibilities entries) for a by-component within a statement.", + "post": { + "description": "Records that this system consumes an upstream's provided capability under this\nstatement. Hand-authored entries carry no leverage link; entries created by\nSubscribe do, and are protected from deletion (409) while that subscription lives.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "tags": [ "System Security Plans" ], - "summary": "Delete the export for a statement-level by-component", + "summary": "Create an inherited control implementation on a statement-level by-component", "parameters": [ { "type": "string", @@ -18941,11 +20193,23 @@ "name": "byComponentId", "in": "path", "required": true + }, + { + "description": "Inherited data", + "name": "inherited", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.InheritedControlImplementation" + } } ], "responses": { - "204": { - "description": "No Content" + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_InheritedControlImplementation" + } }, "400": { "description": "Bad Request", @@ -18973,9 +20237,9 @@ ] } }, - "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/provided": { - "post": { - "description": "Creates a ProvidedControlImplementation entry under the Export of a by-component within a statement.", + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/inherited/{inheritedId}": { + "put": { + "description": "Metadata only — description, props, links and responsible-roles. provided-uuid\nis immutable and a body attempting to change it is rejected with 400: it is the\nidentity the leverage link and the drift detector join on.", "consumes": [ "application/json" ], @@ -18985,7 +20249,7 @@ "tags": [ "System Security Plans" ], - "summary": "Create a provided entry on a statement-level by-component's export", + "summary": "Update an inherited control implementation on a statement-level by-component", "parameters": [ { "type": "string", @@ -19016,20 +20280,27 @@ "required": true }, { - "description": "Provided data", - "name": "provided", + "type": "string", + "description": "Inherited ID", + "name": "inheritedId", + "in": "path", + "required": true + }, + { + "description": "Inherited data", + "name": "inherited", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/oscalTypes_1_1_3.ProvidedControlImplementation" + "$ref": "#/definitions/oscalTypes_1_1_3.InheritedControlImplementation" } } ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ProvidedControlImplementation" + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_InheritedControlImplementation" } }, "400": { @@ -19056,21 +20327,13 @@ "OAuth2Password": [] } ] - } - }, - "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/provided/{providedId}": { - "put": { - "description": "Replaces an existing ProvidedControlImplementation entry under the Export of a by-component within a statement.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], + }, + "delete": { + "description": "Hand-authored entries delete freely. An entry created by a subscription is owned\nby that subscription — an SSPLeverageLink still references it, and both drift\ndetection and notifications read through that reference — so deleting it returns\n409; unsubscribe instead.", "tags": [ "System Security Plans" ], - "summary": "Update a provided entry on a statement-level by-component's export", + "summary": "Delete an inherited control implementation on a statement-level by-component", "parameters": [ { "type": "string", @@ -19102,27 +20365,15 @@ }, { "type": "string", - "description": "Provided entry ID", - "name": "providedId", + "description": "Inherited ID", + "name": "inheritedId", "in": "path", "required": true - }, - { - "description": "Provided data", - "name": "provided", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/oscalTypes_1_1_3.ProvidedControlImplementation" - } } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ProvidedControlImplementation" - } + "204": { + "description": "No Content" }, "400": { "description": "Bad Request", @@ -19136,6 +20387,12 @@ "$ref": "#/definitions/api.Error" } }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -19148,13 +20405,18 @@ "OAuth2Password": [] } ] - }, - "delete": { - "description": "Deletes an existing ProvidedControlImplementation entry under the Export of a by-component within a statement.", + } + }, + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/satisfied": { + "get": { + "description": "Retrieves how this system discharges its upstream's responsibilities under this statement.", + "produces": [ + "application/json" + ], "tags": [ "System Security Plans" ], - "summary": "Delete a provided entry on a statement-level by-component's export", + "summary": "List the satisfied responsibilities on a statement-level by-component", "parameters": [ { "type": "string", @@ -19183,18 +20445,14 @@ "name": "byComponentId", "in": "path", "required": true - }, - { - "type": "string", - "description": "Provided entry ID", - "name": "providedId", - "in": "path", - "required": true } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_SatisfiedControlImplementationResponsibility" + } }, "400": { "description": "Bad Request", @@ -19220,11 +20478,9 @@ "OAuth2Password": [] } ] - } - }, - "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/responsibilities": { + }, "post": { - "description": "Creates a ControlImplementationResponsibility entry under the Export of a by-component within a statement.", + "description": "Records that this system discharges one of its upstream's responsibilities. The\nresponsibility-uuid must resolve to a ControlImplementationResponsibility on an\nExport this by-component actually inherits from (400 otherwise). If the owning\nSSPLeverageLink exists, its cached Satisfaction is re-derived in the same\ntransaction, so partial can flip to full atomically with the write that causes it.", "consumes": [ "application/json" ], @@ -19234,7 +20490,7 @@ "tags": [ "System Security Plans" ], - "summary": "Create a responsibility entry on a statement-level by-component's export", + "summary": "Create a satisfied responsibility on a statement-level by-component", "parameters": [ { "type": "string", @@ -19265,12 +20521,12 @@ "required": true }, { - "description": "Responsibility data", - "name": "responsibility", + "description": "Satisfied data", + "name": "satisfied", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementationResponsibility" + "$ref": "#/definitions/oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility" } } ], @@ -19278,7 +20534,7 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ControlImplementationResponsibility" + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_SatisfiedControlImplementationResponsibility" } }, "400": { @@ -19307,9 +20563,9 @@ ] } }, - "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/responsibilities/{responsibilityId}": { + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/satisfied/{satisfiedId}": { "put": { - "description": "Replaces an existing ControlImplementationResponsibility entry under the Export of a by-component within a statement.", + "description": "Metadata only — description, remarks, props, links and responsible-roles.\nresponsibility-uuid is immutable and a body attempting to change it is rejected\nwith 400: it is what deriveSatisfaction and the drift detector match on. Because\nthe identity can't change, the owning link's Satisfaction cannot change either,\nso no re-derivation is needed here.", "consumes": [ "application/json" ], @@ -19319,7 +20575,7 @@ "tags": [ "System Security Plans" ], - "summary": "Update a responsibility entry on a statement-level by-component's export", + "summary": "Update a satisfied responsibility on a statement-level by-component", "parameters": [ { "type": "string", @@ -19351,18 +20607,18 @@ }, { "type": "string", - "description": "Responsibility entry ID", - "name": "responsibilityId", + "description": "Satisfied ID", + "name": "satisfiedId", "in": "path", "required": true }, { - "description": "Responsibility data", - "name": "responsibility", + "description": "Satisfied data", + "name": "satisfied", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementationResponsibility" + "$ref": "#/definitions/oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility" } } ], @@ -19370,7 +20626,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ControlImplementationResponsibility" + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_SatisfiedControlImplementationResponsibility" } }, "400": { @@ -19399,11 +20655,11 @@ ] }, "delete": { - "description": "Deletes an existing ControlImplementationResponsibility entry under the Export of a by-component within a statement.", + "description": "Removes the entry and, in the same transaction, re-derives the owning\nSSPLeverageLink's cached Satisfaction — so dropping the last satisfied entry\nflips full back to partial atomically rather than leaving the link's bookkeeping\nstale for the drift detector to read.", "tags": [ "System Security Plans" ], - "summary": "Delete a responsibility entry on a statement-level by-component's export", + "summary": "Delete a satisfied responsibility on a statement-level by-component", "parameters": [ { "type": "string", @@ -19435,8 +20691,8 @@ }, { "type": "string", - "description": "Responsibility entry ID", - "name": "responsibilityId", + "description": "Satisfied ID", + "name": "satisfiedId", "in": "path", "required": true } @@ -21841,6 +23097,64 @@ ] } }, + "/oscal/system-security-plans/{id}/shared-responsibility": { + "get": { + "description": "A flat, statement-keyed projection of this SSP's shared-responsibility posture:\nwhat it exports (with the provided capabilities and the responsibilities it\npushes onto consumers, and whether each is actually offered), what it inherits\nfrom upstreams (with live-recomputed satisfaction and link status), how it\ndischarges upstream responsibilities, and any legacy requirement-anchored\nby-components still to be migrated.\n\nThe inherits arm reuses the same batched projection GET /leveraged-controls\nserves, so the two can never disagree about satisfaction. Optionally filter the\nwhole rollup to one control with controlId.", + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Roll up everything one SSP provides, inherits and satisfies", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Only include rows for this control", + "name": "controlId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscal_SharedResponsibilityRollup" + } + }, + "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": [] + } + ] + } + }, "/oscal/system-security-plans/{id}/system-characteristics": { "get": { "description": "Retrieves the System Characteristics for a given System Security Plan.", @@ -33444,6 +34758,19 @@ "meta": {} } }, + "handler.GenericDataListResponse-oscalTypes_1_1_3_ByComponent": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/oscalTypes_1_1_3.ByComponent" + } + }, + "meta": {} + } + }, "handler.GenericDataListResponse-oscalTypes_1_1_3_Capability": { "type": "object", "properties": { @@ -33496,6 +34823,19 @@ "meta": {} } }, + "handler.GenericDataListResponse-oscalTypes_1_1_3_ControlImplementationResponsibility": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementationResponsibility" + } + }, + "meta": {} + } + }, "handler.GenericDataListResponse-oscalTypes_1_1_3_ControlImplementationSet": { "type": "object", "properties": { @@ -33626,6 +34966,19 @@ "meta": {} } }, + "handler.GenericDataListResponse-oscalTypes_1_1_3_InheritedControlImplementation": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/oscalTypes_1_1_3.InheritedControlImplementation" + } + }, + "meta": {} + } + }, "handler.GenericDataListResponse-oscalTypes_1_1_3_InventoryItem": { "type": "object", "properties": { @@ -33678,27 +35031,40 @@ "meta": {} } }, - "handler.GenericDataListResponse-oscalTypes_1_1_3_PlanOfActionAndMilestones": { + "handler.GenericDataListResponse-oscalTypes_1_1_3_PlanOfActionAndMilestones": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestones" + } + }, + "meta": {} + } + }, + "handler.GenericDataListResponse-oscalTypes_1_1_3_PoamItem": { "type": "object", "properties": { "data": { "description": "Items from the list response", "type": "array", "items": { - "$ref": "#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestones" + "$ref": "#/definitions/oscalTypes_1_1_3.PoamItem" } }, "meta": {} } }, - "handler.GenericDataListResponse-oscalTypes_1_1_3_PoamItem": { + "handler.GenericDataListResponse-oscalTypes_1_1_3_ProvidedControlImplementation": { "type": "object", "properties": { "data": { "description": "Items from the list response", "type": "array", "items": { - "$ref": "#/definitions/oscalTypes_1_1_3.PoamItem" + "$ref": "#/definitions/oscalTypes_1_1_3.ProvidedControlImplementation" } }, "meta": {} @@ -33756,6 +35122,19 @@ "meta": {} } }, + "handler.GenericDataListResponse-oscalTypes_1_1_3_SatisfiedControlImplementationResponsibility": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility" + } + }, + "meta": {} + } + }, "handler.GenericDataListResponse-oscalTypes_1_1_3_SystemComponent": { "type": "object", "properties": { @@ -33808,6 +35187,19 @@ "meta": {} } }, + "handler.GenericDataListResponse-oscal_ControlExportOffer": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/oscal.ControlExportOffer" + } + }, + "meta": {} + } + }, "handler.GenericDataListResponse-oscal_InventoryItemWithSource": { "type": "object", "properties": { @@ -34792,6 +36184,19 @@ } } }, + "handler.GenericDataResponse-oscalTypes_1_1_3_InheritedControlImplementation": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/oscalTypes_1_1_3.InheritedControlImplementation" + } + ] + } + } + }, "handler.GenericDataResponse-oscalTypes_1_1_3_InventoryItem": { "type": "object", "properties": { @@ -35026,6 +36431,19 @@ } } }, + "handler.GenericDataResponse-oscalTypes_1_1_3_SatisfiedControlImplementationResponsibility": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility" + } + ] + } + } + }, "handler.GenericDataResponse-oscalTypes_1_1_3_Statement": { "type": "object", "properties": { @@ -35221,6 +36639,19 @@ } } }, + "handler.GenericDataResponse-oscal_SharedResponsibilityRollup": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/oscal.SharedResponsibilityRollup" + } + ] + } + } + }, "handler.GenericDataResponse-oscal_acceptDashboardSuggestionsResponse": { "type": "object", "properties": { @@ -38187,6 +39618,53 @@ } } }, + "oscal.ControlExportOffer": { + "type": "object", + "properties": { + "componentTitle": { + "type": "string" + }, + "componentUuid": { + "type": "string" + }, + "controlId": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "offeringId": { + "type": "string" + }, + "offeringStatus": { + "$ref": "#/definitions/relational.SSPExportOfferingStatus" + }, + "offeringTitle": { + "type": "string" + }, + "offeringVersion": { + "type": "integer" + }, + "provided": { + "$ref": "#/definitions/oscal.controlExportProvided" + }, + "responsibilities": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.controlExportResponsibility" + } + }, + "statementId": { + "type": "string" + }, + "upstreamSspId": { + "type": "string" + }, + "upstreamSspTitle": { + "type": "string" + } + } + }, "oscal.CreateInventoryItemRequest": { "type": "object", "properties": { @@ -38441,6 +39919,161 @@ "oscal.ProfileHandler": { "type": "object" }, + "oscal.SharedResponsibilityInherits": { + "type": "object", + "properties": { + "byComponentUuid": { + "type": "string" + }, + "controlId": { + "type": "string" + }, + "description": { + "type": "string" + }, + "inheritedUuid": { + "type": "string" + }, + "leverageLinkId": { + "type": "string" + }, + "offeringId": { + "type": "string" + }, + "offeringVersion": { + "type": "integer" + }, + "providedUuid": { + "type": "string" + }, + "satisfaction": { + "$ref": "#/definitions/relational.SSPLeverageSatisfaction" + }, + "statementId": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/relational.SSPLeverageStatus" + }, + "upstreamSspId": { + "type": "string" + }, + "upstreamSspTitle": { + "type": "string" + } + } + }, + "oscal.SharedResponsibilityLegacy": { + "type": "object", + "properties": { + "byComponentUuid": { + "type": "string" + }, + "controlId": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "oscal.SharedResponsibilityProvides": { + "type": "object", + "properties": { + "byComponentUuid": { + "type": "string" + }, + "componentTitle": { + "type": "string" + }, + "componentUuid": { + "type": "string" + }, + "controlId": { + "type": "string" + }, + "exportUuid": { + "type": "string" + }, + "offered": { + "description": "Offered reports whether an offering item on this SSP already points at one of these\nprovided-uuids — i.e. whether the capability is actually published for a downstream to\nfind, as opposed to merely existing in the control-implementation tree.", + "type": "boolean" + }, + "provided": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.controlExportProvided" + } + }, + "responsibilities": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.controlExportResponsibility" + } + }, + "statementId": { + "type": "string" + } + } + }, + "oscal.SharedResponsibilityRollup": { + "type": "object", + "properties": { + "inherits": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.SharedResponsibilityInherits" + } + }, + "legacy": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.SharedResponsibilityLegacy" + } + }, + "provides": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.SharedResponsibilityProvides" + } + }, + "satisfies": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.SharedResponsibilitySatisfies" + } + } + } + }, + "oscal.SharedResponsibilitySatisfies": { + "type": "object", + "properties": { + "byComponentUuid": { + "type": "string" + }, + "controlId": { + "type": "string" + }, + "description": { + "type": "string" + }, + "responsibilityUuid": { + "type": "string" + }, + "responsibleRoles": { + "type": "array", + "items": { + "$ref": "#/definitions/oscalTypes_1_1_3.ResponsibleRole" + } + }, + "satisfiedUuid": { + "type": "string" + }, + "statementId": { + "type": "string" + } + } + }, "oscal.SystemComponentRequest": { "type": "object", "properties": { @@ -38600,6 +40233,31 @@ } } }, + "oscal.controlExportProvided": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, + "oscal.controlExportResponsibility": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "providedUuid": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, "oscal.controlSuggestionResultResponse": { "type": "object", "properties": { @@ -38636,6 +40294,7 @@ "type": "string" }, "statementId": { + "description": "StatementID is required on every write. The statement is the canonical anchor for\nshared responsibility: a control is too coarse to attribute a provided capability\nagainst, and a requirement-anchored item leaves the downstream unable to say which\nclause the upstream discharges. Still a *string (not string) because the DB column\nstays nullable for legacy rows that pre-date this constraint — see\nmigrateBackfillOfferingItemStatementIDs.", "type": "string" } } @@ -43980,7 +45639,7 @@ } }, "parentID": { - "description": "As ByComponent can be found in Implemented Requirements \u0026 Statements, using GORM polymorphism to tell us where to attach", + "description": "As ByComponent can be found in Implemented Requirements \u0026 Statements, using GORM polymorphism to tell us where to attach.\n(parent_id, parent_type) is the lookup key for every by-component resolution — the\nresolveByComponentFor* walkers, findOrCreateByComponent, and the shared-responsibility\nrollup all filter on it — so it carries a composite index rather than being an\nunindexed scan.", "type": "string" }, "parentType": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 2e145a71..617cccf5 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -700,6 +700,15 @@ definitions: type: array meta: {} type: object + handler.GenericDataListResponse-oscal_ControlExportOffer: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/oscal.ControlExportOffer' + type: array + meta: {} + type: object handler.GenericDataListResponse-oscal_InventoryItemWithSource: properties: data: @@ -817,6 +826,15 @@ definitions: type: array meta: {} type: object + handler.GenericDataListResponse-oscalTypes_1_1_3_ByComponent: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/oscalTypes_1_1_3.ByComponent' + type: array + meta: {} + type: object handler.GenericDataListResponse-oscalTypes_1_1_3_Capability: properties: data: @@ -853,6 +871,15 @@ definitions: type: array meta: {} type: object + handler.GenericDataListResponse-oscalTypes_1_1_3_ControlImplementationResponsibility: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/oscalTypes_1_1_3.ControlImplementationResponsibility' + type: array + meta: {} + type: object handler.GenericDataListResponse-oscalTypes_1_1_3_ControlImplementationSet: properties: data: @@ -943,6 +970,15 @@ definitions: type: array meta: {} type: object + handler.GenericDataListResponse-oscalTypes_1_1_3_InheritedControlImplementation: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/oscalTypes_1_1_3.InheritedControlImplementation' + type: array + meta: {} + type: object handler.GenericDataListResponse-oscalTypes_1_1_3_InventoryItem: properties: data: @@ -997,6 +1033,15 @@ definitions: type: array meta: {} type: object + handler.GenericDataListResponse-oscalTypes_1_1_3_ProvidedControlImplementation: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/oscalTypes_1_1_3.ProvidedControlImplementation' + type: array + meta: {} + type: object handler.GenericDataListResponse-oscalTypes_1_1_3_Resource: properties: data: @@ -1033,6 +1078,15 @@ definitions: type: array meta: {} type: object + handler.GenericDataListResponse-oscalTypes_1_1_3_SatisfiedControlImplementationResponsibility: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility' + type: array + meta: {} + type: object handler.GenericDataListResponse-oscalTypes_1_1_3_SystemComponent: properties: data: @@ -1414,6 +1468,13 @@ definitions: - $ref: '#/definitions/oscal.ProfileHandler' description: Wrapped response data type: object + handler.GenericDataResponse-oscal_SharedResponsibilityRollup: + properties: + data: + allOf: + - $ref: '#/definitions/oscal.SharedResponsibilityRollup' + description: Wrapped response data + type: object handler.GenericDataResponse-oscal_acceptDashboardSuggestionsResponse: properties: data: @@ -1652,6 +1713,13 @@ definitions: - $ref: '#/definitions/oscalTypes_1_1_3.ImportSsp' description: Wrapped response data type: object + handler.GenericDataResponse-oscalTypes_1_1_3_InheritedControlImplementation: + properties: + data: + allOf: + - $ref: '#/definitions/oscalTypes_1_1_3.InheritedControlImplementation' + description: Wrapped response data + type: object handler.GenericDataResponse-oscalTypes_1_1_3_InventoryItem: properties: data: @@ -1778,6 +1846,13 @@ definitions: - $ref: '#/definitions/oscalTypes_1_1_3.Role' description: Wrapped response data type: object + handler.GenericDataResponse-oscalTypes_1_1_3_SatisfiedControlImplementationResponsibility: + properties: + data: + allOf: + - $ref: '#/definitions/oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility' + description: Wrapped response data + type: object handler.GenericDataResponse-oscalTypes_1_1_3_Statement: properties: data: @@ -3750,6 +3825,37 @@ definitions: profileId: type: string type: object + oscal.ControlExportOffer: + properties: + componentTitle: + type: string + componentUuid: + type: string + controlId: + type: string + itemId: + type: string + offeringId: + type: string + offeringStatus: + $ref: '#/definitions/relational.SSPExportOfferingStatus' + offeringTitle: + type: string + offeringVersion: + type: integer + provided: + $ref: '#/definitions/oscal.controlExportProvided' + responsibilities: + items: + $ref: '#/definitions/oscal.controlExportResponsibility' + type: array + statementId: + type: string + upstreamSspId: + type: string + upstreamSspTitle: + type: string + type: object oscal.CreateInventoryItemRequest: properties: destination: @@ -3916,6 +4022,111 @@ definitions: type: object oscal.ProfileHandler: type: object + oscal.SharedResponsibilityInherits: + properties: + byComponentUuid: + type: string + controlId: + type: string + description: + type: string + inheritedUuid: + type: string + leverageLinkId: + type: string + offeringId: + type: string + offeringVersion: + type: integer + providedUuid: + type: string + satisfaction: + $ref: '#/definitions/relational.SSPLeverageSatisfaction' + statementId: + type: string + status: + $ref: '#/definitions/relational.SSPLeverageStatus' + upstreamSspId: + type: string + upstreamSspTitle: + type: string + type: object + oscal.SharedResponsibilityLegacy: + properties: + byComponentUuid: + type: string + controlId: + type: string + reason: + type: string + type: object + oscal.SharedResponsibilityProvides: + properties: + byComponentUuid: + type: string + componentTitle: + type: string + componentUuid: + type: string + controlId: + type: string + exportUuid: + type: string + offered: + description: |- + Offered reports whether an offering item on this SSP already points at one of these + provided-uuids — i.e. whether the capability is actually published for a downstream to + find, as opposed to merely existing in the control-implementation tree. + type: boolean + provided: + items: + $ref: '#/definitions/oscal.controlExportProvided' + type: array + responsibilities: + items: + $ref: '#/definitions/oscal.controlExportResponsibility' + type: array + statementId: + type: string + type: object + oscal.SharedResponsibilityRollup: + properties: + inherits: + items: + $ref: '#/definitions/oscal.SharedResponsibilityInherits' + type: array + legacy: + items: + $ref: '#/definitions/oscal.SharedResponsibilityLegacy' + type: array + provides: + items: + $ref: '#/definitions/oscal.SharedResponsibilityProvides' + type: array + satisfies: + items: + $ref: '#/definitions/oscal.SharedResponsibilitySatisfies' + type: array + type: object + oscal.SharedResponsibilitySatisfies: + properties: + byComponentUuid: + type: string + controlId: + type: string + description: + type: string + responsibilityUuid: + type: string + responsibleRoles: + items: + $ref: '#/definitions/oscalTypes_1_1_3.ResponsibleRole' + type: array + satisfiedUuid: + type: string + statementId: + type: string + type: object oscal.SystemComponentRequest: properties: definedComponentId: @@ -4020,6 +4231,22 @@ definitions: statementId: type: string type: object + oscal.controlExportProvided: + properties: + description: + type: string + uuid: + type: string + type: object + oscal.controlExportResponsibility: + properties: + description: + type: string + providedUuid: + type: string + uuid: + type: string + type: object oscal.controlSuggestionResultResponse: properties: controlCatalogId: @@ -4044,6 +4271,13 @@ definitions: providedUuid: type: string statementId: + description: |- + StatementID is required on every write. The statement is the canonical anchor for + shared responsibility: a control is too coarse to attribute a provided capability + against, and a requirement-anchored item leaves the downstream unable to say which + clause the upstream discharges. Still a *string (not string) because the DB column + stays nullable for legacy rows that pre-date this constraint — see + migrateBackfillOfferingItemStatementIDs. type: string type: object oscal.createExportOfferingRequest: @@ -7580,8 +7814,12 @@ definitions: $ref: '#/definitions/relational.Link' type: array parentID: - description: As ByComponent can be found in Implemented Requirements & Statements, - using GORM polymorphism to tell us where to attach + description: |- + As ByComponent can be found in Implemented Requirements & Statements, using GORM polymorphism to tell us where to attach. + (parent_id, parent_type) is the lookup key for every by-component resolution — the + resolveByComponentFor* walkers, findOrCreateByComponent, and the shared-responsibility + rollup all filter on it — so it carries a composite index rather than being an + unindexed scan. type: string parentType: type: string @@ -22116,6 +22354,13 @@ paths: upstream SSP: the trust boundary is that subscribing to a published offering only requires ssp-export-offering:subscribe on the offering and ssp:update on the downstream SSP. + + Every subscribed item must be statement-anchored: a legacy offering item with + no statement-id is rejected with 422. The materialized downstream tree is + always requirement -> statement -> by-component -> inherited + satisfied, and is + reported back in meta.created (each row flagged created:true when inserted, + false when an existing row was reused) so the caller can render newly-created + requirements without re-walking the SSP. The data payload is unchanged. parameters: - description: Offering ID in: path @@ -22151,6 +22396,10 @@ paths: description: Conflict schema: $ref: '#/definitions/api.Error' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/api.Error' "500": description: Internal Server Error schema: @@ -22160,6 +22409,46 @@ paths: summary: Subscribe to a published export offering tags: - SSP Export Offerings + /oscal/ssp-export-offerings/by-control/{controlId}: + get: + description: |- + Cross-SSP catalog of what is exported for a control, by whom, and against + which statement — every published offering item whose control-id matches + (case-insensitively), with the offering, upstream SSP, component, provided + capability and responsibility set all resolved server-side. Honours the same + trust boundary as the flat catalog: gated by ssp-export-offering:read only, + never ssp:read on the upstream SSP. Pass downstreamSspId to narrow the result + to offerings that SSP is actually allow-listed to subscribe to (BCH-1342). + parameters: + - description: Control ID (e.g. AC-2) + in: path + name: controlId + required: true + type: string + - description: Only return offerings this downstream SSP may subscribe to + in: query + name: downstreamSspId + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-oscal_ControlExportOffer' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List every published export offering for one control + tags: + - SSP Export Offerings /oscal/system-security-plans: get: description: Retrieves all System Security Plans. @@ -22836,11 +23125,17 @@ paths: tags: - System Security Plans /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}: - put: - consumes: - - application/json - description: Updates an existing by-component that belongs to an implemented - requirement for a given SSP. + delete: + deprecated: true + description: |- + Deprecated: requirement-anchored by-components are legacy. This delete exists + purely so legacy and orphaned requirement-anchored rows can be wound down — + there is deliberately no requirement-level POST to create new ones. + + Cascades exactly like the statement-level delete: the by-component's own + responsible-roles, its inherited and satisfied entries (each with their + responsible-roles), and its export with nested provided/responsibilities are all + removed, along with the responsible_role_parties join rows. parameters: - description: SSP ID in: path @@ -22857,19 +23152,9 @@ paths: name: byComponentId required: true type: string - - description: By-Component data - in: body - name: by-component - required: true - schema: - $ref: '#/definitions/oscalTypes_1_1_3.ByComponent' - produces: - - application/json responses: - "200": - description: OK - schema: - $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ByComponent' + "204": + description: No Content "400": description: Bad Request schema: @@ -22882,13 +23167,21 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/api.Error' - summary: Update a by-component within an implemented requirement + security: + - OAuth2Password: [] + summary: Delete a by-component within an implemented requirement tags: - System Security Plans - /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export: - delete: - description: Deletes the Export (and its Provided/Responsibilities entries) - for a by-component within an implemented requirement. + get: + deprecated: true + description: |- + Deprecated: requirement-anchored by-components are legacy — read-only here so + the UI can display and wind them down. New by-components must be created + against a statement. + + Returns the by-component with its export (including provided and + responsibilities), inherited and satisfied entries, responsible-roles and + implementation-status. parameters: - description: SSP ID in: path @@ -22905,9 +23198,13 @@ paths: name: byComponentId required: true type: string + produces: + - application/json responses: - "204": - description: No Content + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ByComponent' "400": description: Bad Request schema: @@ -22922,12 +23219,26 @@ paths: $ref: '#/definitions/api.Error' security: - OAuth2Password: [] - summary: Delete the export for a control-level by-component + summary: Get a by-component within an implemented requirement tags: - System Security Plans - get: - description: Retrieves the Export (with nested Provided and Responsibilities) - for a by-component within an implemented requirement. + put: + consumes: + - application/json + deprecated: true + description: |- + Deprecated: requirement-anchored by-components are legacy — the statement is + the canonical anchor for shared responsibility. Use + PUT .../statements/{stmtId}/by-components/{byComponentId} instead. This route + remains so existing requirement-anchored rows can be edited and wound down; + there is no requirement-level POST. + + Updates metadata only — description, props, links, set-parameters, remarks, + implementation-status and responsible-roles. Any export, inherited or + satisfied entries in the body are IGNORED (they have their own sub-resource + routes); component-uuid is immutable. Previously this blind-Saved a struct + rebuilt from the request body, which zeroed every omitted field and upserted + nested associations with no cascade cleanup. parameters: - description: SSP ID in: path @@ -22944,8 +23255,109 @@ paths: name: byComponentId required: true type: string - produces: - - application/json + - description: By-Component data + in: body + name: by-component + required: true + schema: + $ref: '#/definitions/oscalTypes_1_1_3.ByComponent' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ByComponent' + "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: Update a by-component within an implemented requirement + tags: + - System Security Plans + /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export: + delete: + deprecated: true + description: |- + Deletes the Export (and its Provided/Responsibilities entries) for a by-component within an implemented requirement. + + Deprecated: requirement-anchored exports are legacy. Shared responsibility is + tracked per statement — use the statement-level equivalent under + .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so + existing requirement-anchored exports can be read and wound down. + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Requirement ID + in: path + name: reqId + required: true + type: string + - description: By-Component ID + in: path + name: byComponentId + required: true + type: string + responses: + "204": + description: No Content + "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: Delete the export for a control-level by-component + tags: + - System Security Plans + get: + deprecated: true + description: |- + Retrieves the Export (with nested Provided and Responsibilities) for a by-component within an implemented requirement. + + Deprecated: requirement-anchored exports are legacy. Shared responsibility is + tracked per statement — use the statement-level equivalent under + .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so + existing requirement-anchored exports can be read and wound down. + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Requirement ID + in: path + name: reqId + required: true + type: string + - description: By-Component ID + in: path + name: byComponentId + required: true + type: string + produces: + - application/json responses: "200": description: OK @@ -22971,8 +23383,14 @@ paths: post: consumes: - application/json - description: Creates the Export for a by-component within an implemented requirement. - A by-component may have at most one Export. + deprecated: true + description: |- + Creates the Export for a by-component within an implemented requirement. A by-component may have at most one Export. + + Deprecated: requirement-anchored exports are legacy. Shared responsibility is + tracked per statement — use the statement-level equivalent under + .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so + existing requirement-anchored exports can be read and wound down. parameters: - description: SSP ID in: path @@ -23026,9 +23444,14 @@ paths: put: consumes: - application/json - description: Updates the scalar fields of an existing Export for a by-component - within an implemented requirement. Provided and Responsibilities entries are - managed via their own routes. + deprecated: true + description: |- + Updates the scalar fields of an existing Export for a by-component within an implemented requirement. Provided and Responsibilities entries are managed via their own routes. + + Deprecated: requirement-anchored exports are legacy. Shared responsibility is + tracked per statement — use the statement-level equivalent under + .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so + existing requirement-anchored exports can be read and wound down. parameters: - description: SSP ID in: path @@ -23076,11 +23499,61 @@ paths: tags: - System Security Plans ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/provided - : post: + : get: + deprecated: true + description: 'Deprecated: use the statement-level equivalent. Requirement-anchored + exports are legacy.' + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Requirement ID + in: path + name: reqId + required: true + type: string + - description: By-Component ID + in: path + name: byComponentId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_ProvidedControlImplementation' + "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: List the provided entries on a control-level by-component's export + tags: + - System Security Plans + post: consumes: - application/json - description: Creates a ProvidedControlImplementation entry under the Export - of a by-component within an implemented requirement. + deprecated: true + description: |- + Creates a ProvidedControlImplementation entry under the Export of a by-component within an implemented requirement. + + Deprecated: requirement-anchored exports are legacy. Shared responsibility is + tracked per statement — use the statement-level equivalent under + .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so + existing requirement-anchored exports can be read and wound down. parameters: - description: SSP ID in: path @@ -23129,8 +23602,14 @@ paths: - System Security Plans ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/provided/{providedId} : delete: - description: Deletes an existing ProvidedControlImplementation entry under the - Export of a by-component within an implemented requirement. + deprecated: true + description: |- + Deletes an existing ProvidedControlImplementation entry under the Export of a by-component within an implemented requirement. + + Deprecated: requirement-anchored exports are legacy. Shared responsibility is + tracked per statement — use the statement-level equivalent under + .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so + existing requirement-anchored exports can be read and wound down. parameters: - description: SSP ID in: path @@ -23175,8 +23654,14 @@ paths: put: consumes: - application/json - description: Replaces an existing ProvidedControlImplementation entry under - the Export of a by-component within an implemented requirement. + deprecated: true + description: |- + Replaces an existing ProvidedControlImplementation entry under the Export of a by-component within an implemented requirement. + + Deprecated: requirement-anchored exports are legacy. Shared responsibility is + tracked per statement — use the statement-level equivalent under + .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so + existing requirement-anchored exports can be read and wound down. parameters: - description: SSP ID in: path @@ -23229,11 +23714,61 @@ paths: tags: - System Security Plans ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/responsibilities - : post: + : get: + deprecated: true + description: 'Deprecated: use the statement-level equivalent. Requirement-anchored + exports are legacy.' + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Requirement ID + in: path + name: reqId + required: true + type: string + - description: By-Component ID + in: path + name: byComponentId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_ControlImplementationResponsibility' + "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: List the responsibility entries on a control-level by-component's export + tags: + - System Security Plans + post: consumes: - application/json - description: Creates a ControlImplementationResponsibility entry under the Export - of a by-component within an implemented requirement. + deprecated: true + description: |- + Creates a ControlImplementationResponsibility entry under the Export of a by-component within an implemented requirement. + + Deprecated: requirement-anchored exports are legacy. Shared responsibility is + tracked per statement — use the statement-level equivalent under + .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so + existing requirement-anchored exports can be read and wound down. parameters: - description: SSP ID in: path @@ -23282,8 +23817,14 @@ paths: - System Security Plans ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/responsibilities/{responsibilityId} : delete: - description: Deletes an existing ControlImplementationResponsibility entry under - the Export of a by-component within an implemented requirement. + deprecated: true + description: |- + Deletes an existing ControlImplementationResponsibility entry under the Export of a by-component within an implemented requirement. + + Deprecated: requirement-anchored exports are legacy. Shared responsibility is + tracked per statement — use the statement-level equivalent under + .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so + existing requirement-anchored exports can be read and wound down. parameters: - description: SSP ID in: path @@ -23328,8 +23869,14 @@ paths: put: consumes: - application/json - description: Replaces an existing ControlImplementationResponsibility entry - under the Export of a by-component within an implemented requirement. + deprecated: true + description: |- + Replaces an existing ControlImplementationResponsibility entry under the Export of a by-component within an implemented requirement. + + Deprecated: requirement-anchored exports are legacy. Shared responsibility is + tracked per statement — use the statement-level equivalent under + .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so + existing requirement-anchored exports can be read and wound down. parameters: - description: SSP ID in: path @@ -23565,6 +24112,51 @@ paths: tags: - System Security Plans /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components: + get: + description: |- + Returns every by-component attached to the statement, each with its export + (including provided and responsibilities), inherited and satisfied entries, + responsible-roles and implementation-status. + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Requirement ID + in: path + name: reqId + required: true + type: string + - description: Statement ID + in: path + name: stmtId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_ByComponent' + "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: List the by-components on a statement + tags: + - System Security Plans post: consumes: - application/json @@ -23581,24 +24173,610 @@ paths: name: reqId required: true type: string - - description: Statement ID + - description: Statement ID + in: path + name: stmtId + required: true + type: string + - description: By-Component data + in: body + name: by-component + required: true + schema: + $ref: '#/definitions/oscalTypes_1_1_3.ByComponent' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ByComponent' + "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' + summary: Create a by-component within a statement (within an implemented requirement) + tags: + - System Security Plans + ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId} + : delete: + consumes: + - application/json + description: Deletes a by-component within an existing statement within an implemented + requirement for a given SSP. + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Requirement ID + in: path + name: reqId + required: true + type: string + - description: Statement ID + in: path + name: stmtId + required: true + type: string + - description: By-Component ID + in: path + name: byComponentId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ByComponent' + "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' + summary: Delete a by-component within a statement (within an implemented requirement) + tags: + - System Security Plans + get: + description: |- + Returns the by-component with its export (including provided and + responsibilities), inherited and satisfied entries, responsible-roles and + implementation-status — everything needed to refetch one by-component after + editing any of its sub-resources. + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Requirement ID + in: path + name: reqId + required: true + type: string + - description: Statement ID + in: path + name: stmtId + required: true + type: string + - description: By-Component ID + in: path + name: byComponentId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ByComponent' + "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: Get a by-component within a statement + tags: + - System Security Plans + put: + consumes: + - application/json + description: |- + Updates metadata only — description, props, links, set-parameters, remarks, + implementation-status and responsible-roles. Any export, inherited or + satisfied entries in the body are IGNORED: those subtrees are managed through + their own sub-resource routes, which enforce the leverage bookkeeping this + route cannot. component-uuid is immutable. Previously this blind-Saved a + struct rebuilt from the request body, which zeroed every omitted field and + upserted nested associations with no cascade cleanup. + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Requirement ID + in: path + name: reqId + required: true + type: string + - description: Statement ID + in: path + name: stmtId + required: true + type: string + - description: By-Component ID + in: path + name: byComponentId + required: true + type: string + - description: By-Component data + in: body + name: by-component + required: true + schema: + $ref: '#/definitions/oscalTypes_1_1_3.ByComponent' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ByComponent' + "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: Update a by-component within a statement (within an implemented requirement) + tags: + - System Security Plans + ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export + : delete: + description: Deletes the Export (and its Provided/Responsibilities entries) + for a by-component within a statement. + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Requirement ID + in: path + name: reqId + required: true + type: string + - description: Statement ID + in: path + name: stmtId + required: true + type: string + - description: By-Component ID + in: path + name: byComponentId + required: true + type: string + responses: + "204": + description: No Content + "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: Delete the export for a statement-level by-component + tags: + - System Security Plans + get: + description: Retrieves the Export (with nested Provided and Responsibilities) + for a by-component within a statement. + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Requirement ID + in: path + name: reqId + required: true + type: string + - description: Statement ID + in: path + name: stmtId + required: true + type: string + - description: By-Component ID + in: path + name: byComponentId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_Export' + "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: Get the export for a statement-level by-component + tags: + - System Security Plans + post: + consumes: + - application/json + description: Creates the Export for a by-component within a statement. A by-component + may have at most one Export. + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Requirement ID + in: path + name: reqId + required: true + type: string + - description: Statement ID + in: path + name: stmtId + required: true + type: string + - description: By-Component ID + in: path + name: byComponentId + required: true + type: string + - description: Export data + in: body + name: export + required: true + schema: + $ref: '#/definitions/oscalTypes_1_1_3.Export' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_Export' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "409": + description: Conflict + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Create the export for a statement-level by-component + tags: + - System Security Plans + put: + consumes: + - application/json + description: Updates the scalar fields of an existing Export for a by-component + within a statement. Provided and Responsibilities entries are managed via + their own routes. + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Requirement ID + in: path + name: reqId + required: true + type: string + - description: Statement ID + in: path + name: stmtId + required: true + type: string + - description: By-Component ID + in: path + name: byComponentId + required: true + type: string + - description: Export data + in: body + name: export + required: true + schema: + $ref: '#/definitions/oscalTypes_1_1_3.Export' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_Export' + "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: Update the export for a statement-level by-component + tags: + - System Security Plans + ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/provided + : get: + description: Retrieves every ProvidedControlImplementation under the Export + of a by-component within a statement. + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Requirement ID + in: path + name: reqId + required: true + type: string + - description: Statement ID + in: path + name: stmtId + required: true + type: string + - description: By-Component ID + in: path + name: byComponentId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_ProvidedControlImplementation' + "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: List the provided entries on a statement-level by-component's export + tags: + - System Security Plans + post: + consumes: + - application/json + description: Creates a ProvidedControlImplementation entry under the Export + of a by-component within a statement. + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Requirement ID + in: path + name: reqId + required: true + type: string + - description: Statement ID + in: path + name: stmtId + required: true + type: string + - description: By-Component ID + in: path + name: byComponentId + required: true + type: string + - description: Provided data + in: body + name: provided + required: true + schema: + $ref: '#/definitions/oscalTypes_1_1_3.ProvidedControlImplementation' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ProvidedControlImplementation' + "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: Create a provided entry on a statement-level by-component's export + tags: + - System Security Plans + ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/provided/{providedId} + : delete: + description: Deletes an existing ProvidedControlImplementation entry under the + Export of a by-component within a statement. + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Requirement ID + in: path + name: reqId + required: true + type: string + - description: Statement ID + in: path + name: stmtId + required: true + type: string + - description: By-Component ID + in: path + name: byComponentId + required: true + type: string + - description: Provided entry ID + in: path + name: providedId + required: true + type: string + responses: + "204": + description: No Content + "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: Delete a provided entry on a statement-level by-component's export + tags: + - System Security Plans + put: + consumes: + - application/json + description: Replaces an existing ProvidedControlImplementation entry under + the Export of a by-component within a statement. + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Requirement ID + in: path + name: reqId + required: true + type: string + - description: Statement ID + in: path + name: stmtId + required: true + type: string + - description: By-Component ID + in: path + name: byComponentId + required: true + type: string + - description: Provided entry ID in: path - name: stmtId + name: providedId required: true type: string - - description: By-Component data + - description: Provided data in: body - name: by-component + name: provided required: true schema: - $ref: '#/definitions/oscalTypes_1_1_3.ByComponent' + $ref: '#/definitions/oscalTypes_1_1_3.ProvidedControlImplementation' produces: - application/json responses: - "201": - description: Created + "200": + description: OK schema: - $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ByComponent' + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ProvidedControlImplementation' "400": description: Bad Request schema: @@ -23611,15 +24789,15 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/api.Error' - summary: Create a by-component within a statement (within an implemented requirement) + security: + - OAuth2Password: [] + summary: Update a provided entry on a statement-level by-component's export tags: - System Security Plans - ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId} - : delete: - consumes: - - application/json - description: Deletes a by-component within an existing statement within an implemented - requirement for a given SSP. + ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/responsibilities + : get: + description: Retrieves every ControlImplementationResponsibility under the Export + of a by-component within a statement. parameters: - description: SSP ID in: path @@ -23647,7 +24825,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ByComponent' + $ref: '#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_ControlImplementationResponsibility' "400": description: Bad Request schema: @@ -23660,14 +24838,17 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/api.Error' - summary: Delete a by-component within a statement (within an implemented requirement) + security: + - OAuth2Password: [] + summary: List the responsibility entries on a statement-level by-component's + export tags: - System Security Plans - put: + post: consumes: - application/json - description: Updates a by-component within an existing statement within an implemented - requirement for a given SSP. + description: Creates a ControlImplementationResponsibility entry under the Export + of a by-component within a statement. parameters: - description: SSP ID in: path @@ -23689,19 +24870,19 @@ paths: name: byComponentId required: true type: string - - description: By-Component data + - description: Responsibility data in: body - name: by-component + name: responsibility required: true schema: - $ref: '#/definitions/oscalTypes_1_1_3.ByComponent' + $ref: '#/definitions/oscalTypes_1_1_3.ControlImplementationResponsibility' produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: - $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ByComponent' + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ControlImplementationResponsibility' "400": description: Bad Request schema: @@ -23714,13 +24895,15 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/api.Error' - summary: Update a by-component within a statement (within an implemented requirement) + security: + - OAuth2Password: [] + summary: Create a responsibility entry on a statement-level by-component's export tags: - System Security Plans - ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export + ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/responsibilities/{responsibilityId} : delete: - description: Deletes the Export (and its Provided/Responsibilities entries) - for a by-component within a statement. + description: Deletes an existing ControlImplementationResponsibility entry under + the Export of a by-component within a statement. parameters: - description: SSP ID in: path @@ -23742,6 +24925,11 @@ paths: name: byComponentId required: true type: string + - description: Responsibility entry ID + in: path + name: responsibilityId + required: true + type: string responses: "204": description: No Content @@ -23759,12 +24947,14 @@ paths: $ref: '#/definitions/api.Error' security: - OAuth2Password: [] - summary: Delete the export for a statement-level by-component + summary: Delete a responsibility entry on a statement-level by-component's export tags: - System Security Plans - get: - description: Retrieves the Export (with nested Provided and Responsibilities) - for a by-component within a statement. + put: + consumes: + - application/json + description: Replaces an existing ControlImplementationResponsibility entry + under the Export of a by-component within a statement. parameters: - description: SSP ID in: path @@ -23786,13 +24976,24 @@ paths: name: byComponentId required: true type: string + - description: Responsibility entry ID + in: path + name: responsibilityId + required: true + type: string + - description: Responsibility data + in: body + name: responsibility + required: true + schema: + $ref: '#/definitions/oscalTypes_1_1_3.ControlImplementationResponsibility' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_Export' + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ControlImplementationResponsibility' "400": description: Bad Request schema: @@ -23807,14 +25008,13 @@ paths: $ref: '#/definitions/api.Error' security: - OAuth2Password: [] - summary: Get the export for a statement-level by-component + summary: Update a responsibility entry on a statement-level by-component's export tags: - System Security Plans - post: - consumes: - - application/json - description: Creates the Export for a by-component within a statement. A by-component - may have at most one Export. + ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/inherited + : get: + description: Retrieves what this system consumes from an upstream under this + statement. parameters: - description: SSP ID in: path @@ -23836,19 +25036,13 @@ paths: name: byComponentId required: true type: string - - description: Export data - in: body - name: export - required: true - schema: - $ref: '#/definitions/oscalTypes_1_1_3.Export' produces: - application/json responses: - "201": - description: Created + "200": + description: OK schema: - $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_Export' + $ref: '#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_InheritedControlImplementation' "400": description: Bad Request schema: @@ -23857,25 +25051,22 @@ paths: description: Not Found schema: $ref: '#/definitions/api.Error' - "409": - description: Conflict - schema: - $ref: '#/definitions/api.Error' "500": description: Internal Server Error schema: $ref: '#/definitions/api.Error' security: - OAuth2Password: [] - summary: Create the export for a statement-level by-component + summary: List the inherited control implementations on a statement-level by-component tags: - System Security Plans - put: + post: consumes: - application/json - description: Updates the scalar fields of an existing Export for a by-component - within a statement. Provided and Responsibilities entries are managed via - their own routes. + description: |- + Records that this system consumes an upstream's provided capability under this + statement. Hand-authored entries carry no leverage link; entries created by + Subscribe do, and are protected from deletion (409) while that subscription lives. parameters: - description: SSP ID in: path @@ -23897,19 +25088,19 @@ paths: name: byComponentId required: true type: string - - description: Export data + - description: Inherited data in: body - name: export + name: inherited required: true schema: - $ref: '#/definitions/oscalTypes_1_1_3.Export' + $ref: '#/definitions/oscalTypes_1_1_3.InheritedControlImplementation' produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: - $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_Export' + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_InheritedControlImplementation' "400": description: Bad Request schema: @@ -23924,15 +25115,16 @@ paths: $ref: '#/definitions/api.Error' security: - OAuth2Password: [] - summary: Update the export for a statement-level by-component + summary: Create an inherited control implementation on a statement-level by-component tags: - System Security Plans - ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/provided - : post: - consumes: - - application/json - description: Creates a ProvidedControlImplementation entry under the Export - of a by-component within a statement. + ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/inherited/{inheritedId} + : delete: + description: |- + Hand-authored entries delete freely. An entry created by a subscription is owned + by that subscription — an SSPLeverageLink still references it, and both drift + detection and notifications read through that reference — so deleting it returns + 409; unsubscribe instead. parameters: - description: SSP ID in: path @@ -23954,19 +25146,14 @@ paths: name: byComponentId required: true type: string - - description: Provided data - in: body - name: provided + - description: Inherited ID + in: path + name: inheritedId required: true - schema: - $ref: '#/definitions/oscalTypes_1_1_3.ProvidedControlImplementation' - produces: - - application/json + type: string responses: - "201": - description: Created - schema: - $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ProvidedControlImplementation' + "204": + description: No Content "400": description: Bad Request schema: @@ -23975,19 +25162,26 @@ paths: description: Not Found schema: $ref: '#/definitions/api.Error' + "409": + description: Conflict + schema: + $ref: '#/definitions/api.Error' "500": description: Internal Server Error schema: $ref: '#/definitions/api.Error' security: - OAuth2Password: [] - summary: Create a provided entry on a statement-level by-component's export + summary: Delete an inherited control implementation on a statement-level by-component tags: - System Security Plans - ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/provided/{providedId} - : delete: - description: Deletes an existing ProvidedControlImplementation entry under the - Export of a by-component within a statement. + put: + consumes: + - application/json + description: |- + Metadata only — description, props, links and responsible-roles. provided-uuid + is immutable and a body attempting to change it is rejected with 400: it is the + identity the leverage link and the drift detector join on. parameters: - description: SSP ID in: path @@ -24009,14 +25203,24 @@ paths: name: byComponentId required: true type: string - - description: Provided entry ID + - description: Inherited ID in: path - name: providedId + name: inheritedId required: true type: string + - description: Inherited data + in: body + name: inherited + required: true + schema: + $ref: '#/definitions/oscalTypes_1_1_3.InheritedControlImplementation' + produces: + - application/json responses: - "204": - description: No Content + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_InheritedControlImplementation' "400": description: Bad Request schema: @@ -24031,14 +25235,13 @@ paths: $ref: '#/definitions/api.Error' security: - OAuth2Password: [] - summary: Delete a provided entry on a statement-level by-component's export + summary: Update an inherited control implementation on a statement-level by-component tags: - System Security Plans - put: - consumes: - - application/json - description: Replaces an existing ProvidedControlImplementation entry under - the Export of a by-component within a statement. + ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/satisfied + : get: + description: Retrieves how this system discharges its upstream's responsibilities + under this statement. parameters: - description: SSP ID in: path @@ -24060,24 +25263,13 @@ paths: name: byComponentId required: true type: string - - description: Provided entry ID - in: path - name: providedId - required: true - type: string - - description: Provided data - in: body - name: provided - required: true - schema: - $ref: '#/definitions/oscalTypes_1_1_3.ProvidedControlImplementation' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ProvidedControlImplementation' + $ref: '#/definitions/handler.GenericDataListResponse-oscalTypes_1_1_3_SatisfiedControlImplementationResponsibility' "400": description: Bad Request schema: @@ -24092,15 +25284,18 @@ paths: $ref: '#/definitions/api.Error' security: - OAuth2Password: [] - summary: Update a provided entry on a statement-level by-component's export + summary: List the satisfied responsibilities on a statement-level by-component tags: - System Security Plans - ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/responsibilities - : post: + post: consumes: - application/json - description: Creates a ControlImplementationResponsibility entry under the Export - of a by-component within a statement. + description: |- + Records that this system discharges one of its upstream's responsibilities. The + responsibility-uuid must resolve to a ControlImplementationResponsibility on an + Export this by-component actually inherits from (400 otherwise). If the owning + SSPLeverageLink exists, its cached Satisfaction is re-derived in the same + transaction, so partial can flip to full atomically with the write that causes it. parameters: - description: SSP ID in: path @@ -24122,19 +25317,19 @@ paths: name: byComponentId required: true type: string - - description: Responsibility data + - description: Satisfied data in: body - name: responsibility + name: satisfied required: true schema: - $ref: '#/definitions/oscalTypes_1_1_3.ControlImplementationResponsibility' + $ref: '#/definitions/oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ControlImplementationResponsibility' + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_SatisfiedControlImplementationResponsibility' "400": description: Bad Request schema: @@ -24149,13 +25344,16 @@ paths: $ref: '#/definitions/api.Error' security: - OAuth2Password: [] - summary: Create a responsibility entry on a statement-level by-component's export + summary: Create a satisfied responsibility on a statement-level by-component tags: - System Security Plans - ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/responsibilities/{responsibilityId} + ? /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/satisfied/{satisfiedId} : delete: - description: Deletes an existing ControlImplementationResponsibility entry under - the Export of a by-component within a statement. + description: |- + Removes the entry and, in the same transaction, re-derives the owning + SSPLeverageLink's cached Satisfaction — so dropping the last satisfied entry + flips full back to partial atomically rather than leaving the link's bookkeeping + stale for the drift detector to read. parameters: - description: SSP ID in: path @@ -24177,9 +25375,9 @@ paths: name: byComponentId required: true type: string - - description: Responsibility entry ID + - description: Satisfied ID in: path - name: responsibilityId + name: satisfiedId required: true type: string responses: @@ -24199,14 +25397,18 @@ paths: $ref: '#/definitions/api.Error' security: - OAuth2Password: [] - summary: Delete a responsibility entry on a statement-level by-component's export + summary: Delete a satisfied responsibility on a statement-level by-component tags: - System Security Plans put: consumes: - application/json - description: Replaces an existing ControlImplementationResponsibility entry - under the Export of a by-component within a statement. + description: |- + Metadata only — description, remarks, props, links and responsible-roles. + responsibility-uuid is immutable and a body attempting to change it is rejected + with 400: it is what deriveSatisfaction and the drift detector match on. Because + the identity can't change, the owning link's Satisfaction cannot change either, + so no re-derivation is needed here. parameters: - description: SSP ID in: path @@ -24228,24 +25430,24 @@ paths: name: byComponentId required: true type: string - - description: Responsibility entry ID + - description: Satisfied ID in: path - name: responsibilityId + name: satisfiedId required: true type: string - - description: Responsibility data + - description: Satisfied data in: body - name: responsibility + name: satisfied required: true schema: - $ref: '#/definitions/oscalTypes_1_1_3.ControlImplementationResponsibility' + $ref: '#/definitions/oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_ControlImplementationResponsibility' + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_SatisfiedControlImplementationResponsibility' "400": description: Bad Request schema: @@ -24260,7 +25462,7 @@ paths: $ref: '#/definitions/api.Error' security: - OAuth2Password: [] - summary: Update a responsibility entry on a statement-level by-component's export + summary: Update a satisfied responsibility on a statement-level by-component tags: - System Security Plans /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/suggest-components: @@ -25841,6 +27043,53 @@ paths: summary: Remove a Profile binding from an SSP tags: - System Security Plans + /oscal/system-security-plans/{id}/shared-responsibility: + get: + description: |- + A flat, statement-keyed projection of this SSP's shared-responsibility posture: + what it exports (with the provided capabilities and the responsibilities it + pushes onto consumers, and whether each is actually offered), what it inherits + from upstreams (with live-recomputed satisfaction and link status), how it + discharges upstream responsibilities, and any legacy requirement-anchored + by-components still to be migrated. + + The inherits arm reuses the same batched projection GET /leveraged-controls + serves, so the two can never disagree about satisfaction. Optionally filter the + whole rollup to one control with controlId. + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Only include rows for this control + in: query + name: controlId + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-oscal_SharedResponsibilityRollup' + "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: Roll up everything one SSP provides, inherits and satisfies + tags: + - System Security Plans /oscal/system-security-plans/{id}/system-characteristics: get: description: Retrieves the System Characteristics for a given System Security diff --git a/internal/api/handler/oscal/import.go b/internal/api/handler/oscal/import.go index 3f0aa2a0..913874b3 100644 --- a/internal/api/handler/oscal/import.go +++ b/internal/api/handler/oscal/import.go @@ -97,6 +97,33 @@ func (h *ImportHandler) ImportOSCAL(ctx echo.Context) error { return ctx.JSON(statusCode, handler.GenericDataResponse[ImportResponse]{Data: response}) } +// validateSSPByComponentImplementationStatuses runs the same implementation-status validation +// every by-component write path performs over an SSP's whole control-implementation tree, at +// both anchoring levels. Import bypassed it entirely: POST /api/oscal/import unmarshals the +// document and hands it straight to FirstOrCreate, so an invalid state (anything outside +// implemented/partial/planned/alternative/not-applicable) sailed in and poisoned the tree. +func validateSSPByComponentImplementationStatuses(ssp *relational.SystemSecurityPlan) error { + for i := range ssp.ControlImplementation.ImplementedRequirements { + requirement := &ssp.ControlImplementation.ImplementedRequirements[i] + + for j := range requirement.ByComponents { + if err := validateByComponentImplementationStatus(&requirement.ByComponents[j]); err != nil { + return fmt.Errorf("control %q: %w", requirement.ControlId, err) + } + } + + for j := range requirement.Statements { + statement := &requirement.Statements[j] + for k := range statement.ByComponents { + if err := validateByComponentImplementationStatus(&statement.ByComponents[k]); err != nil { + return fmt.Errorf("control %q statement %q: %w", requirement.ControlId, statement.StatementId, err) + } + } + } + } + return nil +} + func (h *ImportHandler) processOSCALFile(fileHeader *multipart.FileHeader) ImportFileResult { result := ImportFileResult{ Filename: fileHeader.Filename, @@ -170,6 +197,16 @@ func (h *ImportHandler) processOSCALFile(fileHeader *multipart.FileHeader) Impor if input.SystemSecurityPlan != nil { def := &relational.SystemSecurityPlan{} def.UnmarshalOscal(*input.SystemSecurityPlan) + + // Import is the one write path into the SSP tree that never went through + // validateByComponentImplementationStatus, so a bad implementation-status could be + // imported straight into the tree and only blow up later, at read time. Reject it + // here instead. + if err := validateSSPByComponentImplementationStatuses(def); err != nil { + result.Message = fmt.Sprintf("Failed to import system security plan: %v", err) + return result + } + out := h.db.FirstOrCreate(def) if out.Error != nil { result.Message = fmt.Sprintf("Failed to import system security plan: %v", out.Error) @@ -178,7 +215,19 @@ func (h *ImportHandler) processOSCALFile(fileHeader *multipart.FileHeader) Impor result.Type = "System Security Plan" result.Title = def.Metadata.Title result.Success = true - result.Message = "Successfully imported system security plan" + // FirstOrCreate is keyed on the OSCAL UUID, so re-importing a changed SSP under an + // existing UUID matches the stored row and writes nothing at all. Reporting that as + // "Successfully imported" told the caller their edits had landed when they hadn't. + // This path still reports Success (the file was processed and no error occurred — + // flipping it would turn an idempotent re-import into a 400), but says plainly that + // nothing was written. A real merge/diff import is its own piece of work. + if out.RowsAffected == 0 { + result.Message = fmt.Sprintf( + "System security plan %s already exists; nothing was written (import does not merge into an existing UUID)", + input.SystemSecurityPlan.UUID) + } else { + result.Message = "Successfully imported system security plan" + } imported = true } diff --git a/internal/api/handler/oscal/import_integration_test.go b/internal/api/handler/oscal/import_integration_test.go new file mode 100644 index 00000000..bb2eb0fa --- /dev/null +++ b/internal/api/handler/oscal/import_integration_test.go @@ -0,0 +1,199 @@ +//go:build integration + +package oscal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/suite" + "go.uber.org/zap" + + "github.com/compliance-framework/api/internal/api" + "github.com/compliance-framework/api/internal/api/handler" + evidencesvc "github.com/compliance-framework/api/internal/service/relational/evidence" + "github.com/compliance-framework/api/internal/tests" +) + +type ImportApiIntegrationSuite struct { + tests.IntegrationTestSuite +} + +func (suite *ImportApiIntegrationSuite) SetupSuite() { + suite.IntegrationTestSuite.SetupSuite() +} + +func TestImportApiIntegrationSuite(t *testing.T) { + suite.Run(t, new(ImportApiIntegrationSuite)) +} + +// importSSPDocument posts one SSP JSON document to /api/oscal/import and returns the parsed +// per-file result. +func (suite *ImportApiIntegrationSuite) importSSPDocument(server *api.Server, document string) (ImportFileResult, int) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("files", "ssp.json") + suite.Require().NoError(err) + _, err = part.Write([]byte(document)) + suite.Require().NoError(err) + suite.Require().NoError(writer.Close()) + + req := httptest.NewRequest("POST", "/api/oscal/import", body) + req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) + token, err := suite.GetAuthToken() + suite.Require().NoError(err) + req.Header.Set(echo.HeaderAuthorization, fmt.Sprintf("Bearer %s", *token)) + + rec := httptest.NewRecorder() + server.E().ServeHTTP(rec, req) + + var resp handler.GenericDataResponse[ImportResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + suite.Require().Len(resp.Data.Results, 1) + return resp.Data.Results[0], rec.Code +} + +// sspDocument renders a minimal but complete SSP with one statement-anchored by-component +// carrying the given implementation-status state. +func sspDocument(sspUUID, title, implementationState string) string { + return fmt.Sprintf(`{ + "system-security-plan": { + "uuid": %q, + "metadata": { + "title": %q, + "last-modified": "2024-01-01T00:00:00Z", + "version": "1.0.0", + "oscal-version": "1.1.3" + }, + "import-profile": { "href": "#profile" }, + "system-characteristics": { + "system-ids": [{ "id": "sys-1" }], + "system-name": "Test System", + "description": "d", + "security-sensitivity-level": "low", + "system-information": { "information-types": [] }, + "security-impact-level": { + "security-objective-confidentiality": "low", + "security-objective-integrity": "low", + "security-objective-availability": "low" + }, + "status": { "state": "operational" }, + "authorization-boundary": { "description": "b" } + }, + "system-implementation": { + "users": [], + "components": [{ + "uuid": %q, + "type": "software", + "title": "Component", + "description": "d", + "status": { "state": "operational" } + }] + }, + "control-implementation": { + "description": "ci", + "implemented-requirements": [{ + "uuid": %q, + "control-id": "ac-2", + "statements": [{ + "uuid": %q, + "statement-id": "ac-2_smt.a", + "by-components": [{ + "uuid": %q, + "component-uuid": %q, + "description": "bc", + "implementation-status": { "state": %q } + }] + }] + }] + } + } + }`, + sspUUID, title, + uuid.New().String(), + uuid.New().String(), + uuid.New().String(), + uuid.New().String(), + uuid.New().String(), + implementationState, + ) +} + +func (suite *ImportApiIntegrationSuite) newServer() *api.Server { + logConf := zap.NewDevelopmentConfig() + logConf.Level = zap.NewAtomicLevelAt(zap.ErrorLevel) + logger, _ := logConf.Build() + + suite.Require().NoError(suite.Migrator.Refresh()) + + metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) + server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) + evidenceSvc := evidencesvc.NewEvidenceService(suite.DB, logger.Sugar(), suite.Config, nil) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, evidenceSvc, nil, nil) + return server +} + +// TestImportRejectsInvalidImplementationStatus: import was the one write path into the SSP tree +// that never ran validateByComponentImplementationStatus, so a bad state sailed straight into +// the database via FirstOrCreate. It is now rejected. +func (suite *ImportApiIntegrationSuite) TestImportRejectsInvalidImplementationStatus() { + server := suite.newServer() + + result, code := suite.importSSPDocument(server, sspDocument(uuid.New().String(), "Bad SSP", "totally-made-up")) + + suite.Equal(http.StatusBadRequest, code, "the only file failed, so the whole import is a 400") + suite.False(result.Success) + suite.Contains(result.Message, "totally-made-up") + + var count int64 + suite.Require().NoError(suite.DB.Table("system_security_plans").Count(&count).Error) + suite.Zero(count, "nothing was written") +} + +// TestImportAcceptsValidImplementationStatus is the control for the test above: a valid state +// still imports. +func (suite *ImportApiIntegrationSuite) TestImportAcceptsValidImplementationStatus() { + server := suite.newServer() + + result, code := suite.importSSPDocument(server, sspDocument(uuid.New().String(), "Good SSP", "implemented")) + + suite.Equal(http.StatusOK, code) + suite.True(result.Success) + suite.Equal("Successfully imported system security plan", result.Message) + suite.Equal("System Security Plan", result.Type) +} + +// TestImportReportsNoOpOnExistingUUID: FirstOrCreate is keyed on the OSCAL UUID, so re-importing +// a *changed* SSP under an existing UUID matches the stored row and writes nothing at all. +// Reporting that as "Successfully imported" told the caller their edits had landed when they +// hadn't. The result now says plainly that nothing was written. +func (suite *ImportApiIntegrationSuite) TestImportReportsNoOpOnExistingUUID() { + server := suite.newServer() + sspUUID := uuid.New().String() + + first, code := suite.importSSPDocument(server, sspDocument(sspUUID, "Original title", "implemented")) + suite.Require().Equal(http.StatusOK, code) + suite.Require().True(first.Success) + suite.Equal("Successfully imported system security plan", first.Message) + + // Same UUID, different content: FirstOrCreate matches and writes nothing. + second, code := suite.importSSPDocument(server, sspDocument(sspUUID, "Revised title", "partial")) + suite.Equal(http.StatusOK, code) + suite.True(second.Success, "processing the file did not error — but nothing was written") + suite.Contains(second.Message, "already exists") + suite.Contains(second.Message, "nothing was written") + suite.NotContains(second.Message, "Successfully imported") + + // Proof the no-op really was a no-op: still one SSP, still the original title. + var count int64 + suite.Require().NoError(suite.DB.Table("system_security_plans").Count(&count).Error) + suite.Equal(int64(1), count) +} diff --git a/internal/api/handler/oscal/import_unit_test.go b/internal/api/handler/oscal/import_unit_test.go new file mode 100644 index 00000000..6e1aad3e --- /dev/null +++ b/internal/api/handler/oscal/import_unit_test.go @@ -0,0 +1,161 @@ +package oscal + +import ( + "testing" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "gorm.io/datatypes" + + "github.com/compliance-framework/api/internal/api/middleware" + "github.com/compliance-framework/api/internal/authz" + "github.com/compliance-framework/api/internal/service/relational" +) + +func implementationStatus(state string) datatypes.JSONType[relational.ImplementationStatus] { + return datatypes.NewJSONType(relational.ImplementationStatus{ + State: relational.ImplementationStatusState(state), + }) +} + +// registeredSSPRoutes returns every route the SSP handler mounts, as "METHOD /path". Route +// existence — and, just as importantly, route *absence* — is part of this feature's contract: +// the whole point of anchoring on the statement is that no requirement-level creation surface +// exists to re-open the hole. +func registeredSSPRoutes(t *testing.T) []string { + t.Helper() + + pep := middleware.NewPEP(&stubPDP{allow: true}, authz.FailClosed, zap.NewNop().Sugar()) + e := echo.New() + NewSystemSecurityPlanHandler(zap.NewNop().Sugar(), nil, nil, nil). + Register(e.Group("/api/oscal/system-security-plans"), pep.For(authz.ResourceSSP)) + + routes := make([]string, 0, len(e.Routes())) + for _, route := range e.Routes() { + routes = append(routes, route.Method+" "+route.Path) + } + return routes +} + +// TestValidateSSPByComponentImplementationStatuses: POST /api/oscal/import unmarshalled the +// document and handed it straight to FirstOrCreate, so an invalid implementation-status was +// never rejected and poisoned the tree. It is now validated at both anchoring levels. +func TestValidateSSPByComponentImplementationStatuses(t *testing.T) { + newSSP := func(requirementStatus, statementStatus string) *relational.SystemSecurityPlan { + requirement := relational.ImplementedRequirement{ControlId: "ac-2"} + + if requirementStatus != "" { + requirement.ByComponents = []relational.ByComponent{{ + ComponentUUID: uuid.New(), + ImplementationStatus: implementationStatus(requirementStatus), + }} + } + if statementStatus != "" { + requirement.Statements = []relational.Statement{{ + StatementId: "ac-2_smt.a", + ByComponents: []relational.ByComponent{{ + ComponentUUID: uuid.New(), + ImplementationStatus: implementationStatus(statementStatus), + }}, + }} + } + + return &relational.SystemSecurityPlan{ + ControlImplementation: relational.ControlImplementation{ + ImplementedRequirements: []relational.ImplementedRequirement{requirement}, + }, + } + } + + require.NoError(t, validateSSPByComponentImplementationStatuses(newSSP("implemented", "partial"))) + + // An SSP with no implementation-status at all is fine — the field is optional. + require.NoError(t, validateSSPByComponentImplementationStatuses(newSSP("", ""))) + + err := validateSSPByComponentImplementationStatuses(newSSP("totally-made-up", "")) + require.ErrorContains(t, err, "ac-2") + require.ErrorContains(t, err, "totally-made-up") + + err = validateSSPByComponentImplementationStatuses(newSSP("", "not-a-state")) + require.ErrorContains(t, err, "ac-2_smt.a") + require.ErrorContains(t, err, "not-a-state") +} + +// TestValidateSSPByComponentImplementationStatusesAcceptsEveryValidState guards the enum the +// import path now depends on. +func TestValidateSSPByComponentImplementationStatusesAcceptsEveryValidState(t *testing.T) { + for _, state := range relational.ValidImplementationStatusStates() { + ssp := &relational.SystemSecurityPlan{ + ControlImplementation: relational.ControlImplementation{ + ImplementedRequirements: []relational.ImplementedRequirement{{ + ControlId: "ac-2", + ByComponents: []relational.ByComponent{{ + ComponentUUID: uuid.New(), + ImplementationStatus: implementationStatus(string(state)), + }}, + }}, + }, + } + require.NoErrorf(t, validateSSPByComponentImplementationStatuses(ssp), "state %q must be accepted", state) + } +} + +// TestNoRequirementLevelByComponentPost: the statement is the canonical anchor, so there must be +// no way to create a *new* requirement-anchored by-component. The requirement-level surface is +// read/update/delete only — enough to wind legacy rows down, and nothing more. +func TestNoRequirementLevelByComponentPost(t *testing.T) { + const ( + requirementByComponents = "/api/oscal/system-security-plans/:id/control-implementation/implemented-requirements/:reqId/by-components" + statementByComponents = "/api/oscal/system-security-plans/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components" + ) + + routes := registeredSSPRoutes(t) + + require.NotContains(t, routes, "POST "+requirementByComponents, + "a requirement-level by-component POST would re-open exactly the hole this work closes") + require.NotContains(t, routes, "POST "+requirementByComponents+"/:byComponentId") + + // The legacy read/update/delete surface stays, so existing rows remain manageable. + require.Contains(t, routes, "GET "+requirementByComponents+"/:byComponentId") + require.Contains(t, routes, "PUT "+requirementByComponents+"/:byComponentId") + require.Contains(t, routes, "DELETE "+requirementByComponents+"/:byComponentId") + + // Creation happens at the statement level only. + require.Contains(t, routes, "POST "+statementByComponents) +} + +// TestStatementLevelInheritedAndSatisfiedRoutesExist: the consumer-side CRUD exists, and only at +// the statement level. +func TestStatementLevelInheritedAndSatisfiedRoutesExist(t *testing.T) { + const ( + statementBC = "/api/oscal/system-security-plans/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId" + requirementBC = "/api/oscal/system-security-plans/:id/control-implementation/implemented-requirements/:reqId/by-components/:byComponentId" + ) + + routes := registeredSSPRoutes(t) + + for _, method := range []string{"GET", "POST"} { + require.Contains(t, routes, method+" "+statementBC+"/inherited") + require.Contains(t, routes, method+" "+statementBC+"/satisfied") + } + for _, method := range []string{"PUT", "DELETE"} { + require.Contains(t, routes, method+" "+statementBC+"/inherited/:inheritedId") + require.Contains(t, routes, method+" "+statementBC+"/satisfied/:satisfiedId") + } + + // Inherited/Satisfied describe the downstream half of the export -> inherit -> satisfy loop, + // which is anchored on a statement — there is deliberately no requirement-level surface. + for _, suffix := range []string{"/inherited", "/satisfied"} { + for _, method := range []string{"GET", "POST", "PUT", "DELETE"} { + require.NotContains(t, routes, method+" "+requirementBC+suffix) + } + } +} + +// TestSharedResponsibilityRouteRegistered: the rollup the Controls page reads. +func TestSharedResponsibilityRouteRegistered(t *testing.T) { + require.Contains(t, registeredSSPRoutes(t), + "GET /api/oscal/system-security-plans/:id/shared-responsibility") +} diff --git a/internal/api/handler/oscal/ssp_by_components.go b/internal/api/handler/oscal/ssp_by_components.go new file mode 100644 index 00000000..9e6cb0e5 --- /dev/null +++ b/internal/api/handler/oscal/ssp_by_components.go @@ -0,0 +1,1001 @@ +package oscal + +import ( + "errors" + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "gorm.io/gorm" + + "github.com/compliance-framework/api/internal/api" + "github.com/compliance-framework/api/internal/api/handler" + "github.com/compliance-framework/api/internal/service/relational" + oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" +) + +// This file holds the by-component read surface and the consumer-side (Inherited/Satisfied) +// CRUD. Both were missing entirely: there were no GET routes for by-components at either +// level, so the UI could never refetch one after editing a sub-resource, and Inherited and +// Satisfied were written *only* by Subscribe — a downstream author could not mark a +// responsibility satisfied later, edit a description, attach responsible-roles, or drop a +// stale inherited entry without re-subscribing. +// +// Inherited/Satisfied are statement-level only, on purpose: they are the downstream half of +// the export -> inherit -> satisfy loop, and that loop is anchored on a statement. + +// errInheritedOwnedBySubscription is returned when a delete targets an InheritedControlImplementation +// that an SSPLeverageLink still references. Such a row is owned by its subscription, not by +// the author — removing it would leave the link pointing at nothing, and both the drift +// detector and the notification path read through link.InheritedUUID. Mapped to 409, with the +// unsubscribe path as the way out. Hand-authored inherited entries (no link row) delete freely. +var errInheritedOwnedBySubscription = errors.New("inherited entry is owned by a leverage subscription; unsubscribe instead of deleting it") + +// GetImplementedRequirementByComponent godoc +// +// @Summary Get a by-component within an implemented requirement +// @Description Deprecated: requirement-anchored by-components are legacy — read-only here so +// @Description the UI can display and wind them down. New by-components must be created +// @Description against a statement. +// @Description +// @Description Returns the by-component with its export (including provided and +// @Description responsibilities), inherited and satisfied entries, responsible-roles and +// @Description implementation-status. +// @Tags System Security Plans +// @Produce json +// @Param id path string true "SSP ID" +// @Param reqId path string true "Requirement ID" +// @Param byComponentId path string true "By-Component ID" +// @Success 200 {object} handler.GenericDataResponse[oscalTypes_1_1_3.ByComponent] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Deprecated +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId} [get] +func (h *SystemSecurityPlanHandler) GetImplementedRequirementByComponent(ctx echo.Context) error { + bc, ok := h.resolveByComponentForRequirement(ctx) + if !ok { + return nil + } + return h.getByComponent(ctx, bc) +} + +// GetImplementedRequirementStatementByComponent godoc +// +// @Summary Get a by-component within a statement +// @Description Returns the by-component with its export (including provided and +// @Description responsibilities), inherited and satisfied entries, responsible-roles and +// @Description implementation-status — everything needed to refetch one by-component after +// @Description editing any of its sub-resources. +// @Tags System Security Plans +// @Produce json +// @Param id path string true "SSP ID" +// @Param reqId path string true "Requirement ID" +// @Param stmtId path string true "Statement ID" +// @Param byComponentId path string true "By-Component ID" +// @Success 200 {object} handler.GenericDataResponse[oscalTypes_1_1_3.ByComponent] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId} [get] +func (h *SystemSecurityPlanHandler) GetImplementedRequirementStatementByComponent(ctx echo.Context) error { + bc, ok := h.resolveByComponentForStatement(ctx) + if !ok { + return nil + } + return h.getByComponent(ctx, bc) +} + +// getByComponent renders one already-resolved by-component with every subtree preloaded. +func (h *SystemSecurityPlanHandler) getByComponent(ctx echo.Context, bc *relational.ByComponent) error { + loaded, err := h.reloadByComponent(*bc.ID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("by-component not found"))) + } + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + return ctx.JSON(http.StatusOK, handler.GenericDataResponse[oscalTypes_1_1_3.ByComponent]{Data: *loaded.MarshalOscal()}) +} + +// GetImplementedRequirementStatementByComponents godoc +// +// @Summary List the by-components on a statement +// @Description Returns every by-component attached to the statement, each with its export +// @Description (including provided and responsibilities), inherited and satisfied entries, +// @Description responsible-roles and implementation-status. +// @Tags System Security Plans +// @Produce json +// @Param id path string true "SSP ID" +// @Param reqId path string true "Requirement ID" +// @Param stmtId path string true "Statement ID" +// @Success 200 {object} handler.GenericDataListResponse[oscalTypes_1_1_3.ByComponent] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components [get] +func (h *SystemSecurityPlanHandler) GetImplementedRequirementStatementByComponents(ctx echo.Context) error { + stmt, ok := h.resolveStatement(ctx) + if !ok { + return nil + } + + var byComponents []relational.ByComponent + if err := h.db. + Preload("ResponsibleRoles.Parties"). + Preload("Inherited.ResponsibleRoles.Parties"). + Preload("Satisfied.ResponsibleRoles.Parties"). + Preload("Export.Provided.ResponsibleRoles.Parties"). + Preload("Export.Responsibilities.ResponsibleRoles.Parties"). + Where("parent_id = ? AND parent_type = ?", stmt.ID, "statements"). + Order("id ASC"). + Find(&byComponents).Error; err != nil { + h.sugar.Errorf("Failed to list by-components: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + result := make([]oscalTypes_1_1_3.ByComponent, 0, len(byComponents)) + for i := range byComponents { + result = append(result, *byComponents[i].MarshalOscal()) + } + return ctx.JSON(http.StatusOK, handler.GenericDataListResponse[oscalTypes_1_1_3.ByComponent]{Data: result}) +} + +// resolveStatement parses and verifies a statement belongs to a requirement of the SSP named +// in the path, writing the appropriate error response and returning ok=false on any failure — +// the statement-level sibling of resolveByComponentForStatement, for routes that address a +// statement rather than one by-component under it. +func (h *SystemSecurityPlanHandler) resolveStatement(ctx echo.Context) (stmt *relational.Statement, ok bool) { + sspID, reqID, stmtID, err := parseSSPReqStmtIDs(ctx) + if err != nil { + h.sugar.Warnw("Invalid statement path params", "error", err) + _ = ctx.JSON(http.StatusBadRequest, api.NewError(err)) + return nil, false + } + + var ssp relational.SystemSecurityPlan + if err := h.db.Preload("ControlImplementation").First(&ssp, "id = ?", sspID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + _ = ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("SSP not found"))) + return nil, false + } + _ = ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + return nil, false + } + + var req relational.ImplementedRequirement + if err := h.db.Where("id = ? AND control_implementation_id = ?", reqID, ssp.ControlImplementation.ID). + First(&req).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + _ = ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("requirement not found"))) + return nil, false + } + _ = ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + return nil, false + } + + var found relational.Statement + if err := h.db.Where("id = ? AND implemented_requirement_id = ?", stmtID, req.ID). + First(&found).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + _ = ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("statement not found"))) + return nil, false + } + _ = ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + return nil, false + } + + return &found, true +} + +// DeleteImplementedRequirementByComponent godoc +// +// @Summary Delete a by-component within an implemented requirement +// @Description Deprecated: requirement-anchored by-components are legacy. This delete exists +// @Description purely so legacy and orphaned requirement-anchored rows can be wound down — +// @Description there is deliberately no requirement-level POST to create new ones. +// @Description +// @Description Cascades exactly like the statement-level delete: the by-component's own +// @Description responsible-roles, its inherited and satisfied entries (each with their +// @Description responsible-roles), and its export with nested provided/responsibilities are all +// @Description removed, along with the responsible_role_parties join rows. +// @Tags System Security Plans +// @Param id path string true "SSP ID" +// @Param reqId path string true "Requirement ID" +// @Param byComponentId path string true "By-Component ID" +// @Success 204 "No Content" +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Deprecated +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId} [delete] +func (h *SystemSecurityPlanHandler) DeleteImplementedRequirementByComponent(ctx echo.Context) error { + bc, ok := h.resolveByComponentForRequirement(ctx) + if !ok { + return nil + } + + if err := h.db.Transaction(func(tx *gorm.DB) error { + return deleteByComponentCascade(tx, *bc.ID) + }); err != nil { + h.sugar.Errorf("Failed to delete by-component: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + return ctx.NoContent(http.StatusNoContent) +} + +// getByComponentExportProvided lists the Provided entries under an already-resolved +// by-component's Export. +func (h *SystemSecurityPlanHandler) getByComponentExportProvided(ctx echo.Context, bc *relational.ByComponent) error { + export, ok := h.findExportForByComponent(ctx, bc) + if !ok { + return nil + } + + var provided []relational.ProvidedControlImplementation + if err := h.db. + Preload("ResponsibleRoles.Parties"). + Where("export_id = ?", export.ID). + Order("id ASC"). + Find(&provided).Error; err != nil { + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + result := make([]oscalTypes_1_1_3.ProvidedControlImplementation, 0, len(provided)) + for i := range provided { + result = append(result, *provided[i].MarshalOscal()) + } + return ctx.JSON(http.StatusOK, handler.GenericDataListResponse[oscalTypes_1_1_3.ProvidedControlImplementation]{Data: result}) +} + +// getByComponentExportResponsibilities lists the ControlImplementationResponsibility entries +// under an already-resolved by-component's Export. +func (h *SystemSecurityPlanHandler) getByComponentExportResponsibilities(ctx echo.Context, bc *relational.ByComponent) error { + export, ok := h.findExportForByComponent(ctx, bc) + if !ok { + return nil + } + + var responsibilities []relational.ControlImplementationResponsibility + if err := h.db. + Preload("ResponsibleRoles.Parties"). + Where("export_id = ?", export.ID). + Order("id ASC"). + Find(&responsibilities).Error; err != nil { + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + result := make([]oscalTypes_1_1_3.ControlImplementationResponsibility, 0, len(responsibilities)) + for i := range responsibilities { + result = append(result, *responsibilities[i].MarshalOscal()) + } + return ctx.JSON(http.StatusOK, handler.GenericDataListResponse[oscalTypes_1_1_3.ControlImplementationResponsibility]{Data: result}) +} + +// GetImplementedRequirementByComponentExportProvided godoc +// +// @Summary List the provided entries on a control-level by-component's export +// @Description Deprecated: use the statement-level equivalent. Requirement-anchored exports are legacy. +// @Tags System Security Plans +// @Produce json +// @Param id path string true "SSP ID" +// @Param reqId path string true "Requirement ID" +// @Param byComponentId path string true "By-Component ID" +// @Success 200 {object} handler.GenericDataListResponse[oscalTypes_1_1_3.ProvidedControlImplementation] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Deprecated +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/provided [get] +func (h *SystemSecurityPlanHandler) GetImplementedRequirementByComponentExportProvided(ctx echo.Context) error { + bc, ok := h.resolveByComponentForRequirement(ctx) + if !ok { + return nil + } + return h.getByComponentExportProvided(ctx, bc) +} + +// GetImplementedRequirementByComponentExportResponsibilities godoc +// +// @Summary List the responsibility entries on a control-level by-component's export +// @Description Deprecated: use the statement-level equivalent. Requirement-anchored exports are legacy. +// @Tags System Security Plans +// @Produce json +// @Param id path string true "SSP ID" +// @Param reqId path string true "Requirement ID" +// @Param byComponentId path string true "By-Component ID" +// @Success 200 {object} handler.GenericDataListResponse[oscalTypes_1_1_3.ControlImplementationResponsibility] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Deprecated +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/responsibilities [get] +func (h *SystemSecurityPlanHandler) GetImplementedRequirementByComponentExportResponsibilities(ctx echo.Context) error { + bc, ok := h.resolveByComponentForRequirement(ctx) + if !ok { + return nil + } + return h.getByComponentExportResponsibilities(ctx, bc) +} + +// GetImplementedRequirementStatementByComponentExportProvided godoc +// +// @Summary List the provided entries on a statement-level by-component's export +// @Description Retrieves every ProvidedControlImplementation under the Export of a by-component within a statement. +// @Tags System Security Plans +// @Produce json +// @Param id path string true "SSP ID" +// @Param reqId path string true "Requirement ID" +// @Param stmtId path string true "Statement ID" +// @Param byComponentId path string true "By-Component ID" +// @Success 200 {object} handler.GenericDataListResponse[oscalTypes_1_1_3.ProvidedControlImplementation] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/provided [get] +func (h *SystemSecurityPlanHandler) GetImplementedRequirementStatementByComponentExportProvided(ctx echo.Context) error { + bc, ok := h.resolveByComponentForStatement(ctx) + if !ok { + return nil + } + return h.getByComponentExportProvided(ctx, bc) +} + +// GetImplementedRequirementStatementByComponentExportResponsibilities godoc +// +// @Summary List the responsibility entries on a statement-level by-component's export +// @Description Retrieves every ControlImplementationResponsibility under the Export of a by-component within a statement. +// @Tags System Security Plans +// @Produce json +// @Param id path string true "SSP ID" +// @Param reqId path string true "Requirement ID" +// @Param stmtId path string true "Statement ID" +// @Param byComponentId path string true "By-Component ID" +// @Success 200 {object} handler.GenericDataListResponse[oscalTypes_1_1_3.ControlImplementationResponsibility] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/export/responsibilities [get] +func (h *SystemSecurityPlanHandler) GetImplementedRequirementStatementByComponentExportResponsibilities(ctx echo.Context) error { + bc, ok := h.resolveByComponentForStatement(ctx) + if !ok { + return nil + } + return h.getByComponentExportResponsibilities(ctx, bc) +} + +// GetImplementedRequirementStatementByComponentInherited godoc +// +// @Summary List the inherited control implementations on a statement-level by-component +// @Description Retrieves what this system consumes from an upstream under this statement. +// @Tags System Security Plans +// @Produce json +// @Param id path string true "SSP ID" +// @Param reqId path string true "Requirement ID" +// @Param stmtId path string true "Statement ID" +// @Param byComponentId path string true "By-Component ID" +// @Success 200 {object} handler.GenericDataListResponse[oscalTypes_1_1_3.InheritedControlImplementation] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/inherited [get] +func (h *SystemSecurityPlanHandler) GetImplementedRequirementStatementByComponentInherited(ctx echo.Context) error { + bc, ok := h.resolveByComponentForStatement(ctx) + if !ok { + return nil + } + + var inherited []relational.InheritedControlImplementation + if err := h.db. + Preload("ResponsibleRoles.Parties"). + Where("by_component_id = ?", bc.ID). + Order("id ASC"). + Find(&inherited).Error; err != nil { + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + result := make([]oscalTypes_1_1_3.InheritedControlImplementation, 0, len(inherited)) + for i := range inherited { + result = append(result, *inherited[i].MarshalOscal()) + } + return ctx.JSON(http.StatusOK, handler.GenericDataListResponse[oscalTypes_1_1_3.InheritedControlImplementation]{Data: result}) +} + +// CreateImplementedRequirementStatementByComponentInherited godoc +// +// @Summary Create an inherited control implementation on a statement-level by-component +// @Description Records that this system consumes an upstream's provided capability under this +// @Description statement. Hand-authored entries carry no leverage link; entries created by +// @Description Subscribe do, and are protected from deletion (409) while that subscription lives. +// @Tags System Security Plans +// @Accept json +// @Produce json +// @Param id path string true "SSP ID" +// @Param reqId path string true "Requirement ID" +// @Param stmtId path string true "Statement ID" +// @Param byComponentId path string true "By-Component ID" +// @Param inherited body oscalTypes_1_1_3.InheritedControlImplementation true "Inherited data" +// @Success 201 {object} handler.GenericDataResponse[oscalTypes_1_1_3.InheritedControlImplementation] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/inherited [post] +func (h *SystemSecurityPlanHandler) CreateImplementedRequirementStatementByComponentInherited(ctx echo.Context) error { + bc, ok := h.resolveByComponentForStatement(ctx) + if !ok { + return nil + } + + var oscalInherited oscalTypes_1_1_3.InheritedControlImplementation + if err := ctx.Bind(&oscalInherited); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := ensureBodyUUID(&oscalInherited.UUID); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if oscalInherited.ProvidedUuid == "" { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("provided-uuid is required"))) + } + if _, err := uuid.Parse(oscalInherited.ProvidedUuid); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("provided-uuid must be a valid UUID"))) + } + + relInherited := &relational.InheritedControlImplementation{} + relInherited.UnmarshalOscal(oscalInherited) + relInherited.ByComponentId = *bc.ID + + if err := h.db.Transaction(func(tx *gorm.DB) error { + // Same advisory-lock guard the Export create uses: nothing in the schema stops two + // concurrent creates from racing on the same by-component, and the satisfaction + // re-derivation below reads the rows this write produces. + if err := lockByComponentExportCreate(tx, *bc.ID); err != nil { + return err + } + return tx.Create(relInherited).Error + }); err != nil { + h.sugar.Errorf("Failed to create inherited control implementation: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + created, err := h.reloadInherited(*relInherited.ID) + if err != nil { + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + return ctx.JSON(http.StatusCreated, handler.GenericDataResponse[oscalTypes_1_1_3.InheritedControlImplementation]{Data: *created.MarshalOscal()}) +} + +// UpdateImplementedRequirementStatementByComponentInherited godoc +// +// @Summary Update an inherited control implementation on a statement-level by-component +// @Description Metadata only — description, props, links and responsible-roles. provided-uuid +// @Description is immutable and a body attempting to change it is rejected with 400: it is the +// @Description identity the leverage link and the drift detector join on. +// @Tags System Security Plans +// @Accept json +// @Produce json +// @Param id path string true "SSP ID" +// @Param reqId path string true "Requirement ID" +// @Param stmtId path string true "Statement ID" +// @Param byComponentId path string true "By-Component ID" +// @Param inheritedId path string true "Inherited ID" +// @Param inherited body oscalTypes_1_1_3.InheritedControlImplementation true "Inherited data" +// @Success 200 {object} handler.GenericDataResponse[oscalTypes_1_1_3.InheritedControlImplementation] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/inherited/{inheritedId} [put] +func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementStatementByComponentInherited(ctx echo.Context) error { + bc, ok := h.resolveByComponentForStatement(ctx) + if !ok { + return nil + } + + inheritedID, err := uuid.Parse(ctx.Param("inheritedId")) + if err != nil { + h.sugar.Warnw("Invalid inherited id", "inheritedId", ctx.Param("inheritedId"), "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + var existing relational.InheritedControlImplementation + if err := h.db.Where("id = ? AND by_component_id = ?", inheritedID, bc.ID).First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("inherited entry not found"))) + } + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + var oscalInherited oscalTypes_1_1_3.InheritedControlImplementation + if err := ctx.Bind(&oscalInherited); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if oscalInherited.ProvidedUuid != "" && oscalInherited.ProvidedUuid != existing.ProvidedUuid.String() { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf( + "provided-uuid is immutable: it is the identity the leverage link and drift detector join on"))) + } + // Echo back the stored identities so UnmarshalOscal's MustParse can't panic on a body + // that legitimately omits them. + oscalInherited.UUID = existing.ID.String() + oscalInherited.ProvidedUuid = existing.ProvidedUuid.String() + + parsed := &relational.InheritedControlImplementation{} + parsed.UnmarshalOscal(oscalInherited) + + if err := h.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&relational.InheritedControlImplementation{}). + Where("id = ?", existing.ID). + Updates(map[string]any{ + "description": parsed.Description, + "props": parsed.Props, + "links": parsed.Links, + }).Error; err != nil { + return err + } + return replaceResponsibleRoles(tx, &existing, parsed.ResponsibleRoles) + }); err != nil { + h.sugar.Errorf("Failed to update inherited control implementation: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + updated, err := h.reloadInherited(*existing.ID) + if err != nil { + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + return ctx.JSON(http.StatusOK, handler.GenericDataResponse[oscalTypes_1_1_3.InheritedControlImplementation]{Data: *updated.MarshalOscal()}) +} + +// DeleteImplementedRequirementStatementByComponentInherited godoc +// +// @Summary Delete an inherited control implementation on a statement-level by-component +// @Description Hand-authored entries delete freely. An entry created by a subscription is owned +// @Description by that subscription — an SSPLeverageLink still references it, and both drift +// @Description detection and notifications read through that reference — so deleting it returns +// @Description 409; unsubscribe instead. +// @Tags System Security Plans +// @Param id path string true "SSP ID" +// @Param reqId path string true "Requirement ID" +// @Param stmtId path string true "Statement ID" +// @Param byComponentId path string true "By-Component ID" +// @Param inheritedId path string true "Inherited ID" +// @Success 204 "No Content" +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 409 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/inherited/{inheritedId} [delete] +func (h *SystemSecurityPlanHandler) DeleteImplementedRequirementStatementByComponentInherited(ctx echo.Context) error { + bc, ok := h.resolveByComponentForStatement(ctx) + if !ok { + return nil + } + + inheritedID, err := uuid.Parse(ctx.Param("inheritedId")) + if err != nil { + h.sugar.Warnw("Invalid inherited id", "inheritedId", ctx.Param("inheritedId"), "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + var existing relational.InheritedControlImplementation + if err := h.db. + Preload("ResponsibleRoles.Parties"). + Where("id = ? AND by_component_id = ?", inheritedID, bc.ID). + First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("inherited entry not found"))) + } + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + if err := h.db.Transaction(func(tx *gorm.DB) error { + var linkCount int64 + if err := tx.Model(&relational.SSPLeverageLink{}). + Where("inherited_uuid = ?", existing.ID). + Count(&linkCount).Error; err != nil { + return err + } + if linkCount > 0 { + return errInheritedOwnedBySubscription + } + + if err := deleteResponsibleRoles(tx, existing.ResponsibleRoles); err != nil { + return err + } + return tx.Delete(&existing).Error + }); err != nil { + if errors.Is(err, errInheritedOwnedBySubscription) { + return ctx.JSON(http.StatusConflict, api.NewError(err)) + } + h.sugar.Errorf("Failed to delete inherited control implementation: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.NoContent(http.StatusNoContent) +} + +// GetImplementedRequirementStatementByComponentSatisfied godoc +// +// @Summary List the satisfied responsibilities on a statement-level by-component +// @Description Retrieves how this system discharges its upstream's responsibilities under this statement. +// @Tags System Security Plans +// @Produce json +// @Param id path string true "SSP ID" +// @Param reqId path string true "Requirement ID" +// @Param stmtId path string true "Statement ID" +// @Param byComponentId path string true "By-Component ID" +// @Success 200 {object} handler.GenericDataListResponse[oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/satisfied [get] +func (h *SystemSecurityPlanHandler) GetImplementedRequirementStatementByComponentSatisfied(ctx echo.Context) error { + bc, ok := h.resolveByComponentForStatement(ctx) + if !ok { + return nil + } + + var satisfied []relational.SatisfiedControlImplementationResponsibility + if err := h.db. + Preload("ResponsibleRoles.Parties"). + Where("by_component_id = ?", bc.ID). + Order("id ASC"). + Find(&satisfied).Error; err != nil { + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + result := make([]oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility, 0, len(satisfied)) + for i := range satisfied { + result = append(result, *satisfied[i].MarshalOscal()) + } + return ctx.JSON(http.StatusOK, handler.GenericDataListResponse[oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility]{Data: result}) +} + +// CreateImplementedRequirementStatementByComponentSatisfied godoc +// +// @Summary Create a satisfied responsibility on a statement-level by-component +// @Description Records that this system discharges one of its upstream's responsibilities. The +// @Description responsibility-uuid must resolve to a ControlImplementationResponsibility on an +// @Description Export this by-component actually inherits from (400 otherwise). If the owning +// @Description SSPLeverageLink exists, its cached Satisfaction is re-derived in the same +// @Description transaction, so partial can flip to full atomically with the write that causes it. +// @Tags System Security Plans +// @Accept json +// @Produce json +// @Param id path string true "SSP ID" +// @Param reqId path string true "Requirement ID" +// @Param stmtId path string true "Statement ID" +// @Param byComponentId path string true "By-Component ID" +// @Param satisfied body oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility true "Satisfied data" +// @Success 201 {object} handler.GenericDataResponse[oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/satisfied [post] +func (h *SystemSecurityPlanHandler) CreateImplementedRequirementStatementByComponentSatisfied(ctx echo.Context) error { + sspID, _, _, err := parseSSPReqStmtIDs(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + bc, ok := h.resolveByComponentForStatement(ctx) + if !ok { + return nil + } + + var oscalSatisfied oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility + if err := ctx.Bind(&oscalSatisfied); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := ensureBodyUUID(&oscalSatisfied.UUID); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + responsibilityUUID, err := uuid.Parse(oscalSatisfied.ResponsibilityUuid) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("responsibility-uuid must be a valid UUID"))) + } + + inheritable, err := h.inheritableResponsibilities(*bc.ID) + if err != nil { + h.sugar.Errorf("Failed to resolve inheritable responsibilities: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + if !inheritable[responsibilityUUID] { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf( + "responsibility-uuid %q is not a responsibility on any export this by-component inherits from", responsibilityUUID))) + } + + relSatisfied := &relational.SatisfiedControlImplementationResponsibility{} + relSatisfied.UnmarshalOscal(oscalSatisfied) + relSatisfied.ByComponentId = *bc.ID + + if err := h.db.Transaction(func(tx *gorm.DB) error { + if err := lockByComponentExportCreate(tx, *bc.ID); err != nil { + return err + } + if err := tx.Create(relSatisfied).Error; err != nil { + return err + } + return resyncLeverageSatisfaction(tx, sspID, *bc.ID) + }); err != nil { + h.sugar.Errorf("Failed to create satisfied responsibility: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + created, err := h.reloadSatisfied(*relSatisfied.ID) + if err != nil { + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + return ctx.JSON(http.StatusCreated, handler.GenericDataResponse[oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility]{Data: *created.MarshalOscal()}) +} + +// UpdateImplementedRequirementStatementByComponentSatisfied godoc +// +// @Summary Update a satisfied responsibility on a statement-level by-component +// @Description Metadata only — description, remarks, props, links and responsible-roles. +// @Description responsibility-uuid is immutable and a body attempting to change it is rejected +// @Description with 400: it is what deriveSatisfaction and the drift detector match on. Because +// @Description the identity can't change, the owning link's Satisfaction cannot change either, +// @Description so no re-derivation is needed here. +// @Tags System Security Plans +// @Accept json +// @Produce json +// @Param id path string true "SSP ID" +// @Param reqId path string true "Requirement ID" +// @Param stmtId path string true "Statement ID" +// @Param byComponentId path string true "By-Component ID" +// @Param satisfiedId path string true "Satisfied ID" +// @Param satisfied body oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility true "Satisfied data" +// @Success 200 {object} handler.GenericDataResponse[oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/satisfied/{satisfiedId} [put] +func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementStatementByComponentSatisfied(ctx echo.Context) error { + bc, ok := h.resolveByComponentForStatement(ctx) + if !ok { + return nil + } + + satisfiedID, err := uuid.Parse(ctx.Param("satisfiedId")) + if err != nil { + h.sugar.Warnw("Invalid satisfied id", "satisfiedId", ctx.Param("satisfiedId"), "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + var existing relational.SatisfiedControlImplementationResponsibility + if err := h.db.Where("id = ? AND by_component_id = ?", satisfiedID, bc.ID).First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("satisfied entry not found"))) + } + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + var oscalSatisfied oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility + if err := ctx.Bind(&oscalSatisfied); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if oscalSatisfied.ResponsibilityUuid != "" && oscalSatisfied.ResponsibilityUuid != existing.ResponsibilityUuid.String() { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf( + "responsibility-uuid is immutable: it is the identity satisfaction derivation and the drift detector join on"))) + } + oscalSatisfied.UUID = existing.ID.String() + oscalSatisfied.ResponsibilityUuid = existing.ResponsibilityUuid.String() + + parsed := &relational.SatisfiedControlImplementationResponsibility{} + parsed.UnmarshalOscal(oscalSatisfied) + + if err := h.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&relational.SatisfiedControlImplementationResponsibility{}). + Where("id = ?", existing.ID). + Updates(map[string]any{ + "description": parsed.Description, + "remarks": parsed.Remarks, + "props": parsed.Props, + "links": parsed.Links, + }).Error; err != nil { + return err + } + return replaceResponsibleRoles(tx, &existing, parsed.ResponsibleRoles) + }); err != nil { + h.sugar.Errorf("Failed to update satisfied responsibility: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + updated, err := h.reloadSatisfied(*existing.ID) + if err != nil { + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + return ctx.JSON(http.StatusOK, handler.GenericDataResponse[oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility]{Data: *updated.MarshalOscal()}) +} + +// DeleteImplementedRequirementStatementByComponentSatisfied godoc +// +// @Summary Delete a satisfied responsibility on a statement-level by-component +// @Description Removes the entry and, in the same transaction, re-derives the owning +// @Description SSPLeverageLink's cached Satisfaction — so dropping the last satisfied entry +// @Description flips full back to partial atomically rather than leaving the link's bookkeeping +// @Description stale for the drift detector to read. +// @Tags System Security Plans +// @Param id path string true "SSP ID" +// @Param reqId path string true "Requirement ID" +// @Param stmtId path string true "Statement ID" +// @Param byComponentId path string true "By-Component ID" +// @Param satisfiedId path string true "Satisfied ID" +// @Success 204 "No Content" +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/satisfied/{satisfiedId} [delete] +func (h *SystemSecurityPlanHandler) DeleteImplementedRequirementStatementByComponentSatisfied(ctx echo.Context) error { + sspID, _, _, err := parseSSPReqStmtIDs(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + bc, ok := h.resolveByComponentForStatement(ctx) + if !ok { + return nil + } + + satisfiedID, err := uuid.Parse(ctx.Param("satisfiedId")) + if err != nil { + h.sugar.Warnw("Invalid satisfied id", "satisfiedId", ctx.Param("satisfiedId"), "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + var existing relational.SatisfiedControlImplementationResponsibility + if err := h.db. + Preload("ResponsibleRoles.Parties"). + Where("id = ? AND by_component_id = ?", satisfiedID, bc.ID). + First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("satisfied entry not found"))) + } + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + if err := h.db.Transaction(func(tx *gorm.DB) error { + if err := deleteResponsibleRoles(tx, existing.ResponsibleRoles); err != nil { + return err + } + if err := tx.Delete(&existing).Error; err != nil { + return err + } + return resyncLeverageSatisfaction(tx, sspID, *bc.ID) + }); err != nil { + h.sugar.Errorf("Failed to delete satisfied responsibility: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.NoContent(http.StatusNoContent) +} + +// inheritableResponsibilities is the set of upstream responsibility uuids a by-component may +// legitimately claim to satisfy: the responsibilities on the Exports behind the provided-uuids +// this by-component actually inherits. Scoped by (export_id, provided_uuid) via +// bulkResolveUpstreamResponsibilities, because provided-uuid values are only unique within one +// upstream's Export — a provided_uuid-only match could pick up a same-valued responsibility +// from an unrelated Export. +func (h *SystemSecurityPlanHandler) inheritableResponsibilities(byComponentID uuid.UUID) (map[uuid.UUID]bool, error) { + var inherited []relational.InheritedControlImplementation + if err := h.db.Where("by_component_id = ?", byComponentID).Find(&inherited).Error; err != nil { + return nil, err + } + + providedUUIDs := uniqueUUIDs(inherited, func(i relational.InheritedControlImplementation) uuid.UUID { return i.ProvidedUuid }) + byProvided, err := bulkResolveUpstreamResponsibilities(h.db, providedUUIDs) + if err != nil { + return nil, err + } + + allowed := make(map[uuid.UUID]bool) + for _, responsibilities := range byProvided { + for _, r := range responsibilities { + allowed[r.ResponsibilityUUID] = true + } + } + return allowed, nil +} + +// resyncLeverageSatisfaction re-derives the cached Satisfaction on every SSPLeverageLink that +// owns an Inherited row on this by-component, after its satisfied set changed. +// +// LeveragedControls recomputes satisfaction live, so the stored value is bookkeeping — but the +// drift detector and the notification path read it, so it must not rot. The link is found the +// only way it can be: through the by-component's inherited rows, keyed on +// (downstream_ssp_id, provided_uuid) — the pair the unique index is built on. A hand-authored +// inherited entry has no link and is skipped silently. +// +// Runs inside the caller's transaction so the satisfied write and the satisfaction it implies +// commit together. +func resyncLeverageSatisfaction(tx *gorm.DB, downstreamSSPID, byComponentID uuid.UUID) error { + var inherited []relational.InheritedControlImplementation + if err := tx.Where("by_component_id = ?", byComponentID).Find(&inherited).Error; err != nil { + return err + } + if len(inherited) == 0 { + return nil + } + + var satisfiedRows []relational.SatisfiedControlImplementationResponsibility + if err := tx.Where("by_component_id = ?", byComponentID).Find(&satisfiedRows).Error; err != nil { + return err + } + // deriveSatisfaction only asks whether each *upstream* responsibility is covered, so + // satisfied rows belonging to a sibling inherited entry on the same by-component are + // simply never consulted — one set is safe to share across all of them. + satisfiedUUIDs := make(map[uuid.UUID]bool, len(satisfiedRows)) + for _, s := range satisfiedRows { + satisfiedUUIDs[s.ResponsibilityUuid] = true + } + + providedUUIDs := uniqueUUIDs(inherited, func(i relational.InheritedControlImplementation) uuid.UUID { return i.ProvidedUuid }) + fullSetByProvided, err := bulkResolveUpstreamResponsibilities(tx, providedUUIDs) + if err != nil { + return err + } + + for _, providedUUID := range providedUUIDs { + var link relational.SSPLeverageLink + err := tx.Where("downstream_ssp_id = ? AND provided_uuid = ?", downstreamSSPID, providedUUID). + First(&link).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + continue + } + if err != nil { + return err + } + + satisfaction, _ := deriveSatisfaction(fullSetByProvided[providedUUID], satisfiedUUIDs) + if satisfaction == link.Satisfaction { + continue + } + if err := tx.Model(&relational.SSPLeverageLink{}). + Where("id = ?", link.ID). + Update("satisfaction", satisfaction).Error; err != nil { + return err + } + } + return nil +} + +// ensureBodyUUID fills in a missing uuid on a create body and rejects a malformed one. The +// OSCAL Unmarshal* helpers MustParse their uuid field, so without this a body omitting it +// panics into a 500 instead of returning a 400. +func ensureBodyUUID(id *string) error { + if *id == "" { + *id = uuid.New().String() + return nil + } + if _, err := uuid.Parse(*id); err != nil { + return fmt.Errorf("uuid must be a valid UUID") + } + return nil +} + +func (h *SystemSecurityPlanHandler) reloadInherited(id uuid.UUID) (*relational.InheritedControlImplementation, error) { + var row relational.InheritedControlImplementation + err := h.db.Preload("ResponsibleRoles.Parties").First(&row, "id = ?", id).Error + return &row, err +} + +func (h *SystemSecurityPlanHandler) reloadSatisfied(id uuid.UUID) (*relational.SatisfiedControlImplementationResponsibility, error) { + var row relational.SatisfiedControlImplementationResponsibility + err := h.db.Preload("ResponsibleRoles.Parties").First(&row, "id = ?", id).Error + return &row, err +} diff --git a/internal/api/handler/oscal/ssp_export_offerings.go b/internal/api/handler/oscal/ssp_export_offerings.go index 64332865..98f7ee28 100644 --- a/internal/api/handler/oscal/ssp_export_offerings.go +++ b/internal/api/handler/oscal/ssp_export_offerings.go @@ -255,9 +255,11 @@ func (h *SSPExportOfferingHandler) RegisterNested(api *echo.Group, guard middlew } // Register mounts the top-level, cross-SSP read-only catalog: list and get any -// offering by its own ID, gated by ssp-export-offering:read. +// offering by its own ID, plus the control-centric by-control query — all gated by +// ssp-export-offering:read. func (h *SSPExportOfferingHandler) Register(api *echo.Group, guard middleware.ResourceGuard) { api.GET("", h.ListAll, guard.Read()) + api.GET("/by-control/:controlId", h.ByControl, guard.Read()) api.GET("/:id", h.GetByID, guard.Read()) } @@ -499,8 +501,14 @@ func (h *SSPExportOfferingHandler) DeleteOffering(ctx echo.Context) error { } type createExportOfferingItemRequest struct { - ControlID string `json:"controlId"` - StatementID *string `json:"statementId,omitempty"` + ControlID string `json:"controlId"` + // StatementID is required on every write. The statement is the canonical anchor for + // shared responsibility: a control is too coarse to attribute a provided capability + // against, and a requirement-anchored item leaves the downstream unable to say which + // clause the upstream discharges. Still a *string (not string) because the DB column + // stays nullable for legacy rows that pre-date this constraint — see + // migrateBackfillOfferingItemStatementIDs. + StatementID *string `json:"statementId"` ComponentUUID string `json:"componentUuid"` ProvidedUUID string `json:"providedUuid"` } @@ -509,6 +517,9 @@ func (r createExportOfferingItemRequest) validate() error { if r.ControlID == "" { return fmt.Errorf("controlId is required") } + if r.StatementID == nil || strings.TrimSpace(*r.StatementID) == "" { + return fmt.Errorf("statementId is required: shared responsibility is tracked per statement — pick the statement this provided capability is exported from") + } if _, err := uuid.Parse(r.ComponentUUID); err != nil { return fmt.Errorf("componentUuid must be a valid UUID") } @@ -518,6 +529,90 @@ func (r createExportOfferingItemRequest) validate() error { return nil } +// validateOfferingItemCoherence checks that the item's (ControlID, StatementID, +// ComponentUUID, ProvidedUUID) tuple actually describes one real statement-anchored +// by-component inside the offering's own SSP, rather than four independently-plausible +// identifiers. Nothing validated this before: an item could name a provided-uuid from a +// different SSP entirely, or pair it with a control/statement/component it has no relation +// to, and the incoherence only surfaced downstream at subscribe time (or never). +// +// It walks the ownership chain the offering item is a by-value pointer into: +// +// Provided -> Export -> ByComponent (must be statement-anchored) +// -> Statement (statement-id must match) -> ImplementedRequirement (control-id must match) +// -> ControlImplementation (must be this SSP's) +// +// resolveItemImplementationStatuses walks the first half of the same chain, but only ever +// projects the ByComponent's implementation-status out of it — it discards the parent +// identities this check exists to compare — so the walk is done explicitly here. +func (h *SSPExportOfferingHandler) validateOfferingItemCoherence(sspID uuid.UUID, req createExportOfferingItemRequest) error { + providedUUID := uuid.MustParse(req.ProvidedUUID) + componentUUID := uuid.MustParse(req.ComponentUUID) + + var provided relational.ProvidedControlImplementation + if err := h.db.First(&provided, "id = ?", providedUUID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("providedUuid %q does not exist", req.ProvidedUUID) + } + return err + } + + var export relational.Export + if err := h.db.First(&export, "id = ?", provided.ExportId).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("providedUuid %q has no export", req.ProvidedUUID) + } + return err + } + + var byComponent relational.ByComponent + if err := h.db.First(&byComponent, "id = ?", export.ByComponentId).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("providedUuid %q has no by-component", req.ProvidedUUID) + } + return err + } + + if byComponent.ParentType == nil || *byComponent.ParentType != "statements" || byComponent.ParentID == nil { + return fmt.Errorf("providedUuid %q is exported from a requirement-anchored by-component; shared responsibility is tracked per statement", req.ProvidedUUID) + } + if byComponent.ComponentUUID != componentUUID { + return fmt.Errorf("componentUuid %q does not match the component exporting providedUuid %q", req.ComponentUUID, req.ProvidedUUID) + } + + var statement relational.Statement + if err := h.db.First(&statement, "id = ?", *byComponent.ParentID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("providedUuid %q has no statement", req.ProvidedUUID) + } + return err + } + if statement.StatementId != *req.StatementID { + return fmt.Errorf("statementId %q does not match the statement exporting providedUuid %q", *req.StatementID, req.ProvidedUUID) + } + + var requirement relational.ImplementedRequirement + if err := h.db.First(&requirement, "id = ?", statement.ImplementedRequirementId).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("providedUuid %q has no implemented requirement", req.ProvidedUUID) + } + return err + } + if !strings.EqualFold(requirement.ControlId, req.ControlID) { + return fmt.Errorf("controlId %q does not match the control exporting providedUuid %q", req.ControlID, req.ProvidedUUID) + } + + var ssp relational.SystemSecurityPlan + if err := h.db.Preload("ControlImplementation").First(&ssp, "id = ?", sspID).Error; err != nil { + return err + } + if ssp.ControlImplementation.ID == nil || *ssp.ControlImplementation.ID != requirement.ControlImplementationId { + return fmt.Errorf("providedUuid %q does not resolve inside this SSP", req.ProvidedUUID) + } + + return nil +} + // CreateItem godoc // // @Summary Add an item to an export offering @@ -550,6 +645,10 @@ func (h *SSPExportOfferingHandler) CreateItem(ctx echo.Context) error { if err := req.validate(); err != nil { return ctx.JSON(http.StatusBadRequest, api.NewError(err)) } + if err := h.validateOfferingItemCoherence(offering.SSPID, req); err != nil { + h.sugar.Warnw("Incoherent export offering item", "offeringId", offering.ID, "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } item := relational.SSPExportOfferingItem{ OfferingID: *offering.ID, @@ -611,6 +710,10 @@ func (h *SSPExportOfferingHandler) UpdateItem(ctx echo.Context) error { if err := req.validate(); err != nil { return ctx.JSON(http.StatusBadRequest, api.NewError(err)) } + if err := h.validateOfferingItemCoherence(offering.SSPID, req); err != nil { + h.sugar.Warnw("Incoherent export offering item", "offeringId", offering.ID, "itemId", itemID, "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } var rowsAffected int64 if err := h.db.Transaction(func(tx *gorm.DB) error { @@ -1111,3 +1214,210 @@ func (h *SSPExportOfferingHandler) GetByID(ctx echo.Context) error { } return ctx.JSON(http.StatusOK, handler.GenericDataResponse[catalogOffering]{Data: withResponsibilities[0]}) } + +// controlExportProvided is the provided capability behind one ControlExportOffer, resolved +// by value so the Controls UI never has to walk into the upstream's Export subtree. +type controlExportProvided struct { + UUID uuid.UUID `json:"uuid"` + Description string `json:"description"` +} + +// controlExportResponsibility is one responsibility the upstream makes the downstream +// answerable for under the offered provided capability. +type controlExportResponsibility struct { + UUID uuid.UUID `json:"uuid"` + Description string `json:"description"` + ProvidedUUID uuid.UUID `json:"providedUuid"` +} + +// ControlExportOffer answers "for control X, what is exported, by whom, against which +// statement?" — one published offering item, with every pointer it carries already resolved +// (offering, upstream SSP, component, provided, responsibilities). This is what the Controls +// page reads and what "import an implementation" picks from, so a consumer needs no further +// round-trips and — critically — no ssp:read on the upstream SSP. +type ControlExportOffer struct { + OfferingID uuid.UUID `json:"offeringId"` + OfferingTitle string `json:"offeringTitle"` + OfferingVersion int `json:"offeringVersion"` + OfferingStatus relational.SSPExportOfferingStatus `json:"offeringStatus"` + + UpstreamSSPID uuid.UUID `json:"upstreamSspId"` + UpstreamSSPTitle string `json:"upstreamSspTitle"` + + ItemID uuid.UUID `json:"itemId"` + + ControlID string `json:"controlId"` + StatementID *string `json:"statementId,omitempty"` + + ComponentUUID uuid.UUID `json:"componentUuid"` + ComponentTitle string `json:"componentTitle"` + + Provided *controlExportProvided `json:"provided"` + Responsibilities []controlExportResponsibility `json:"responsibilities"` +} + +// ByControl godoc +// +// @Summary List every published export offering for one control +// @Description Cross-SSP catalog of what is exported for a control, by whom, and against +// @Description which statement — every published offering item whose control-id matches +// @Description (case-insensitively), with the offering, upstream SSP, component, provided +// @Description capability and responsibility set all resolved server-side. Honours the same +// @Description trust boundary as the flat catalog: gated by ssp-export-offering:read only, +// @Description never ssp:read on the upstream SSP. Pass downstreamSspId to narrow the result +// @Description to offerings that SSP is actually allow-listed to subscribe to (BCH-1342). +// @Tags SSP Export Offerings +// @Produce json +// @Param controlId path string true "Control ID (e.g. AC-2)" +// @Param downstreamSspId query string false "Only return offerings this downstream SSP may subscribe to" +// @Success 200 {object} handler.GenericDataListResponse[ControlExportOffer] +// @Failure 400 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/ssp-export-offerings/by-control/{controlId} [get] +func (h *SSPExportOfferingHandler) ByControl(ctx echo.Context) error { + controlID := strings.TrimSpace(ctx.Param("controlId")) + if controlID == "" { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("controlId is required"))) + } + + var downstreamSSPID *uuid.UUID + if raw := strings.TrimSpace(ctx.QueryParam("downstreamSspId")); raw != "" { + parsed, err := uuid.Parse(raw) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("downstreamSspId must be a valid UUID"))) + } + downstreamSSPID = &parsed + } + + // Control ids are stored in their catalog-canonical casing but referenced in mixed + // casing across the codebase (fixtures use "ac-2", catalogs "AC-2"), so match the way + // every other control lookup here does: fold case rather than compare bytes. + var items []relational.SSPExportOfferingItem + if err := h.db. + Joins("JOIN ssp_export_offerings ON ssp_export_offerings.id = ssp_export_offering_items.offering_id"). + Where("ssp_export_offerings.status = ? AND UPPER(ssp_export_offering_items.control_id) = UPPER(?)", + relational.SSPExportOfferingStatusPublished, controlID). + Order("ssp_export_offering_items.id ASC"). + Find(&items).Error; err != nil { + h.sugar.Errorf("Failed to list export offering items by control: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + if len(items) == 0 { + return ctx.JSON(http.StatusOK, handler.GenericDataListResponse[ControlExportOffer]{Data: []ControlExportOffer{}}) + } + + offeringIDs := uniqueUUIDs(items, func(i relational.SSPExportOfferingItem) uuid.UUID { return i.OfferingID }) + var offerings []relational.SSPExportOffering + if err := h.db.Where("id IN ?", offeringIDs).Find(&offerings).Error; err != nil { + h.sugar.Errorf("Failed to load offerings by control: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + offeringByID := make(map[uuid.UUID]relational.SSPExportOffering, len(offerings)) + for _, o := range offerings { + offeringByID[*o.ID] = o + } + + // Drop items whose offering the downstream isn't allow-listed for, before spending any + // further resolution on them. + if downstreamSSPID != nil { + allowedOfferings := make(map[uuid.UUID]bool, len(offerings)) + for _, o := range offerings { + allowed, err := isDownstreamAllowed(h.db, *o.ID, *downstreamSSPID) + if err != nil { + h.sugar.Errorf("Failed to check offering allow-list: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + allowedOfferings[*o.ID] = allowed + } + filtered := make([]relational.SSPExportOfferingItem, 0, len(items)) + for _, item := range items { + if allowedOfferings[item.OfferingID] { + filtered = append(filtered, item) + } + } + items = filtered + if len(items) == 0 { + return ctx.JSON(http.StatusOK, handler.GenericDataListResponse[ControlExportOffer]{Data: []ControlExportOffer{}}) + } + } + + sspIDs := uniqueUUIDs(offerings, func(o relational.SSPExportOffering) uuid.UUID { return o.SSPID }) + var ssps []relational.SystemSecurityPlan + if err := h.db.Preload("Metadata").Where("id IN ?", sspIDs).Find(&ssps).Error; err != nil { + h.sugar.Errorf("Failed to load upstream SSPs by control: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + sspTitleByID := make(map[uuid.UUID]string, len(ssps)) + for _, s := range ssps { + sspTitleByID[*s.ID] = s.Metadata.Title + } + + componentUUIDs := uniqueUUIDs(items, func(i relational.SSPExportOfferingItem) uuid.UUID { return i.ComponentUUID }) + var components []relational.SystemComponent + if err := h.db.Where("id IN ?", componentUUIDs).Find(&components).Error; err != nil { + h.sugar.Errorf("Failed to load components by control: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + componentTitleByID := make(map[uuid.UUID]string, len(components)) + for _, c := range components { + componentTitleByID[*c.ID] = c.Title + } + + providedUUIDs := uniqueUUIDs(items, func(i relational.SSPExportOfferingItem) uuid.UUID { return i.ProvidedUUID }) + var providedRows []relational.ProvidedControlImplementation + if err := h.db.Where("id IN ?", providedUUIDs).Find(&providedRows).Error; err != nil { + h.sugar.Errorf("Failed to load provided implementations by control: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + providedByID := make(map[uuid.UUID]relational.ProvidedControlImplementation, len(providedRows)) + for _, p := range providedRows { + providedByID[*p.ID] = p + } + + // Same batched (export_id, provided_uuid)-scoped resolution the flat catalog uses — the + // only correct way to map a provided-uuid to its responsibilities, since provided-uuid + // values are only unique within one upstream's Export. + responsibilitiesByProvided, err := bulkResolveUpstreamResponsibilities(h.db, providedUUIDs) + if err != nil { + h.sugar.Errorf("Failed to resolve responsibilities by control: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + result := make([]ControlExportOffer, 0, len(items)) + for _, item := range items { + offering := offeringByID[item.OfferingID] + + var provided *controlExportProvided + if p, ok := providedByID[item.ProvidedUUID]; ok { + provided = &controlExportProvided{UUID: *p.ID, Description: p.Description} + } + + responsibilities := make([]controlExportResponsibility, 0, len(responsibilitiesByProvided[item.ProvidedUUID])) + for _, r := range responsibilitiesByProvided[item.ProvidedUUID] { + responsibilities = append(responsibilities, controlExportResponsibility{ + UUID: r.ResponsibilityUUID, + Description: r.Description, + ProvidedUUID: item.ProvidedUUID, + }) + } + + result = append(result, ControlExportOffer{ + OfferingID: item.OfferingID, + OfferingTitle: offering.Title, + OfferingVersion: offering.Version, + OfferingStatus: offering.Status, + UpstreamSSPID: offering.SSPID, + UpstreamSSPTitle: sspTitleByID[offering.SSPID], + ItemID: *item.ID, + ControlID: item.ControlID, + StatementID: item.StatementID, + ComponentUUID: item.ComponentUUID, + ComponentTitle: componentTitleByID[item.ComponentUUID], + Provided: provided, + Responsibilities: responsibilities, + }) + } + + return ctx.JSON(http.StatusOK, handler.GenericDataListResponse[ControlExportOffer]{Data: result}) +} diff --git a/internal/api/handler/oscal/ssp_export_offerings_test.go b/internal/api/handler/oscal/ssp_export_offerings_test.go index 349fd0dd..db84449d 100644 --- a/internal/api/handler/oscal/ssp_export_offerings_test.go +++ b/internal/api/handler/oscal/ssp_export_offerings_test.go @@ -144,8 +144,67 @@ func minimalSSP(componentUUID string) *oscalTypes_1_1_3.SystemSecurityPlan { } } +// exportedStatement names one statement-anchored provided capability to seed on a fixture SSP: +// control -> statement -> by-component -> export -> provided. +type exportedStatement struct { + ControlID string + StatementID string + ProvidedUUID string +} + +// sspWithStatementExports builds on minimalSSP by replacing its implemented-requirements with +// one per exported statement, each carrying a statement-level by-component whose Export provides +// the named capability. +// +// Everything carrying shared responsibility is statement-anchored now: an offering item must +// name a statement, and its (controlId, statementId, componentUuid, providedUuid) tuple must +// actually resolve to a real statement-anchored export inside the SSP. A fixture that invents a +// providedUuid out of thin air — as these tests used to — is precisely the incoherent case the +// coherence check exists to reject. +func sspWithStatementExports(componentUUID string, exports ...exportedStatement) *oscalTypes_1_1_3.SystemSecurityPlan { + ssp := minimalSSP(componentUUID) + + requirements := make([]oscalTypes_1_1_3.ImplementedRequirement, 0, len(exports)) + for _, export := range exports { + requirements = append(requirements, oscalTypes_1_1_3.ImplementedRequirement{ + UUID: uuid.New().String(), + ControlId: export.ControlID, + Statements: &[]oscalTypes_1_1_3.Statement{ + { + UUID: uuid.New().String(), + StatementId: export.StatementID, + ByComponents: &[]oscalTypes_1_1_3.ByComponent{ + { + UUID: uuid.New().String(), + ComponentUuid: componentUUID, + Description: fmt.Sprintf("%s %s implemented by test component", export.ControlID, export.StatementID), + Export: &oscalTypes_1_1_3.Export{ + Provided: &[]oscalTypes_1_1_3.ProvidedControlImplementation{ + {UUID: export.ProvidedUUID, Description: "Provides " + export.ControlID}, + }, + }, + }, + }, + }, + }, + }) + } + ssp.ControlImplementation.ImplementedRequirements = requirements + return ssp +} + +// offeringItemBody is the coherent offering-item write body for one seeded exported statement. +func offeringItemBody(componentUUID string, export exportedStatement) map[string]any { + return map[string]any{ + "controlId": export.ControlID, + "statementId": export.StatementID, + "componentUuid": componentUUID, + "providedUuid": export.ProvidedUUID, + } +} + // TestPublishRequiresSSPExportContributorCan_ViewerCannot is the ticket's authz AC: -// a contributor publishes an offering of 2 controls (1 per-statement); a viewer gets 403 on +// a contributor publishes an offering of 2 per-statement controls; a viewer gets 403 on // publish and 200 on read (via the top-level ssp-export-offering:read catalog). func (suite *SSPExportOfferingAuthzIntegrationSuite) TestPublishRequiresSSPExportContributorCan_ViewerCannot() { suite.Require().NoError(suite.Migrator.Refresh()) @@ -156,7 +215,9 @@ func (suite *SSPExportOfferingAuthzIntegrationSuite) TestPublishRequiresSSPExpor server := newCedarServer(&suite.IntegrationTestSuite) componentUUID := uuid.New().String() - ssp := minimalSSP(componentUUID) + acOne := exportedStatement{ControlID: "ac-1", StatementID: "ac-1_stmt.a", ProvidedUUID: uuid.New().String()} + acTwo := exportedStatement{ControlID: "ac-2", StatementID: "ac-2_stmt.a", ProvidedUUID: uuid.New().String()} + ssp := sspWithStatementExports(componentUUID, acOne, acTwo) rec := authedRequest(&suite.IntegrationTestSuite, server, "POST", "/api/oscal/system-security-plans", contributorToken, ssp) suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) @@ -164,29 +225,23 @@ func (suite *SSPExportOfferingAuthzIntegrationSuite) TestPublishRequiresSSPExpor rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings", ssp.UUID), contributorToken, - map[string]string{"title": "Leverageable controls", "description": "AC-1 and a per-statement AC-2"}, + map[string]string{"title": "Leverageable controls", "description": "a per-statement AC-1 and AC-2"}, ) suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) var created handler.GenericDataResponse[relational.SSPExportOffering] suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &created)) offeringID := created.Data.ID.String() - // A viewer must not be able to curate items either — same ssp:export gate. - stmtID := "ac-2_stmt.a" + // A viewer must not be able to curate items either — same ssp:export gate. The body is + // coherent, so the 403 can only be the authz gate, never the coherence check. itemPath := fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings/%s/items", ssp.UUID, offeringID) - rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", itemPath, viewerToken, map[string]any{ - "controlId": "ac-1", "componentUuid": componentUUID, "providedUuid": uuid.New().String(), - }) + rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", itemPath, viewerToken, offeringItemBody(componentUUID, acOne)) suite.Require().Equal(http.StatusForbidden, rec.Code, rec.Body.String()) - // Contributor adds the 2 controls: one control-level, one per-statement. - rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", itemPath, contributorToken, map[string]any{ - "controlId": "ac-1", "componentUuid": componentUUID, "providedUuid": uuid.New().String(), - }) + // Contributor adds both controls, each anchored on its own statement. + rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", itemPath, contributorToken, offeringItemBody(componentUUID, acOne)) suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) - rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", itemPath, contributorToken, map[string]any{ - "controlId": "ac-2", "statementId": stmtID, "componentUuid": componentUUID, "providedUuid": uuid.New().String(), - }) + rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", itemPath, contributorToken, offeringItemBody(componentUUID, acTwo)) suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) publishPath := fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings/%s/publish", ssp.UUID, offeringID) diff --git a/internal/api/handler/oscal/ssp_leverage.go b/internal/api/handler/oscal/ssp_leverage.go index eabb20a7..f1cea428 100644 --- a/internal/api/handler/oscal/ssp_leverage.go +++ b/internal/api/handler/oscal/ssp_leverage.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "strings" "time" "github.com/google/uuid" @@ -276,74 +277,178 @@ func findOrCreateThisSystemComponent(tx *gorm.DB, systemImplementationID uuid.UU // controlID under the given ControlImplementation, creating one if none exists. No // find-or-create primitive exists elsewhere in the codebase for this — every existing // creation path either does a naive insert (always creates) or a read-only lookup -// (404s if missing). -func findOrCreateImplementedRequirement(tx *gorm.DB, controlImplementationID uuid.UUID, controlID string) (*relational.ImplementedRequirement, error) { +// (404s if missing). The created flag distinguishes an insert from a match, so Subscribe +// can report which rows it actually materialized rather than making the caller re-walk the +// downstream SSP to find out. +func findOrCreateImplementedRequirement(tx *gorm.DB, controlImplementationID uuid.UUID, controlID string) (req *relational.ImplementedRequirement, created bool, err error) { var existing relational.ImplementedRequirement - err := tx.Where("control_implementation_id = ? AND control_id = ?", controlImplementationID, controlID). + err = tx.Where("control_implementation_id = ? AND control_id = ?", controlImplementationID, controlID). First(&existing).Error if err == nil { - return &existing, nil + return &existing, false, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err + return nil, false, err } - created := relational.ImplementedRequirement{ + inserted := relational.ImplementedRequirement{ ControlImplementationId: controlImplementationID, ControlId: controlID, } - if err := tx.Create(&created).Error; err != nil { - return nil, err + if err := tx.Create(&inserted).Error; err != nil { + return nil, false, err } - return &created, nil + return &inserted, true, nil } // findOrCreateStatement finds the ImplementedRequirement's child Statement for // statementID, creating one if none exists. -func findOrCreateStatement(tx *gorm.DB, implementedRequirementID uuid.UUID, statementID string) (*relational.Statement, error) { +func findOrCreateStatement(tx *gorm.DB, implementedRequirementID uuid.UUID, statementID string) (stmt *relational.Statement, created bool, err error) { var existing relational.Statement - err := tx.Where("implemented_requirement_id = ? AND statement_id = ?", implementedRequirementID, statementID). + err = tx.Where("implemented_requirement_id = ? AND statement_id = ?", implementedRequirementID, statementID). First(&existing).Error if err == nil { - return &existing, nil + return &existing, false, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err + return nil, false, err } - created := relational.Statement{ + inserted := relational.Statement{ ImplementedRequirementId: implementedRequirementID, StatementId: statementID, } - if err := tx.Create(&created).Error; err != nil { - return nil, err + if err := tx.Create(&inserted).Error; err != nil { + return nil, false, err } - return &created, nil + return &inserted, true, nil } // findOrCreateByComponent finds the ByComponent row for (parentID, parentType, // componentUUID), creating one if none exists. parentType is "implemented_requirements" -// or "statements", matching the string constants used throughout system_security_plans.go. -func findOrCreateByComponent(tx *gorm.DB, parentID uuid.UUID, parentType string, componentUUID uuid.UUID) (*relational.ByComponent, error) { +// or "statements", matching the string constants used throughout system_security_plans.go +// — though Subscribe only ever passes "statements" now that the statement is the canonical +// anchor. +func findOrCreateByComponent(tx *gorm.DB, parentID uuid.UUID, parentType string, componentUUID uuid.UUID) (bc *relational.ByComponent, created bool, err error) { var existing relational.ByComponent - err := tx.Where("parent_id = ? AND parent_type = ? AND component_uuid = ?", parentID, parentType, componentUUID). + err = tx.Where("parent_id = ? AND parent_type = ? AND component_uuid = ?", parentID, parentType, componentUUID). First(&existing).Error if err == nil { - return &existing, nil + return &existing, false, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err + return nil, false, err } - created := relational.ByComponent{ + inserted := relational.ByComponent{ ParentID: &parentID, ParentType: &parentType, ComponentUUID: componentUUID, } - if err := tx.Create(&created).Error; err != nil { - return nil, err + if err := tx.Create(&inserted).Error; err != nil { + return nil, false, err } - return &created, nil + return &inserted, true, nil +} + +// subscribeCreatedRequirement / subscribeCreatedStatement / subscribeCreatedByComponent +// report one row Subscribe touched while materializing the downstream tree, with created +// distinguishing an insert from a reuse. +type subscribeCreatedRequirement struct { + UUID uuid.UUID `json:"uuid"` + ControlID string `json:"controlId"` + Created bool `json:"created"` +} + +type subscribeCreatedStatement struct { + UUID uuid.UUID `json:"uuid"` + StatementID string `json:"statementId"` + ImplementedRequirementUUID uuid.UUID `json:"implementedRequirementUuid"` + Created bool `json:"created"` +} + +type subscribeCreatedByComponent struct { + UUID uuid.UUID `json:"uuid"` + StatementUUID uuid.UUID `json:"statementUuid"` + ComponentUUID uuid.UUID `json:"componentUuid"` + Created bool `json:"created"` +} + +// subscribeCreated is the tree Subscribe materialized on the downstream, so the UI can +// render newly-created requirements straight from the subscribe response instead of +// re-walking the SSP. +type subscribeCreated struct { + ImplementedRequirements []subscribeCreatedRequirement `json:"implementedRequirements"` + Statements []subscribeCreatedStatement `json:"statements"` + ByComponents []subscribeCreatedByComponent `json:"byComponents"` +} + +// subscribeMeta rides in GenericDataListResponse's existing Meta field rather than changing +// the response envelope: Data stays exactly the SSPLeverageLink[] every current caller +// already reads, and the created block is purely additive. +type subscribeMeta struct { + Created subscribeCreated `json:"created"` +} + +// subscribeCreationTracker accumulates the created-tree across a subscribe request's items, +// deduplicating by row id — several items can land on the same requirement or statement, and +// the first sighting is the one that knows whether it was inserted. +type subscribeCreationTracker struct { + created subscribeCreated + seenReqs map[uuid.UUID]bool + seenStmts map[uuid.UUID]bool + seenByComps map[uuid.UUID]bool +} + +func newSubscribeCreationTracker() *subscribeCreationTracker { + return &subscribeCreationTracker{ + created: subscribeCreated{ + ImplementedRequirements: []subscribeCreatedRequirement{}, + Statements: []subscribeCreatedStatement{}, + ByComponents: []subscribeCreatedByComponent{}, + }, + seenReqs: map[uuid.UUID]bool{}, + seenStmts: map[uuid.UUID]bool{}, + seenByComps: map[uuid.UUID]bool{}, + } +} + +func (t *subscribeCreationTracker) addRequirement(req *relational.ImplementedRequirement, created bool) { + if t.seenReqs[*req.ID] { + return + } + t.seenReqs[*req.ID] = true + t.created.ImplementedRequirements = append(t.created.ImplementedRequirements, subscribeCreatedRequirement{ + UUID: *req.ID, + ControlID: req.ControlId, + Created: created, + }) +} + +func (t *subscribeCreationTracker) addStatement(stmt *relational.Statement, created bool) { + if t.seenStmts[*stmt.ID] { + return + } + t.seenStmts[*stmt.ID] = true + t.created.Statements = append(t.created.Statements, subscribeCreatedStatement{ + UUID: *stmt.ID, + StatementID: stmt.StatementId, + ImplementedRequirementUUID: stmt.ImplementedRequirementId, + Created: created, + }) +} + +func (t *subscribeCreationTracker) addByComponent(bc *relational.ByComponent, statementUUID uuid.UUID, created bool) { + if t.seenByComps[*bc.ID] { + return + } + t.seenByComps[*bc.ID] = true + t.created.ByComponents = append(t.created.ByComponents, subscribeCreatedByComponent{ + UUID: *bc.ID, + StatementUUID: statementUUID, + ComponentUUID: bc.ComponentUUID, + Created: created, + }) } type subscribeLeveragedAuthorizationRequest struct { @@ -399,6 +504,13 @@ func (r subscribeRequest) validate() error { // @Description upstream SSP: the trust boundary is that subscribing to a published offering // @Description only requires ssp-export-offering:subscribe on the offering and ssp:update on // @Description the downstream SSP. +// @Description +// @Description Every subscribed item must be statement-anchored: a legacy offering item with +// @Description no statement-id is rejected with 422. The materialized downstream tree is +// @Description always requirement -> statement -> by-component -> inherited + satisfied, and is +// @Description reported back in meta.created (each row flagged created:true when inserted, +// @Description false when an existing row was reused) so the caller can render newly-created +// @Description requirements without re-walking the SSP. The data payload is unchanged. // @Tags SSP Export Offerings // @Accept json // @Produce json @@ -409,6 +521,7 @@ func (r subscribeRequest) validate() error { // @Failure 403 {object} api.Error // @Failure 404 {object} api.Error // @Failure 409 {object} api.Error +// @Failure 422 {object} api.Error // @Failure 500 {object} api.Error // @Security OAuth2Password // @Router /oscal/ssp-export-offerings/{id}/subscribe [post] @@ -459,6 +572,15 @@ func (h *SSPLeverageHandler) Subscribe(ctx echo.Context) error { if !ok { return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("unknown offering item id %q", reqItem.ItemID))) } + // The statement is the canonical anchor: a legacy item with no statement-id cannot + // be subscribed to, because there is no clause to attribute the inherited + // responsibility against. Fail loudly rather than falling back to anchoring the + // by-component at the requirement level — that fallback is what produced + // requirement-anchored rows the API could never delete. + if item.StatementID == nil || strings.TrimSpace(*item.StatementID) == "" { + return ctx.JSON(http.StatusUnprocessableEntity, api.NewError(fmt.Errorf( + "offering item %q has no statement-id: shared responsibility is tracked per statement, so this legacy item must be re-curated against a statement before it can be subscribed to", reqItem.ItemID))) + } // A request can't subscribe to the same provided-uuid twice in one call: besides // being nonsensical, it would otherwise slip past the existing-link pre-check // below (which only compares against already-committed rows) and hit the @@ -546,7 +668,12 @@ func (h *SSPLeverageHandler) Subscribe(ctx echo.Context) error { now := time.Now() var links []relational.SSPLeverageLink + tracker := newSubscribeCreationTracker() if err := h.db.Transaction(func(tx *gorm.DB) error { + // A retried transaction must not double-report rows from the abandoned attempt. + tracker = newSubscribeCreationTracker() + links = nil + downstreamAllowed, err := isDownstreamAllowed(tx, offeringID, downstreamSSPID) if err != nil { return err @@ -573,26 +700,25 @@ func (h *SSPLeverageHandler) Subscribe(ctx echo.Context) error { for _, reqItem := range req.Items { item := itemsByID[uuid.MustParse(reqItem.ItemID)] - implReq, err := findOrCreateImplementedRequirement(tx, *downstream.ControlImplementation.ID, item.ControlID) + implReq, reqCreated, err := findOrCreateImplementedRequirement(tx, *downstream.ControlImplementation.ID, item.ControlID) if err != nil { return err } + tracker.addRequirement(implReq, reqCreated) - parentID := *implReq.ID - parentType := "implemented_requirements" - if item.StatementID != nil { - stmt, err := findOrCreateStatement(tx, *implReq.ID, *item.StatementID) - if err != nil { - return err - } - parentID = *stmt.ID - parentType = "statements" + // item.StatementID is guaranteed non-empty by the pre-transaction check above, + // so the materialized tree is always requirement -> statement -> by-component. + stmt, stmtCreated, err := findOrCreateStatement(tx, *implReq.ID, *item.StatementID) + if err != nil { + return err } + tracker.addStatement(stmt, stmtCreated) - byComponent, err := findOrCreateByComponent(tx, parentID, parentType, *thisSystemComponent.ID) + byComponent, bcCreated, err := findOrCreateByComponent(tx, *stmt.ID, "statements", *thisSystemComponent.ID) if err != nil { return err } + tracker.addByComponent(byComponent, *stmt.ID, bcCreated) inherited := relational.InheritedControlImplementation{ ByComponentId: *byComponent.ID, @@ -666,7 +792,10 @@ func (h *SSPLeverageHandler) Subscribe(ctx echo.Context) error { return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } - return ctx.JSON(http.StatusCreated, handler.GenericDataListResponse[relational.SSPLeverageLink]{Data: links}) + return ctx.JSON(http.StatusCreated, handler.GenericDataListResponse[relational.SSPLeverageLink]{ + Data: links, + Meta: subscribeMeta{Created: tracker.created}, + }) } type leveragedControlInheritedFrom struct { @@ -733,23 +862,72 @@ func (h *SSPLeverageHandler) LeveragedControls(ctx echo.Context) error { return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } - var links []relational.SSPLeverageLink - if err := h.db.Where("downstream_ssp_id = ?", sspID).Find(&links).Error; err != nil { - h.sugar.Errorf("Failed to list leverage links: %v", err) + projection, err := projectLeveragedControls(h.db, sspID) + if err != nil { + h.sugar.Errorf("Failed to project leveraged controls: %v", err) return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } + result := make([]leveragedControlResponse, 0, len(projection)) + for _, p := range projection { + result = append(result, leveragedControlResponse{ + ID: *p.Link.ID, + ControlID: p.Link.ControlID, + StatementID: p.Link.StatementID, + InheritedFrom: leveragedControlInheritedFrom{ + UpstreamSSPID: p.Link.UpstreamSSPID, + OfferingID: p.Link.OfferingID, + OfferingTitle: p.OfferingTitle, + OfferingVersion: p.Link.OfferingVersion, + }, + Satisfaction: p.Satisfaction, + Status: p.Link.Status, + OutstandingResponsibilities: p.Outstanding, + ResponsibilityPosture: p.Posture, + DriftRiskID: p.DriftRiskID, + }) + } + + 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 + Posture map[uuid.UUID]string + DriftRiskID *uuid.UUID +} + +// projectLeveragedControls builds the projection for every leverage link on one downstream +// SSP in a fixed number of queries (six), 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. +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 ctx.JSON(http.StatusOK, handler.GenericDataListResponse[leveragedControlResponse]{Data: []leveragedControlResponse{}}) + return []leveragedControlProjection{}, nil } - // Batch every per-link lookup up front (four queries total, independent of how many - // links this SSP has) instead of the four-queries-per-link loop this used to be. offeringIDs := uniqueUUIDs(links, func(l relational.SSPLeverageLink) uuid.UUID { return l.OfferingID }) var offerings []relational.SSPExportOffering - if err := h.db.Select("id, title").Where("id IN ?", offeringIDs).Find(&offerings).Error; err != nil { - h.sugar.Errorf("Failed to load offerings for leverage links: %v", err) - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + 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 { @@ -758,28 +936,25 @@ func (h *SSPLeverageHandler) LeveragedControls(ctx echo.Context) error { inheritedIDs := uniqueUUIDs(links, func(l relational.SSPLeverageLink) uuid.UUID { return l.InheritedUUID }) var inheritedRows []relational.InheritedControlImplementation - if err := h.db.Where("id IN ?", inheritedIDs).Find(&inheritedRows).Error; err != nil { - h.sugar.Errorf("Failed to load inherited control implementations for leverage links: %v", err) - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + 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) } - byComponentIDByInherited := make(map[uuid.UUID]uuid.UUID, len(inheritedRows)) + inheritedByID := make(map[uuid.UUID]relational.InheritedControlImplementation, len(inheritedRows)) for _, i := range inheritedRows { - byComponentIDByInherited[*i.ID] = i.ByComponentId + inheritedByID[*i.ID] = i } providedUUIDs := uniqueUUIDs(links, func(l relational.SSPLeverageLink) uuid.UUID { return l.ProvidedUUID }) - fullSetByProvided, err := bulkResolveUpstreamResponsibilities(h.db, providedUUIDs) + fullSetByProvided, err := bulkResolveUpstreamResponsibilities(db, providedUUIDs) if err != nil { - h.sugar.Errorf("Failed to resolve upstream responsibilities for leverage links: %v", err) - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + 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 := h.db.Where("by_component_id IN ?", byComponentIDs).Find(&satisfiedRows).Error; err != nil { - h.sugar.Errorf("Failed to load satisfied responsibilities for leverage links: %v", err) - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + 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)) @@ -798,10 +973,9 @@ func (h *SSPLeverageHandler) LeveragedControls(ctx echo.Context) error { allResponsibilityUUIDs = append(allResponsibilityUUIDs, r.ResponsibilityUUID) } } - posture, err := ResponsibilityPosture(h.db, sspID, allResponsibilityUUIDs) + posture, err := ResponsibilityPosture(db, sspID, allResponsibilityUUIDs) if err != nil { - h.sugar.Errorf("Failed to compute responsibility posture for leverage links: %v", err) - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + 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 @@ -820,11 +994,10 @@ func (h *SSPLeverageHandler) LeveragedControls(ctx echo.Context) error { driftRiskIDByLinkID := make(map[uuid.UUID]uuid.UUID, len(dedupeKeys)) if len(dedupeKeys) > 0 { var driftRisks []risks.Risk - if err := h.db.Select("id, dedupe_key"). + 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 { - h.sugar.Errorf("Failed to load drift risks for leverage links: %v", err) - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + 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 { @@ -833,9 +1006,15 @@ func (h *SSPLeverageHandler) LeveragedControls(ctx echo.Context) error { } } - result := make([]leveragedControlResponse, 0, len(links)) + result := make([]leveragedControlProjection, 0, len(links)) for _, link := range links { - byComponentID := byComponentIDByInherited[link.InheritedUUID] + 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]) @@ -849,25 +1028,19 @@ func (h *SSPLeverageHandler) LeveragedControls(ctx echo.Context) error { driftRiskID = &id } - result = append(result, leveragedControlResponse{ - ID: *link.ID, - ControlID: link.ControlID, - StatementID: link.StatementID, - InheritedFrom: leveragedControlInheritedFrom{ - UpstreamSSPID: link.UpstreamSSPID, - OfferingID: link.OfferingID, - OfferingTitle: offeringTitleByID[link.OfferingID], - OfferingVersion: link.OfferingVersion, - }, - Satisfaction: satisfaction, - Status: link.Status, - OutstandingResponsibilities: outstanding, - ResponsibilityPosture: linkPosture, - DriftRiskID: driftRiskID, + result = append(result, leveragedControlProjection{ + Link: link, + OfferingTitle: offeringTitleByID[link.OfferingID], + ByComponentID: byComponentID, + Inherited: inherited, + Satisfaction: satisfaction, + Outstanding: outstanding, + Posture: linkPosture, + DriftRiskID: driftRiskID, }) } - return ctx.JSON(http.StatusOK, handler.GenericDataListResponse[leveragedControlResponse]{Data: result}) + return result, nil } // ReAttest clears drift on a leverage link (BCH-1341): bumps OfferingVersion to the diff --git a/internal/api/handler/oscal/ssp_leverage_allowlist_integration_test.go b/internal/api/handler/oscal/ssp_leverage_allowlist_integration_test.go index 7b4b2609..2fb463f2 100644 --- a/internal/api/handler/oscal/ssp_leverage_allowlist_integration_test.go +++ b/internal/api/handler/oscal/ssp_leverage_allowlist_integration_test.go @@ -41,7 +41,10 @@ func (suite *SubscribeAllowlistIntegrationSuite) SetupTest() { // contributorToken, and returns (upstreamSSPID, offeringID, itemID). func (suite *SubscribeAllowlistIntegrationSuite) publishedOfferingFixture(contributorToken string) (upstreamSSPID, offeringID, itemID string) { componentUUID := uuid.New().String() - ssp := minimalSSP(componentUUID) + // The offered capability has to be a real statement-anchored export inside this SSP: an item + // now requires a statementId, and the whole tuple must resolve. + acOne := exportedStatement{ControlID: "ac-1", StatementID: "ac-1_stmt.a", ProvidedUUID: uuid.New().String()} + ssp := sspWithStatementExports(componentUUID, acOne) rec := authedRequest(&suite.IntegrationTestSuite, suite.server, "POST", "/api/oscal/system-security-plans", contributorToken, ssp) suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) upstreamSSPID = ssp.UUID @@ -57,9 +60,8 @@ func (suite *SubscribeAllowlistIntegrationSuite) publishedOfferingFixture(contri offeringID = created.Data.ID.String() itemPath := fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings/%s/items", upstreamSSPID, offeringID) - rec = authedRequest(&suite.IntegrationTestSuite, suite.server, "POST", itemPath, contributorToken, map[string]any{ - "controlId": "ac-1", "componentUuid": componentUUID, "providedUuid": uuid.New().String(), - }) + rec = authedRequest(&suite.IntegrationTestSuite, suite.server, "POST", itemPath, contributorToken, + offeringItemBody(componentUUID, acOne)) suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) var item handler.GenericDataResponse[relational.SSPExportOfferingItem] suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &item)) diff --git a/internal/api/handler/oscal/ssp_leverage_drift_integration_test.go b/internal/api/handler/oscal/ssp_leverage_drift_integration_test.go index 0209c795..592fdf0c 100644 --- a/internal/api/handler/oscal/ssp_leverage_drift_integration_test.go +++ b/internal/api/handler/oscal/ssp_leverage_drift_integration_test.go @@ -51,13 +51,28 @@ type driftFixture struct { respID uuid.UUID } +// driftStatementID is the statement the fixture's leverageable capability is exported from. +// Subscribe rejects a statementless offering item with 422, so the fixture's exporting +// by-component is anchored on a real statement, as the model now requires everywhere. +const driftStatementID = "ac-1_smt.a" + func seedDriftFixture(suite *LeverageDriftIntegrationSuite) driftFixture { db := suite.DB upstreamSSP := relational.SystemSecurityPlan{} suite.Require().NoError(db.Create(&upstreamSSP).Error) + upstreamImpl := relational.ControlImplementation{SystemSecurityPlanId: *upstreamSSP.ID} + suite.Require().NoError(db.Create(&upstreamImpl).Error) + requirement := relational.ImplementedRequirement{ControlImplementationId: *upstreamImpl.ID, ControlId: "ac-1"} + suite.Require().NoError(db.Create(&requirement).Error) + statement := relational.Statement{ImplementedRequirementId: *requirement.ID, StatementId: driftStatementID} + suite.Require().NoError(db.Create(&statement).Error) + + statementsType := "statements" byComponent := relational.ByComponent{ + ParentID: statement.ID, + ParentType: &statementsType, ImplementationStatus: datatypes.NewJSONType(relational.ImplementationStatus{State: relational.ImplementationStatusImplemented}), } suite.Require().NoError(db.Create(&byComponent).Error) @@ -74,8 +89,10 @@ func seedDriftFixture(suite *LeverageDriftIntegrationSuite) driftFixture { offering := relational.SSPExportOffering{SSPID: *upstreamSSP.ID, Title: "Offering", Status: relational.SSPExportOfferingStatusDraft} suite.Require().NoError(db.Create(&offering).Error) + statementID := driftStatementID item := relational.SSPExportOfferingItem{ - OfferingID: *offering.ID, ControlID: "ac-1", ComponentUUID: byComponent.ComponentUUID, ProvidedUUID: *provided.ID, + OfferingID: *offering.ID, ControlID: "ac-1", StatementID: &statementID, + ComponentUUID: byComponent.ComponentUUID, ProvidedUUID: *provided.ID, } suite.Require().NoError(db.Create(&item).Error) diff --git a/internal/api/handler/oscal/ssp_leverage_test.go b/internal/api/handler/oscal/ssp_leverage_test.go index e7fde9dc..e9291204 100644 --- a/internal/api/handler/oscal/ssp_leverage_test.go +++ b/internal/api/handler/oscal/ssp_leverage_test.go @@ -31,24 +31,36 @@ func TestSSPLeverageIntegrationSuite(t *testing.T) { suite.Run(t, new(SSPLeverageIntegrationSuite)) } +// leverageStatementID is the statement the fixture's leverageable capability is exported from. +// The statement is the canonical anchor for shared responsibility, so an Export — and the +// offering item pointing at it — hangs off a statement-level by-component, never a +// requirement-level one. +const leverageStatementID = "ac-1_smt.a" + // sspWithLeverageableCapability builds on minimalSSP by giving the ac-1 -// implemented-requirement a by-component with an Export: one provided control -// implementation and two responsibilities under it — the fixture the subscribe/leverage -// flow needs on the upstream side. +// implemented-requirement a statement, and that statement a by-component with an Export: one +// provided control implementation and two responsibilities under it — the fixture the +// subscribe/leverage flow needs on the upstream side. func sspWithLeverageableCapability(componentUUID, providedUUID, respAUUID, respBUUID string) *oscalTypes_1_1_3.SystemSecurityPlan { ssp := minimalSSP(componentUUID) - ssp.ControlImplementation.ImplementedRequirements[0].ByComponents = &[]oscalTypes_1_1_3.ByComponent{ + ssp.ControlImplementation.ImplementedRequirements[0].Statements = &[]oscalTypes_1_1_3.Statement{ { - UUID: uuid.New().String(), - ComponentUuid: componentUUID, - Description: "AC-1 implemented by test component", - Export: &oscalTypes_1_1_3.Export{ - Provided: &[]oscalTypes_1_1_3.ProvidedControlImplementation{ - {UUID: providedUUID, Description: "Provides AC-1 capability"}, - }, - Responsibilities: &[]oscalTypes_1_1_3.ControlImplementationResponsibility{ - {UUID: respAUUID, ProvidedUuid: providedUUID, Description: "Responsibility A"}, - {UUID: respBUUID, ProvidedUuid: providedUUID, Description: "Responsibility B"}, + UUID: uuid.New().String(), + StatementId: leverageStatementID, + ByComponents: &[]oscalTypes_1_1_3.ByComponent{ + { + UUID: uuid.New().String(), + ComponentUuid: componentUUID, + Description: "AC-1 statement (a) implemented by test component", + Export: &oscalTypes_1_1_3.Export{ + Provided: &[]oscalTypes_1_1_3.ProvidedControlImplementation{ + {UUID: providedUUID, Description: "Provides AC-1 capability"}, + }, + Responsibilities: &[]oscalTypes_1_1_3.ControlImplementationResponsibility{ + {UUID: respAUUID, ProvidedUuid: providedUUID, Description: "Responsibility A"}, + {UUID: respBUUID, ProvidedUuid: providedUUID, Description: "Responsibility B"}, + }, + }, }, }, }, @@ -56,6 +68,18 @@ func sspWithLeverageableCapability(componentUUID, providedUUID, respAUUID, respB return ssp } +// leverageOfferingItemBody is the statement-anchored offering item pointing at the fixture's +// provided capability. statementId is required on every write, and the whole (controlId, +// statementId, componentUuid, providedUuid) tuple must actually resolve inside the upstream SSP. +func leverageOfferingItemBody(componentUUID, providedUUID string) map[string]any { + return map[string]any{ + "controlId": "ac-1", + "statementId": leverageStatementID, + "componentUuid": componentUUID, + "providedUuid": providedUUID, + } +} + // TestSubscribePartialThenProjection is the ticket's core integration scenario: a // downstream subscriber holding only ssp-export-offering:subscribe + ssp:update (no // ssp:read anywhere, in particular none on the upstream) subscribes to an offering item @@ -97,7 +121,7 @@ func (suite *SSPLeverageIntegrationSuite) TestSubscribePartialThenProjection() { rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings/%s/items", upstreamSSP.UUID, offeringID), contributorToken, - map[string]any{"controlId": "ac-1", "componentUuid": upstreamComponentUUID, "providedUuid": providedUUID}, + leverageOfferingItemBody(upstreamComponentUUID, providedUUID), ) suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) var createdItem handler.GenericDataResponse[relational.SSPExportOfferingItem] @@ -167,13 +191,15 @@ func (suite *SSPLeverageIntegrationSuite) TestSubscribePartialThenProjection() { suite.Require().Len(projection.Data[0].OutstandingResponsibilities, 1) suite.Equal(respBUUID, projection.Data[0].OutstandingResponsibilities[0].ResponsibilityUUID.String()) - // AC #4: the downstream's OSCAL subtree, marshaled with zero new export code, - // contains the inherited/satisfied/leveraged-authorization entries subscribe wrote. + // AC #4: the downstream's OSCAL subtree, marshaled with zero new export code, contains the + // inherited/satisfied/leveraged-authorization entries subscribe wrote — and the tree it + // materialized is requirement -> statement -> by-component, never requirement-anchored. var downstream relational.SystemSecurityPlan suite.Require().NoError(suite.DB. Preload("SystemImplementation.LeveragedAuthorizations"). - Preload("ControlImplementation.ImplementedRequirements.ByComponents.Inherited"). - Preload("ControlImplementation.ImplementedRequirements.ByComponents.Satisfied"). + Preload("ControlImplementation.ImplementedRequirements.ByComponents"). + Preload("ControlImplementation.ImplementedRequirements.Statements.ByComponents.Inherited"). + Preload("ControlImplementation.ImplementedRequirements.Statements.ByComponents.Satisfied"). First(&downstream, "id = ?", downstreamSSP.UUID).Error) marshaled := downstream.MarshalOscal() @@ -183,28 +209,39 @@ func (suite *SSPLeverageIntegrationSuite) TestSubscribePartialThenProjection() { var foundInherited, foundSatisfied bool for _, req := range marshaled.ControlImplementation.ImplementedRequirements { - if req.ControlId != "ac-1" || req.ByComponents == nil { + if req.ControlId != "ac-1" { continue } - for _, bc := range *req.ByComponents { - if bc.Inherited != nil { - for _, inh := range *bc.Inherited { - if inh.ProvidedUuid == providedUUID { - foundInherited = true + + // Nothing is anchored at the requirement level any more: the statement is the + // canonical anchor, and Subscribe no longer has a requirement-anchoring fallback. + suite.Nil(req.ByComponents, "subscribe must not anchor a by-component at the requirement level") + + suite.Require().NotNil(req.Statements) + for _, stmt := range *req.Statements { + if stmt.StatementId != leverageStatementID || stmt.ByComponents == nil { + continue + } + for _, bc := range *stmt.ByComponents { + if bc.Inherited != nil { + for _, inh := range *bc.Inherited { + if inh.ProvidedUuid == providedUUID { + foundInherited = true + } } } - } - if bc.Satisfied != nil { - for _, sat := range *bc.Satisfied { - if sat.ResponsibilityUuid == respAUUID { - foundSatisfied = true + if bc.Satisfied != nil { + for _, sat := range *bc.Satisfied { + if sat.ResponsibilityUuid == respAUUID { + foundSatisfied = true + } } } } } } - suite.True(foundInherited, "expected an inherited-control-implementation referencing the upstream provided-uuid") - suite.True(foundSatisfied, "expected a satisfied responsibility referencing respA") + suite.True(foundInherited, "expected an inherited-control-implementation referencing the upstream provided-uuid, on a statement-anchored by-component") + suite.True(foundSatisfied, "expected a satisfied responsibility referencing respA, on a statement-anchored by-component") } // TestSubscribeRequiresSSPUpdateOnDownstream: a viewer (ssp:read on everything, but no @@ -234,7 +271,7 @@ func (suite *SSPLeverageIntegrationSuite) TestSubscribeRequiresSSPUpdateOnDownst rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings/%s/items", upstreamSSP.UUID, offeringID), - contributorToken, map[string]any{"controlId": "ac-1", "componentUuid": componentUUID, "providedUuid": providedUUID}) + contributorToken, leverageOfferingItemBody(componentUUID, providedUUID)) suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) var createdItem handler.GenericDataResponse[relational.SSPExportOfferingItem] suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &createdItem)) @@ -296,7 +333,7 @@ func (suite *SSPLeverageIntegrationSuite) TestResponsibilityFilterFlipsPosture() rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings/%s/items", upstreamSSP.UUID, offeringID), - contributorToken, map[string]any{"controlId": "ac-1", "componentUuid": upstreamComponentUUID, "providedUuid": providedUUID}) + contributorToken, leverageOfferingItemBody(upstreamComponentUUID, providedUUID)) suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) var createdItem handler.GenericDataResponse[relational.SSPExportOfferingItem] suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &createdItem)) diff --git a/internal/api/handler/oscal/ssp_leverage_unit_test.go b/internal/api/handler/oscal/ssp_leverage_unit_test.go index 2a6bb344..8b092830 100644 --- a/internal/api/handler/oscal/ssp_leverage_unit_test.go +++ b/internal/api/handler/oscal/ssp_leverage_unit_test.go @@ -43,6 +43,14 @@ func newSSPLeverageTestDB(t *testing.T) *gorm.DB { &relational.InheritedControlImplementation{}, &relational.SatisfiedControlImplementationResponsibility{}, &relational.LeveragedAuthorization{}, + // ResponsibleRole (with its Party m2m and Role) is polymorphically attached to + // by-components, provided, responsibilities, inherited and satisfied — every + // by-component read preloads it, so the schema has to carry it. + &relational.ResponsibleRole{}, + &relational.Party{}, + &relational.Role{}, + // The control-centric read models resolve an SSP's title through its Metadata. + &relational.Metadata{}, &relational.Filter{}, &relational.FilterResponsibility{}, &relational.Profile{}, @@ -114,8 +122,10 @@ func newLeverageFixture(t *testing.T, db *gorm.DB) leverageFixture { } require.NoError(t, db.Create(&offering).Error) + // Statement-anchored: the statement is the canonical anchor for shared responsibility, and + // Subscribe rejects a NULL-statement (legacy) item with 422. item := relational.SSPExportOfferingItem{ - OfferingID: *offering.ID, ControlID: "ac-1", + OfferingID: *offering.ID, ControlID: "ac-1", StatementID: statementID("ac-1_smt.a"), ComponentUUID: uuid.New(), ProvidedUUID: *provided.ID, } require.NoError(t, db.Create(&item).Error) @@ -284,23 +294,54 @@ func TestFindOrCreateThisSystemComponent(t *testing.T) { // TestFindOrCreateImplementedRequirement: creates a requirement for a control_id when // none exists under the given ControlImplementation, and returns the same row (not a -// duplicate) on a second call for the same control_id. +// duplicate) on a second call for the same control_id. The created flag reports which of +// the two happened — Subscribe's meta.created block is built from it. func TestFindOrCreateImplementedRequirement(t *testing.T) { db := newSSPLeverageTestDB(t) ci := relational.ControlImplementation{} require.NoError(t, db.Create(&ci).Error) - first, err := findOrCreateImplementedRequirement(db, *ci.ID, "ac-1") + first, created, err := findOrCreateImplementedRequirement(db, *ci.ID, "ac-1") require.NoError(t, err) require.Equal(t, "ac-1", first.ControlId) + require.True(t, created) - second, err := findOrCreateImplementedRequirement(db, *ci.ID, "ac-1") + second, created, err := findOrCreateImplementedRequirement(db, *ci.ID, "ac-1") require.NoError(t, err) require.Equal(t, *first.ID, *second.ID) + require.False(t, created) - third, err := findOrCreateImplementedRequirement(db, *ci.ID, "ac-2") + third, created, err := findOrCreateImplementedRequirement(db, *ci.ID, "ac-2") require.NoError(t, err) require.NotEqual(t, *first.ID, *third.ID) + require.True(t, created) +} + +// TestFindOrCreateStatement: creates a Statement for a statement_id when none exists under +// the given ImplementedRequirement, returns the same row on a second call, and reports which +// happened via created. Subscribe now always goes through this helper — the statement is the +// canonical anchor, so there is no requirement-anchored path left. +func TestFindOrCreateStatement(t *testing.T) { + db := newSSPLeverageTestDB(t) + ci := relational.ControlImplementation{} + require.NoError(t, db.Create(&ci).Error) + ir := relational.ImplementedRequirement{ControlImplementationId: *ci.ID, ControlId: "ac-1"} + require.NoError(t, db.Create(&ir).Error) + + first, created, err := findOrCreateStatement(db, *ir.ID, "ac-1_smt.a") + require.NoError(t, err) + require.Equal(t, "ac-1_smt.a", first.StatementId) + require.True(t, created) + + second, created, err := findOrCreateStatement(db, *ir.ID, "ac-1_smt.a") + require.NoError(t, err) + require.Equal(t, *first.ID, *second.ID) + require.False(t, created) + + third, created, err := findOrCreateStatement(db, *ir.ID, "ac-1_smt.b") + require.NoError(t, err) + require.NotEqual(t, *first.ID, *third.ID) + require.True(t, created) } // TestFindOrCreateByComponent: creates a ByComponent row for a (parent, componentUUID) @@ -310,13 +351,15 @@ func TestFindOrCreateByComponent(t *testing.T) { parentID := uuid.New() componentUUID := uuid.New() - first, err := findOrCreateByComponent(db, parentID, "implemented_requirements", componentUUID) + first, created, err := findOrCreateByComponent(db, parentID, "statements", componentUUID) require.NoError(t, err) require.Equal(t, componentUUID, first.ComponentUUID) + require.True(t, created) - second, err := findOrCreateByComponent(db, parentID, "implemented_requirements", componentUUID) + second, created, err := findOrCreateByComponent(db, parentID, "statements", componentUUID) require.NoError(t, err) require.Equal(t, *first.ID, *second.ID) + require.False(t, created) } // TestSubscribePartialSatisfactionWritesAtomically: subscribing to an item whose diff --git a/internal/api/handler/oscal/ssp_shared_responsibility.go b/internal/api/handler/oscal/ssp_shared_responsibility.go new file mode 100644 index 00000000..d688dbb2 --- /dev/null +++ b/internal/api/handler/oscal/ssp_shared_responsibility.go @@ -0,0 +1,414 @@ +package oscal + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "gorm.io/gorm" + + "github.com/compliance-framework/api/internal/api" + "github.com/compliance-framework/api/internal/api/handler" + "github.com/compliance-framework/api/internal/service/relational" + oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" +) + +// SharedResponsibilityProvides is one statement-anchored by-component this SSP exports from: +// what it provides, and what it makes a consumer responsible for. +type SharedResponsibilityProvides struct { + ControlID string `json:"controlId"` + StatementID string `json:"statementId"` + + ByComponentUUID uuid.UUID `json:"byComponentUuid"` + ComponentUUID uuid.UUID `json:"componentUuid"` + ComponentTitle string `json:"componentTitle"` + + ExportUUID uuid.UUID `json:"exportUuid"` + + Provided []controlExportProvided `json:"provided"` + Responsibilities []controlExportResponsibility `json:"responsibilities"` + + // Offered reports whether an offering item on this SSP already points at one of these + // provided-uuids — i.e. whether the capability is actually published for a downstream to + // find, as opposed to merely existing in the control-implementation tree. + Offered bool `json:"offered"` +} + +// SharedResponsibilityInherits is one upstream capability this SSP consumes, projected from +// the leverage link rather than re-derived — so satisfaction here always agrees with what +// GET /leveraged-controls reports. +type SharedResponsibilityInherits struct { + ControlID string `json:"controlId"` + StatementID *string `json:"statementId,omitempty"` + + ByComponentUUID uuid.UUID `json:"byComponentUuid"` + InheritedUUID uuid.UUID `json:"inheritedUuid"` + ProvidedUUID uuid.UUID `json:"providedUuid"` + + UpstreamSSPID uuid.UUID `json:"upstreamSspId"` + UpstreamSSPTitle string `json:"upstreamSspTitle"` + + OfferingID uuid.UUID `json:"offeringId"` + OfferingVersion int `json:"offeringVersion"` + + LeverageLinkID uuid.UUID `json:"leverageLinkId"` + Satisfaction relational.SSPLeverageSatisfaction `json:"satisfaction"` + Status relational.SSPLeverageStatus `json:"status"` + + Description string `json:"description"` +} + +// SharedResponsibilitySatisfies is one upstream responsibility this SSP has discharged. +type SharedResponsibilitySatisfies struct { + ControlID string `json:"controlId"` + StatementID string `json:"statementId"` + + ByComponentUUID uuid.UUID `json:"byComponentUuid"` + SatisfiedUUID uuid.UUID `json:"satisfiedUuid"` + ResponsibilityUUID uuid.UUID `json:"responsibilityUuid"` + + Description string `json:"description"` + ResponsibleRoles []oscalTypes_1_1_3.ResponsibleRole `json:"responsibleRoles"` +} + +// SharedResponsibilityLegacy is one requirement-anchored by-component still carrying shared +// responsibility. These can't be expressed in the statement-anchored model, so they're +// surfaced explicitly rather than silently dropped: they are exactly the rows the +// requirement-level DELETE exists to wind down. +type SharedResponsibilityLegacy struct { + ControlID string `json:"controlId"` + ByComponentUUID uuid.UUID `json:"byComponentUuid"` + Reason string `json:"reason"` +} + +// SharedResponsibilityRollup is everything one SSP provides, inherits and satisfies — +// flattened and statement-keyed, so neither the Controls page nor the Export Offerings tab +// has to walk the control-implementation tree to render it. +type SharedResponsibilityRollup struct { + Provides []SharedResponsibilityProvides `json:"provides"` + Inherits []SharedResponsibilityInherits `json:"inherits"` + Satisfies []SharedResponsibilitySatisfies `json:"satisfies"` + Legacy []SharedResponsibilityLegacy `json:"legacy"` +} + +// SharedResponsibility godoc +// +// @Summary Roll up everything one SSP provides, inherits and satisfies +// @Description A flat, statement-keyed projection of this SSP's shared-responsibility posture: +// @Description what it exports (with the provided capabilities and the responsibilities it +// @Description pushes onto consumers, and whether each is actually offered), what it inherits +// @Description from upstreams (with live-recomputed satisfaction and link status), how it +// @Description discharges upstream responsibilities, and any legacy requirement-anchored +// @Description by-components still to be migrated. +// @Description +// @Description The inherits arm reuses the same batched projection GET /leveraged-controls +// @Description serves, so the two can never disagree about satisfaction. Optionally filter the +// @Description whole rollup to one control with controlId. +// @Tags System Security Plans +// @Produce json +// @Param id path string true "SSP ID" +// @Param controlId query string false "Only include rows for this control" +// @Success 200 {object} handler.GenericDataResponse[SharedResponsibilityRollup] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/shared-responsibility [get] +func (h *SystemSecurityPlanHandler) SharedResponsibility(ctx echo.Context) error { + idParam := ctx.Param("id") + sspID, err := uuid.Parse(idParam) + if err != nil { + h.sugar.Warnw("Invalid SSP id", "id", idParam, "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + controlFilter := strings.TrimSpace(ctx.QueryParam("controlId")) + + var ssp relational.SystemSecurityPlan + if err := h.db.Preload("ControlImplementation").First(&ssp, "id = ?", sspID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("SSP not found"))) + } + h.sugar.Errorf("Failed to load SSP: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + rollup := SharedResponsibilityRollup{ + Provides: []SharedResponsibilityProvides{}, + Inherits: []SharedResponsibilityInherits{}, + Satisfies: []SharedResponsibilitySatisfies{}, + Legacy: []SharedResponsibilityLegacy{}, + } + + if ssp.ControlImplementation.ID != nil { + if err := h.collectOwnedSharedResponsibility(&rollup, sspID, *ssp.ControlImplementation.ID, controlFilter); err != nil { + h.sugar.Errorf("Failed to roll up owned shared responsibility: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + } + + if err := h.collectInheritedSharedResponsibility(&rollup, sspID, controlFilter); err != nil { + h.sugar.Errorf("Failed to roll up inherited shared responsibility: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.JSON(http.StatusOK, handler.GenericDataResponse[SharedResponsibilityRollup]{Data: rollup}) +} + +// collectOwnedSharedResponsibility fills the provides / satisfies / legacy arms from the SSP's +// own control-implementation tree, in a fixed number of queries regardless of tree size: the +// requirements, their statements, every by-component under either, and then the exports, +// satisfied rows, components and offering items in one batch each. +func (h *SystemSecurityPlanHandler) collectOwnedSharedResponsibility(rollup *SharedResponsibilityRollup, sspID, controlImplementationID uuid.UUID, controlFilter string) error { + requirementQuery := h.db.Where("control_implementation_id = ?", controlImplementationID) + if controlFilter != "" { + requirementQuery = requirementQuery.Where("UPPER(control_id) = UPPER(?)", controlFilter) + } + var requirements []relational.ImplementedRequirement + if err := requirementQuery.Order("id ASC").Find(&requirements).Error; err != nil { + return err + } + if len(requirements) == 0 { + return nil + } + requirementIDs := make([]uuid.UUID, 0, len(requirements)) + controlIDByRequirement := make(map[uuid.UUID]string, len(requirements)) + for _, r := range requirements { + requirementIDs = append(requirementIDs, *r.ID) + controlIDByRequirement[*r.ID] = r.ControlId + } + + var statements []relational.Statement + if err := h.db.Where("implemented_requirement_id IN ?", requirementIDs).Order("id ASC").Find(&statements).Error; err != nil { + return err + } + statementIDs := make([]uuid.UUID, 0, len(statements)) + statementByID := make(map[uuid.UUID]relational.Statement, len(statements)) + for _, s := range statements { + statementIDs = append(statementIDs, *s.ID) + statementByID[*s.ID] = s + } + + // One query for both anchoring levels: requirement-anchored rows are legacy but must + // still be reported, so they can't simply be filtered out of the read. + byComponentQuery := h.db.Where("parent_id IN ? AND parent_type = ?", requirementIDs, "implemented_requirements") + if len(statementIDs) > 0 { + byComponentQuery = byComponentQuery.Or("parent_id IN ? AND parent_type = ?", statementIDs, "statements") + } + var byComponents []relational.ByComponent + if err := byComponentQuery.Order("id ASC").Find(&byComponents).Error; err != nil { + return err + } + if len(byComponents) == 0 { + return nil + } + + byComponentIDs := make([]uuid.UUID, 0, len(byComponents)) + for _, bc := range byComponents { + byComponentIDs = append(byComponentIDs, *bc.ID) + } + + var exports []relational.Export + if err := h.db. + Preload("Provided"). + Preload("Responsibilities"). + Where("by_component_id IN ?", byComponentIDs). + Find(&exports).Error; err != nil { + return err + } + exportByComponent := make(map[uuid.UUID]relational.Export, len(exports)) + for _, e := range exports { + exportByComponent[e.ByComponentId] = e + } + + var satisfiedRows []relational.SatisfiedControlImplementationResponsibility + if err := h.db. + Preload("ResponsibleRoles.Parties"). + Where("by_component_id IN ?", byComponentIDs). + Order("id ASC"). + Find(&satisfiedRows).Error; err != nil { + return err + } + + componentUUIDs := uniqueUUIDs(byComponents, func(bc relational.ByComponent) uuid.UUID { return bc.ComponentUUID }) + var components []relational.SystemComponent + if err := h.db.Where("id IN ?", componentUUIDs).Find(&components).Error; err != nil { + return err + } + componentTitleByID := make(map[uuid.UUID]string, len(components)) + for _, c := range components { + componentTitleByID[*c.ID] = c.Title + } + + // Which of this SSP's provided-uuids are actually published in one of its own offerings. + var offeredItems []relational.SSPExportOfferingItem + if err := h.db. + Joins("JOIN ssp_export_offerings ON ssp_export_offerings.id = ssp_export_offering_items.offering_id"). + Where("ssp_export_offerings.ssp_id = ?", sspID). + Find(&offeredItems).Error; err != nil { + return err + } + offeredProvidedUUIDs := make(map[uuid.UUID]bool, len(offeredItems)) + for _, item := range offeredItems { + offeredProvidedUUIDs[item.ProvidedUUID] = true + } + + // Resolve each by-component's (controlId, statementId) once, and split legacy off. + type anchor struct { + controlID string + statementID string + legacy bool + } + anchorByComponent := make(map[uuid.UUID]anchor, len(byComponents)) + for _, bc := range byComponents { + if bc.ParentID == nil || bc.ParentType == nil { + continue + } + switch *bc.ParentType { + case "statements": + stmt, ok := statementByID[*bc.ParentID] + if !ok { + continue + } + anchorByComponent[*bc.ID] = anchor{ + controlID: controlIDByRequirement[stmt.ImplementedRequirementId], + statementID: stmt.StatementId, + } + case "implemented_requirements": + anchorByComponent[*bc.ID] = anchor{ + controlID: controlIDByRequirement[*bc.ParentID], + legacy: true, + } + } + } + + for _, bc := range byComponents { + a, ok := anchorByComponent[*bc.ID] + if !ok { + continue + } + + if a.legacy { + rollup.Legacy = append(rollup.Legacy, SharedResponsibilityLegacy{ + ControlID: a.controlID, + ByComponentUUID: *bc.ID, + Reason: "requirement-anchored export", + }) + continue + } + + export, hasExport := exportByComponent[*bc.ID] + if !hasExport { + continue + } + + provided := make([]controlExportProvided, 0, len(export.Provided)) + offered := false + for _, p := range export.Provided { + provided = append(provided, controlExportProvided{UUID: *p.ID, Description: p.Description}) + if offeredProvidedUUIDs[*p.ID] { + offered = true + } + } + + responsibilities := make([]controlExportResponsibility, 0, len(export.Responsibilities)) + for _, r := range export.Responsibilities { + responsibilities = append(responsibilities, controlExportResponsibility{ + UUID: *r.ID, + Description: r.Description, + ProvidedUUID: r.ProvidedUuid, + }) + } + + rollup.Provides = append(rollup.Provides, SharedResponsibilityProvides{ + ControlID: a.controlID, + StatementID: a.statementID, + ByComponentUUID: *bc.ID, + ComponentUUID: bc.ComponentUUID, + ComponentTitle: componentTitleByID[bc.ComponentUUID], + ExportUUID: *export.ID, + Provided: provided, + Responsibilities: responsibilities, + Offered: offered, + }) + } + + for i := range satisfiedRows { + s := satisfiedRows[i] + a, ok := anchorByComponent[s.ByComponentId] + if !ok || a.legacy { + continue + } + + roles := make([]oscalTypes_1_1_3.ResponsibleRole, 0, len(s.ResponsibleRoles)) + for j := range s.ResponsibleRoles { + roles = append(roles, *s.ResponsibleRoles[j].MarshalOscal()) + } + + rollup.Satisfies = append(rollup.Satisfies, SharedResponsibilitySatisfies{ + ControlID: a.controlID, + StatementID: a.statementID, + ByComponentUUID: s.ByComponentId, + SatisfiedUUID: *s.ID, + ResponsibilityUUID: s.ResponsibilityUuid, + Description: s.Description, + ResponsibleRoles: roles, + }) + } + + return nil +} + +// collectInheritedSharedResponsibility fills the inherits arm from the leverage projection — +// the same batched computation GET /leveraged-controls serves, so satisfaction is never +// re-derived here and the two surfaces cannot disagree. +func (h *SystemSecurityPlanHandler) collectInheritedSharedResponsibility(rollup *SharedResponsibilityRollup, sspID uuid.UUID, controlFilter string) error { + projection, err := projectLeveragedControls(h.db, sspID) + if err != nil { + return err + } + if len(projection) == 0 { + return nil + } + + upstreamIDs := uniqueUUIDs(projection, func(p leveragedControlProjection) uuid.UUID { return p.Link.UpstreamSSPID }) + var upstreams []relational.SystemSecurityPlan + if err := h.db.Preload("Metadata").Where("id IN ?", upstreamIDs).Find(&upstreams).Error; err != nil { + return err + } + upstreamTitleByID := make(map[uuid.UUID]string, len(upstreams)) + for _, s := range upstreams { + upstreamTitleByID[*s.ID] = s.Metadata.Title + } + + for _, p := range projection { + if controlFilter != "" && !strings.EqualFold(p.Link.ControlID, controlFilter) { + continue + } + + description := "" + if p.Inherited != nil { + description = p.Inherited.Description + } + + rollup.Inherits = append(rollup.Inherits, SharedResponsibilityInherits{ + ControlID: p.Link.ControlID, + StatementID: p.Link.StatementID, + ByComponentUUID: p.ByComponentID, + InheritedUUID: p.Link.InheritedUUID, + ProvidedUUID: p.Link.ProvidedUUID, + UpstreamSSPID: p.Link.UpstreamSSPID, + UpstreamSSPTitle: upstreamTitleByID[p.Link.UpstreamSSPID], + OfferingID: p.Link.OfferingID, + OfferingVersion: p.Link.OfferingVersion, + LeverageLinkID: *p.Link.ID, + Satisfaction: p.Satisfaction, + Status: p.Link.Status, + Description: description, + }) + } + + return nil +} diff --git a/internal/api/handler/oscal/ssp_shared_responsibility_test.go b/internal/api/handler/oscal/ssp_shared_responsibility_test.go new file mode 100644 index 00000000..bea76bd3 --- /dev/null +++ b/internal/api/handler/oscal/ssp_shared_responsibility_test.go @@ -0,0 +1,1032 @@ +package oscal + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/compliance-framework/api/internal/api/handler" + "github.com/compliance-framework/api/internal/authz" + "github.com/compliance-framework/api/internal/service/relational" + oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" +) + +// sharedResponsibilityFixture is one upstream SSP that exports a statement-anchored provided +// capability with two responsibilities, published in an offering — plus a downstream SSP ready +// to subscribe to it. It mirrors the real shape the statement-anchored model requires: +// SSP -> ControlImplementation -> ImplementedRequirement -> Statement -> ByComponent -> Export. +type sharedResponsibilityFixture struct { + upstreamSSPID uuid.UUID + downstreamSSPID uuid.UUID + + requirementID uuid.UUID + statementID uuid.UUID + byComponentID uuid.UUID + componentID uuid.UUID + exportID uuid.UUID + providedID uuid.UUID + + respAID uuid.UUID + respBID uuid.UUID + + offeringID uuid.UUID + itemID uuid.UUID +} + +func newSharedResponsibilityFixture(t *testing.T, db *gorm.DB) sharedResponsibilityFixture { + t.Helper() + + upstream := relational.SystemSecurityPlan{} + require.NoError(t, db.Create(&upstream).Error) + upstreamImpl := relational.ControlImplementation{SystemSecurityPlanId: *upstream.ID} + require.NoError(t, db.Create(&upstreamImpl).Error) + upstreamSysImpl := relational.SystemImplementation{SystemSecurityPlanId: *upstream.ID} + require.NoError(t, db.Create(&upstreamSysImpl).Error) + + component := relational.SystemComponent{ + Type: "software", Title: "Meridian Runtime", + SystemImplementationId: *upstreamSysImpl.ID, + } + require.NoError(t, db.Create(&component).Error) + + requirement := relational.ImplementedRequirement{ + ControlImplementationId: *upstreamImpl.ID, ControlId: "ac-2", + } + require.NoError(t, db.Create(&requirement).Error) + + statement := relational.Statement{ + ImplementedRequirementId: *requirement.ID, StatementId: "ac-2_smt.a", + } + require.NoError(t, db.Create(&statement).Error) + + statementsType := "statements" + byComponent := relational.ByComponent{ + ParentID: statement.ID, ParentType: &statementsType, + ComponentUUID: *component.ID, + Description: "original description", + Remarks: "original remarks", + } + require.NoError(t, db.Create(&byComponent).Error) + + export := relational.Export{ByComponentId: *byComponent.ID, Description: "export"} + require.NoError(t, db.Create(&export).Error) + + provided := relational.ProvidedControlImplementation{ExportId: *export.ID, Description: "provided capability"} + require.NoError(t, db.Create(&provided).Error) + + respA := relational.ControlImplementationResponsibility{ + ExportId: *export.ID, ProvidedUuid: *provided.ID, Description: "resp a", + } + require.NoError(t, db.Create(&respA).Error) + respB := relational.ControlImplementationResponsibility{ + ExportId: *export.ID, ProvidedUuid: *provided.ID, Description: "resp b", + } + require.NoError(t, db.Create(&respB).Error) + + offering := relational.SSPExportOffering{ + SSPID: *upstream.ID, Title: "Meridian Offering", Version: 3, + Status: relational.SSPExportOfferingStatusPublished, + } + require.NoError(t, db.Create(&offering).Error) + + item := relational.SSPExportOfferingItem{ + OfferingID: *offering.ID, ControlID: "ac-2", StatementID: statementID("ac-2_smt.a"), + ComponentUUID: *component.ID, ProvidedUUID: *provided.ID, + } + require.NoError(t, db.Create(&item).Error) + + downstream := relational.SystemSecurityPlan{} + require.NoError(t, db.Create(&downstream).Error) + require.NoError(t, db.Create(&relational.ControlImplementation{SystemSecurityPlanId: *downstream.ID}).Error) + require.NoError(t, db.Create(&relational.SystemImplementation{SystemSecurityPlanId: *downstream.ID}).Error) + + return sharedResponsibilityFixture{ + upstreamSSPID: *upstream.ID, + downstreamSSPID: *downstream.ID, + requirementID: *requirement.ID, + statementID: *statement.ID, + byComponentID: *byComponent.ID, + componentID: *component.ID, + exportID: *export.ID, + providedID: *provided.ID, + respAID: *respA.ID, + respBID: *respB.ID, + offeringID: *offering.ID, + itemID: *item.ID, + } +} + +// newByComponentContext builds an echo context addressed at one statement-level by-component, +// with any extra path params (inheritedId/satisfiedId) appended. +func newByComponentContext(method, body string, sspID, reqID, stmtID, bcID uuid.UUID, extra ...[2]string) (echo.Context, *httptest.ResponseRecorder) { + e := echo.New() + req := httptest.NewRequest(method, "/", bytes.NewBufferString(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + + names := []string{"id", "reqId", "stmtId", "byComponentId"} + values := []string{sspID.String(), reqID.String(), stmtID.String(), bcID.String()} + for _, pair := range extra { + names = append(names, pair[0]) + values = append(values, pair[1]) + } + ctx.SetParamNames(names...) + ctx.SetParamValues(values...) + return ctx, rec +} + +func newSSPHandler(db *gorm.DB) *SystemSecurityPlanHandler { + return NewSystemSecurityPlanHandler(zap.NewNop().Sugar(), db, nil, nil) +} + +// subscribeFixture runs a real Subscribe against the fixture, satisfying the given +// responsibilities, and returns the created leverage link plus the meta.created block. +func subscribeFixture(t *testing.T, db *gorm.DB, fx sharedResponsibilityFixture, satisfied ...uuid.UUID) (relational.SSPLeverageLink, subscribeCreated) { + t.Helper() + + pdp := &stubPDP{allow: true} + h := NewSSPLeverageHandler(zap.NewNop().Sugar(), db, pdp, authz.FailClosed) + ctx, _, rec := newSubscribeRequestContext(fx.offeringID, subscribeBody(fx.downstreamSSPID, fx.itemID, satisfied...)) + require.NoError(t, h.Subscribe(ctx)) + require.Equal(t, http.StatusCreated, rec.Code) + + var resp struct { + Data []relational.SSPLeverageLink `json:"data"` + Meta subscribeMeta `json:"meta"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Len(t, resp.Data, 1) + return resp.Data[0], resp.Meta.Created +} + +// --- Part 1: statement anchoring --------------------------------------------------------- + +// TestCreateOfferingItemRequiresStatementID: an offering item with no statement-id is the +// ambiguous requirement-anchored case this work eliminates, and is rejected on the write path. +func TestCreateOfferingItemRequiresStatementID(t *testing.T) { + req := createExportOfferingItemRequest{ + ControlID: "ac-2", + ComponentUUID: uuid.New().String(), + ProvidedUUID: uuid.New().String(), + } + err := req.validate() + require.Error(t, err) + require.Contains(t, err.Error(), "statementId is required") + + blank := "" + req.StatementID = &blank + require.Error(t, req.validate()) + + req.StatementID = statementID("ac-2_smt.a") + require.NoError(t, req.validate()) +} + +// TestOfferingItemCoherenceRejectsIncoherentTuples: the (controlId, statementId, +// componentUuid, providedUuid) tuple must actually resolve to one real statement-anchored +// by-component inside this SSP. Nothing validated this before. +func TestOfferingItemCoherenceRejectsIncoherentTuples(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := NewSSPExportOfferingHandler(zap.NewNop().Sugar(), db, nil) + + coherent := createExportOfferingItemRequest{ + ControlID: "ac-2", + StatementID: statementID("ac-2_smt.a"), + ComponentUUID: fx.componentID.String(), + ProvidedUUID: fx.providedID.String(), + } + require.NoError(t, h.validateOfferingItemCoherence(fx.upstreamSSPID, coherent)) + + wrongControl := coherent + wrongControl.ControlID = "ac-3" + require.ErrorContains(t, h.validateOfferingItemCoherence(fx.upstreamSSPID, wrongControl), "controlId") + + wrongStatement := coherent + wrongStatement.StatementID = statementID("ac-2_smt.b") + require.ErrorContains(t, h.validateOfferingItemCoherence(fx.upstreamSSPID, wrongStatement), "statementId") + + wrongComponent := coherent + wrongComponent.ComponentUUID = uuid.New().String() + require.ErrorContains(t, h.validateOfferingItemCoherence(fx.upstreamSSPID, wrongComponent), "componentUuid") + + unknownProvided := coherent + unknownProvided.ProvidedUUID = uuid.New().String() + require.ErrorContains(t, h.validateOfferingItemCoherence(fx.upstreamSSPID, unknownProvided), "does not exist") + + // Coherent in itself, but belonging to a different SSP entirely. + otherSSP := relational.SystemSecurityPlan{} + require.NoError(t, db.Create(&otherSSP).Error) + require.NoError(t, db.Create(&relational.ControlImplementation{SystemSecurityPlanId: *otherSSP.ID}).Error) + require.ErrorContains(t, h.validateOfferingItemCoherence(*otherSSP.ID, coherent), "does not resolve inside this SSP") +} + +// TestOfferingItemCoherenceRejectsRequirementAnchoredProvided: a provided capability exported +// from a requirement-anchored by-component cannot be offered — there is no statement to +// attribute the responsibility against. +func TestOfferingItemCoherenceRejectsRequirementAnchoredProvided(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := NewSSPExportOfferingHandler(zap.NewNop().Sugar(), db, nil) + + requirementsType := "implemented_requirements" + legacyBC := relational.ByComponent{ + ParentID: &fx.requirementID, ParentType: &requirementsType, ComponentUUID: fx.componentID, + } + require.NoError(t, db.Create(&legacyBC).Error) + legacyExport := relational.Export{ByComponentId: *legacyBC.ID} + require.NoError(t, db.Create(&legacyExport).Error) + legacyProvided := relational.ProvidedControlImplementation{ExportId: *legacyExport.ID} + require.NoError(t, db.Create(&legacyProvided).Error) + + err := h.validateOfferingItemCoherence(fx.upstreamSSPID, createExportOfferingItemRequest{ + ControlID: "ac-2", + StatementID: statementID("ac-2_smt.a"), + ComponentUUID: fx.componentID.String(), + ProvidedUUID: legacyProvided.ID.String(), + }) + require.ErrorContains(t, err, "requirement-anchored") +} + +// TestSubscribeRejectsStatementlessLegacyItem: subscribing to a legacy NULL-statement item +// fails with 422 naming the item, rather than silently falling back to requirement-anchoring +// (which produced by-component rows the API could never delete). +func TestSubscribeRejectsStatementlessLegacyItem(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + + require.NoError(t, db.Model(&relational.SSPExportOfferingItem{}). + Where("id = ?", fx.itemID). + Update("statement_id", nil).Error) + + pdp := &stubPDP{allow: true} + h := NewSSPLeverageHandler(zap.NewNop().Sugar(), db, pdp, authz.FailClosed) + ctx, _, rec := newSubscribeRequestContext(fx.offeringID, subscribeBody(fx.downstreamSSPID, fx.itemID)) + require.NoError(t, h.Subscribe(ctx)) + + require.Equal(t, http.StatusUnprocessableEntity, rec.Code) + require.Contains(t, rec.Body.String(), fx.itemID.String()) + + // Nothing was materialized on the downstream. + var links int64 + require.NoError(t, db.Model(&relational.SSPLeverageLink{}).Count(&links).Error) + require.Zero(t, links) +} + +// TestSubscribeReportsCreatedTree: Subscribe reports the requirement/statement/by-component it +// materialized, flagging inserts as created:true, so the UI can render newly-created +// requirements without re-walking the SSP. The tree is always requirement -> statement -> +// by-component, never requirement-anchored. +func TestSubscribeReportsCreatedTree(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + + link, created := subscribeFixture(t, db, fx, fx.respAID) + + require.Len(t, created.ImplementedRequirements, 1) + require.Equal(t, "ac-2", created.ImplementedRequirements[0].ControlID) + require.True(t, created.ImplementedRequirements[0].Created, "downstream never implemented ac-2, so it was inserted") + + require.Len(t, created.Statements, 1) + require.Equal(t, "ac-2_smt.a", created.Statements[0].StatementID) + require.True(t, created.Statements[0].Created) + require.Equal(t, created.ImplementedRequirements[0].UUID, created.Statements[0].ImplementedRequirementUUID) + + require.Len(t, created.ByComponents, 1) + require.True(t, created.ByComponents[0].Created) + require.Equal(t, created.Statements[0].UUID, created.ByComponents[0].StatementUUID) + + // The materialized by-component really is statement-anchored. + var bc relational.ByComponent + require.NoError(t, db.First(&bc, "id = ?", created.ByComponents[0].UUID).Error) + require.NotNil(t, bc.ParentType) + require.Equal(t, "statements", *bc.ParentType) + require.Equal(t, created.Statements[0].UUID, *bc.ParentID) + + // ...and carries the inherited + satisfied rows, with the link pointing at them. + var inherited relational.InheritedControlImplementation + require.NoError(t, db.First(&inherited, "by_component_id = ?", bc.ID).Error) + require.Equal(t, link.InheritedUUID, *inherited.ID) + + var satisfiedCount int64 + require.NoError(t, db.Model(&relational.SatisfiedControlImplementationResponsibility{}). + Where("by_component_id = ?", bc.ID).Count(&satisfiedCount).Error) + require.Equal(t, int64(1), satisfiedCount) + require.Equal(t, relational.SSPLeverageSatisfactionPartial, link.Satisfaction) +} + +// TestSubscribeReportsReusedRowsAsNotCreated: a second item on the same statement reuses the +// requirement, statement and by-component already materialized, and reports created:false. +func TestSubscribeReportsReusedRowsAsNotCreated(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + + // A second provided capability on the same statement-anchored by-component, offered as a + // second item in the same offering. + provided2 := relational.ProvidedControlImplementation{ExportId: fx.exportID, Description: "second capability"} + require.NoError(t, db.Create(&provided2).Error) + item2 := relational.SSPExportOfferingItem{ + OfferingID: fx.offeringID, ControlID: "ac-2", StatementID: statementID("ac-2_smt.a"), + ComponentUUID: fx.componentID, ProvidedUUID: *provided2.ID, + } + require.NoError(t, db.Create(&item2).Error) + + pdp := &stubPDP{allow: true} + h := NewSSPLeverageHandler(zap.NewNop().Sugar(), db, pdp, authz.FailClosed) + body := fmt.Sprintf( + `{"downstreamSspId":%q,"leveragedAuthorization":{"title":"Trust","partyUuid":%q},"items":[{"itemId":%q},{"itemId":%q}]}`, + fx.downstreamSSPID.String(), uuid.New().String(), fx.itemID.String(), item2.ID.String()) + ctx, _, rec := newSubscribeRequestContext(fx.offeringID, body) + require.NoError(t, h.Subscribe(ctx)) + require.Equal(t, http.StatusCreated, rec.Code) + + var resp struct { + Data []relational.SSPLeverageLink `json:"data"` + Meta subscribeMeta `json:"meta"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Len(t, resp.Data, 2, "two items subscribed, two links") + + // Both items land on the same requirement/statement/by-component: each is reported once, + // as created (the first item inserted it), not duplicated per item. + require.Len(t, resp.Meta.Created.ImplementedRequirements, 1) + require.Len(t, resp.Meta.Created.Statements, 1) + require.Len(t, resp.Meta.Created.ByComponents, 1) + require.True(t, resp.Meta.Created.ByComponents[0].Created) +} + +// TestSubscribeReportsExistingRequirementAsNotCreated: when the downstream already implements +// the control, the requirement is matched rather than inserted — created:false. +func TestSubscribeReportsExistingRequirementAsNotCreated(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + + var downstreamImpl relational.ControlImplementation + require.NoError(t, db.First(&downstreamImpl, "system_security_plan_id = ?", fx.downstreamSSPID).Error) + existing := relational.ImplementedRequirement{ + ControlImplementationId: *downstreamImpl.ID, ControlId: "ac-2", + } + require.NoError(t, db.Create(&existing).Error) + + _, created := subscribeFixture(t, db, fx) + + require.Len(t, created.ImplementedRequirements, 1) + require.Equal(t, *existing.ID, created.ImplementedRequirements[0].UUID) + require.False(t, created.ImplementedRequirements[0].Created, "the requirement already existed") + require.True(t, created.Statements[0].Created, "but its statement did not") +} + +// --- Part 2: by-component reads ----------------------------------------------------------- + +// TestGetStatementByComponentReturnsFullSubtree: the single-by-component GET is what lets the +// UI refetch after editing a sub-resource, so it must carry export (with provided and +// responsibilities), inherited, satisfied and responsible-roles. +func TestGetStatementByComponentReturnsFullSubtree(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + + inherited := relational.InheritedControlImplementation{ + ByComponentId: fx.byComponentID, ProvidedUuid: uuid.New(), Description: "inherited thing", + } + require.NoError(t, db.Create(&inherited).Error) + satisfied := relational.SatisfiedControlImplementationResponsibility{ + ByComponentId: fx.byComponentID, ResponsibilityUuid: uuid.New(), Description: "satisfied thing", + } + require.NoError(t, db.Create(&satisfied).Error) + + h := newSSPHandler(db) + ctx, rec := newByComponentContext(http.MethodGet, "", fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID) + require.NoError(t, h.GetImplementedRequirementStatementByComponent(ctx)) + require.Equal(t, http.StatusOK, rec.Code) + + var resp handler.GenericDataResponse[oscalTypes_1_1_3.ByComponent] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + + require.Equal(t, fx.byComponentID.String(), resp.Data.UUID) + require.NotNil(t, resp.Data.Export) + require.NotNil(t, resp.Data.Export.Provided) + require.Len(t, *resp.Data.Export.Provided, 1) + require.NotNil(t, resp.Data.Export.Responsibilities) + require.Len(t, *resp.Data.Export.Responsibilities, 2) + require.NotNil(t, resp.Data.Inherited) + require.Len(t, *resp.Data.Inherited, 1) + require.NotNil(t, resp.Data.Satisfied) + require.Len(t, *resp.Data.Satisfied, 1) +} + +// TestListStatementByComponentsReturnsAll: the list GET returns every by-component on the +// statement. +func TestListStatementByComponentsReturnsAll(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + + statementsType := "statements" + second := relational.ByComponent{ + ParentID: &fx.statementID, ParentType: &statementsType, ComponentUUID: uuid.New(), + } + require.NoError(t, db.Create(&second).Error) + + h := newSSPHandler(db) + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + ctx.SetParamNames("id", "reqId", "stmtId") + ctx.SetParamValues(fx.upstreamSSPID.String(), fx.requirementID.String(), fx.statementID.String()) + + require.NoError(t, h.GetImplementedRequirementStatementByComponents(ctx)) + require.Equal(t, http.StatusOK, rec.Code) + + var resp handler.GenericDataListResponse[oscalTypes_1_1_3.ByComponent] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Len(t, resp.Data, 2) +} + +// TestGetByComponentExportSubResources: the provided and responsibilities collections are +// readable on their own, which they never were. +func TestGetByComponentExportSubResources(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := newSSPHandler(db) + + ctx, rec := newByComponentContext(http.MethodGet, "", fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID) + require.NoError(t, h.GetImplementedRequirementStatementByComponentExportProvided(ctx)) + require.Equal(t, http.StatusOK, rec.Code) + var provided handler.GenericDataListResponse[oscalTypes_1_1_3.ProvidedControlImplementation] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &provided)) + require.Len(t, provided.Data, 1) + require.Equal(t, fx.providedID.String(), provided.Data[0].UUID) + + ctx, rec = newByComponentContext(http.MethodGet, "", fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID) + require.NoError(t, h.GetImplementedRequirementStatementByComponentExportResponsibilities(ctx)) + require.Equal(t, http.StatusOK, rec.Code) + var responsibilities handler.GenericDataListResponse[oscalTypes_1_1_3.ControlImplementationResponsibility] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &responsibilities)) + require.Len(t, responsibilities.Data, 2) +} + +// --- The blind-Save regression ------------------------------------------------------------ + +// TestUpdateByComponentDoesNotClobberSubtreesOrOmittedFields is the regression test for the +// latent bug in both by-component PUTs: they blind-Saved a struct rebuilt from the request +// body, so a PUT carrying only a description zeroed every other field, and a PUT carrying a +// nested export upserted it as a GORM association with no cascade cleanup. +func TestUpdateByComponentDoesNotClobberSubtreesOrOmittedFields(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + + inherited := relational.InheritedControlImplementation{ + ByComponentId: fx.byComponentID, ProvidedUuid: uuid.New(), Description: "inherited thing", + } + require.NoError(t, db.Create(&inherited).Error) + satisfied := relational.SatisfiedControlImplementationResponsibility{ + ByComponentId: fx.byComponentID, ResponsibilityUuid: uuid.New(), Description: "satisfied thing", + } + require.NoError(t, db.Create(&satisfied).Error) + + h := newSSPHandler(db) + + // A PUT that omits export/inherited/satisfied entirely, and additionally tries to smuggle + // a nested export in — neither the omission nor the smuggled subtree may touch the stored + // subtrees. + body := fmt.Sprintf(`{ + "uuid": %q, + "component-uuid": %q, + "description": "updated description", + "export": {"description": "smuggled export", "provided": [{"uuid": %q, "description": "smuggled provided"}]} + }`, fx.byComponentID.String(), fx.componentID.String(), uuid.New().String()) + + ctx, rec := newByComponentContext(http.MethodPut, body, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID) + require.NoError(t, h.UpdateImplementedRequirementStatementByComponent(ctx)) + require.Equal(t, http.StatusOK, rec.Code) + + // The field the PUT actually set landed. + var stored relational.ByComponent + require.NoError(t, db.First(&stored, "id = ?", fx.byComponentID).Error) + require.Equal(t, "updated description", stored.Description) + + // The export subtree is untouched: still exactly one export with its original description, + // one provided, two responsibilities. The smuggled export was not upserted. + var exports []relational.Export + require.NoError(t, db.Where("by_component_id = ?", fx.byComponentID).Find(&exports).Error) + require.Len(t, exports, 1) + require.Equal(t, fx.exportID, *exports[0].ID) + require.Equal(t, "export", exports[0].Description) + + var providedCount, responsibilityCount int64 + require.NoError(t, db.Model(&relational.ProvidedControlImplementation{}). + Where("export_id = ?", fx.exportID).Count(&providedCount).Error) + require.Equal(t, int64(1), providedCount) + require.NoError(t, db.Model(&relational.ControlImplementationResponsibility{}). + Where("export_id = ?", fx.exportID).Count(&responsibilityCount).Error) + require.Equal(t, int64(2), responsibilityCount) + + // Inherited and satisfied survive a PUT that never mentioned them. + var inheritedCount, satisfiedCount int64 + require.NoError(t, db.Model(&relational.InheritedControlImplementation{}). + Where("by_component_id = ?", fx.byComponentID).Count(&inheritedCount).Error) + require.Equal(t, int64(1), inheritedCount) + require.NoError(t, db.Model(&relational.SatisfiedControlImplementationResponsibility{}). + Where("by_component_id = ?", fx.byComponentID).Count(&satisfiedCount).Error) + require.Equal(t, int64(1), satisfiedCount) +} + +// --- Part 3: Inherited / Satisfied CRUD --------------------------------------------------- + +// TestInheritedCRUDRoundTrip: create, read, update and delete a hand-authored inherited entry. +func TestInheritedCRUDRoundTrip(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := newSSPHandler(db) + + providedUUID := uuid.New() + body := fmt.Sprintf(`{"provided-uuid": %q, "description": "we inherit this"}`, providedUUID.String()) + ctx, rec := newByComponentContext(http.MethodPost, body, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID) + require.NoError(t, h.CreateImplementedRequirementStatementByComponentInherited(ctx)) + require.Equal(t, http.StatusCreated, rec.Code) + + var created handler.GenericDataResponse[oscalTypes_1_1_3.InheritedControlImplementation] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created)) + require.Equal(t, providedUUID.String(), created.Data.ProvidedUuid) + inheritedID := created.Data.UUID + + ctx, rec = newByComponentContext(http.MethodGet, "", fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID) + require.NoError(t, h.GetImplementedRequirementStatementByComponentInherited(ctx)) + require.Equal(t, http.StatusOK, rec.Code) + var list handler.GenericDataListResponse[oscalTypes_1_1_3.InheritedControlImplementation] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &list)) + require.Len(t, list.Data, 1) + + updateBody := `{"description": "revised"}` + ctx, rec = newByComponentContext(http.MethodPut, updateBody, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID, + [2]string{"inheritedId", inheritedID}) + require.NoError(t, h.UpdateImplementedRequirementStatementByComponentInherited(ctx)) + require.Equal(t, http.StatusOK, rec.Code) + var updated handler.GenericDataResponse[oscalTypes_1_1_3.InheritedControlImplementation] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated)) + require.Equal(t, "revised", updated.Data.Description) + require.Equal(t, providedUUID.String(), updated.Data.ProvidedUuid, "provided-uuid survives a PUT that omits it") + + ctx, rec = newByComponentContext(http.MethodDelete, "", fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID, + [2]string{"inheritedId", inheritedID}) + require.NoError(t, h.DeleteImplementedRequirementStatementByComponentInherited(ctx)) + require.Equal(t, http.StatusNoContent, rec.Code, "hand-authored inherited entries delete freely") + + var count int64 + require.NoError(t, db.Model(&relational.InheritedControlImplementation{}). + Where("by_component_id = ?", fx.byComponentID).Count(&count).Error) + require.Zero(t, count) +} + +// TestInheritedPutRejectsProvidedUuidChange: provided-uuid is the identity the leverage link +// and the drift detector join on, so it is immutable. +func TestInheritedPutRejectsProvidedUuidChange(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := newSSPHandler(db) + + inherited := relational.InheritedControlImplementation{ + ByComponentId: fx.byComponentID, ProvidedUuid: uuid.New(), Description: "inherited", + } + require.NoError(t, db.Create(&inherited).Error) + + body := fmt.Sprintf(`{"provided-uuid": %q, "description": "sneaky"}`, uuid.New().String()) + ctx, rec := newByComponentContext(http.MethodPut, body, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID, + [2]string{"inheritedId", inherited.ID.String()}) + require.NoError(t, h.UpdateImplementedRequirementStatementByComponentInherited(ctx)) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "immutable") +} + +// TestDeleteSubscriptionOwnedInheritedConflicts: an inherited entry an SSPLeverageLink still +// references is owned by that subscription — deleting it would leave the link pointing at +// nothing, so it returns 409 and points at unsubscribe. +func TestDeleteSubscriptionOwnedInheritedConflicts(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + + link, created := subscribeFixture(t, db, fx, fx.respAID) + bcID := created.ByComponents[0].UUID + stmtID := created.Statements[0].UUID + reqID := created.ImplementedRequirements[0].UUID + + h := newSSPHandler(db) + ctx, rec := newByComponentContext(http.MethodDelete, "", fx.downstreamSSPID, reqID, stmtID, bcID, + [2]string{"inheritedId", link.InheritedUUID.String()}) + require.NoError(t, h.DeleteImplementedRequirementStatementByComponentInherited(ctx)) + + require.Equal(t, http.StatusConflict, rec.Code) + require.Contains(t, rec.Body.String(), "unsubscribe") + + var count int64 + require.NoError(t, db.Model(&relational.InheritedControlImplementation{}). + Where("id = ?", link.InheritedUUID).Count(&count).Error) + require.Equal(t, int64(1), count, "the inherited entry survives the rejected delete") +} + +// TestSatisfiedCreateRejectsForeignResponsibility: the responsibility-uuid must resolve to a +// responsibility on an export this by-component actually inherits from. +func TestSatisfiedCreateRejectsForeignResponsibility(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + + _, created := subscribeFixture(t, db, fx) + bcID := created.ByComponents[0].UUID + + h := newSSPHandler(db) + body := fmt.Sprintf(`{"responsibility-uuid": %q, "description": "not ours"}`, uuid.New().String()) + ctx, rec := newByComponentContext(http.MethodPost, body, fx.downstreamSSPID, + created.ImplementedRequirements[0].UUID, created.Statements[0].UUID, bcID) + require.NoError(t, h.CreateImplementedRequirementStatementByComponentSatisfied(ctx)) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "not a responsibility") +} + +// TestSatisfiedWritesRederiveLeverageSatisfaction: adding the last outstanding satisfied entry +// flips the owning link partial -> full, and removing it flips it back — in the same +// transaction as the write, so the drift detector never reads stale bookkeeping. The +// leveraged-controls projection agrees, since it recomputes satisfaction live. +func TestSatisfiedWritesRederiveLeverageSatisfaction(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + + // Subscribe satisfying only respA, of two responsibilities -> partial. + link, created := subscribeFixture(t, db, fx, fx.respAID) + require.Equal(t, relational.SSPLeverageSatisfactionPartial, link.Satisfaction) + + reqID := created.ImplementedRequirements[0].UUID + stmtID := created.Statements[0].UUID + bcID := created.ByComponents[0].UUID + h := newSSPHandler(db) + + // Satisfy respB too -> full. + body := fmt.Sprintf(`{"responsibility-uuid": %q, "description": "and now b"}`, fx.respBID.String()) + ctx, rec := newByComponentContext(http.MethodPost, body, fx.downstreamSSPID, reqID, stmtID, bcID) + require.NoError(t, h.CreateImplementedRequirementStatementByComponentSatisfied(ctx)) + require.Equal(t, http.StatusCreated, rec.Code) + + var satisfiedResp handler.GenericDataResponse[oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &satisfiedResp)) + + var reloaded relational.SSPLeverageLink + require.NoError(t, db.First(&reloaded, "id = ?", link.ID).Error) + require.Equal(t, relational.SSPLeverageSatisfactionFull, reloaded.Satisfaction) + + projection, err := projectLeveragedControls(db, fx.downstreamSSPID) + require.NoError(t, err) + require.Len(t, projection, 1) + require.Equal(t, relational.SSPLeverageSatisfactionFull, projection[0].Satisfaction) + require.Empty(t, projection[0].Outstanding) + + // Remove it again -> back to partial. + ctx, rec = newByComponentContext(http.MethodDelete, "", fx.downstreamSSPID, reqID, stmtID, bcID, + [2]string{"satisfiedId", satisfiedResp.Data.UUID}) + require.NoError(t, h.DeleteImplementedRequirementStatementByComponentSatisfied(ctx)) + require.Equal(t, http.StatusNoContent, rec.Code) + + require.NoError(t, db.First(&reloaded, "id = ?", link.ID).Error) + require.Equal(t, relational.SSPLeverageSatisfactionPartial, reloaded.Satisfaction) + + projection, err = projectLeveragedControls(db, fx.downstreamSSPID) + require.NoError(t, err) + require.Equal(t, relational.SSPLeverageSatisfactionPartial, projection[0].Satisfaction) + require.Len(t, projection[0].Outstanding, 1) +} + +// TestSatisfiedPutRejectsResponsibilityUuidChange: responsibility-uuid is what satisfaction +// derivation matches on, so it is immutable. +func TestSatisfiedPutRejectsResponsibilityUuidChange(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := newSSPHandler(db) + + satisfied := relational.SatisfiedControlImplementationResponsibility{ + ByComponentId: fx.byComponentID, ResponsibilityUuid: fx.respAID, Description: "satisfied", + } + require.NoError(t, db.Create(&satisfied).Error) + + body := fmt.Sprintf(`{"responsibility-uuid": %q}`, uuid.New().String()) + ctx, rec := newByComponentContext(http.MethodPut, body, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID, + [2]string{"satisfiedId", satisfied.ID.String()}) + require.NoError(t, h.UpdateImplementedRequirementStatementByComponentSatisfied(ctx)) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "immutable") +} + +// TestSatisfiedWritesOnHandAuthoredEntriesSkipLeverageResync: a by-component with no leverage +// link (hand-authored inherited entries) has no bookkeeping to re-derive — the write succeeds +// and nothing blows up. +func TestSatisfiedWritesOnHandAuthoredEntriesSkipLeverageResync(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + + // Hand-authored: an inherited entry pointing at the upstream's provided-uuid, with no + // SSPLeverageLink anywhere. + inherited := relational.InheritedControlImplementation{ + ByComponentId: fx.byComponentID, ProvidedUuid: fx.providedID, Description: "hand-authored", + } + require.NoError(t, db.Create(&inherited).Error) + + h := newSSPHandler(db) + body := fmt.Sprintf(`{"responsibility-uuid": %q, "description": "done"}`, fx.respAID.String()) + ctx, rec := newByComponentContext(http.MethodPost, body, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID) + require.NoError(t, h.CreateImplementedRequirementStatementByComponentSatisfied(ctx)) + require.Equal(t, http.StatusCreated, rec.Code) + + var count int64 + require.NoError(t, db.Model(&relational.SatisfiedControlImplementationResponsibility{}). + Where("by_component_id = ?", fx.byComponentID).Count(&count).Error) + require.Equal(t, int64(1), count) +} + +// --- Part 4: the requirement-level hole --------------------------------------------------- + +// TestDeleteRequirementByComponentCascades: the new requirement-level DELETE winds a legacy +// row down completely — export, provided, responsibilities, inherited and satisfied all go. +func TestDeleteRequirementByComponentCascades(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + + requirementsType := "implemented_requirements" + legacyBC := relational.ByComponent{ + ParentID: &fx.requirementID, ParentType: &requirementsType, ComponentUUID: fx.componentID, + } + require.NoError(t, db.Create(&legacyBC).Error) + legacyExport := relational.Export{ByComponentId: *legacyBC.ID} + require.NoError(t, db.Create(&legacyExport).Error) + legacyProvided := relational.ProvidedControlImplementation{ExportId: *legacyExport.ID} + require.NoError(t, db.Create(&legacyProvided).Error) + legacyResp := relational.ControlImplementationResponsibility{ + ExportId: *legacyExport.ID, ProvidedUuid: *legacyProvided.ID, + } + require.NoError(t, db.Create(&legacyResp).Error) + legacyInherited := relational.InheritedControlImplementation{ + ByComponentId: *legacyBC.ID, ProvidedUuid: uuid.New(), + } + require.NoError(t, db.Create(&legacyInherited).Error) + legacySatisfied := relational.SatisfiedControlImplementationResponsibility{ + ByComponentId: *legacyBC.ID, ResponsibilityUuid: uuid.New(), + } + require.NoError(t, db.Create(&legacySatisfied).Error) + + h := newSSPHandler(db) + e := echo.New() + req := httptest.NewRequest(http.MethodDelete, "/", nil) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + ctx.SetParamNames("id", "reqId", "byComponentId") + ctx.SetParamValues(fx.upstreamSSPID.String(), fx.requirementID.String(), legacyBC.ID.String()) + + require.NoError(t, h.DeleteImplementedRequirementByComponent(ctx)) + require.Equal(t, http.StatusNoContent, rec.Code) + + for name, count := range map[string]func() int64{ + "by_component": func() int64 { return countRows(t, db, &relational.ByComponent{}, "id = ?", legacyBC.ID) }, + "export": func() int64 { return countRows(t, db, &relational.Export{}, "id = ?", legacyExport.ID) }, + "provided": func() int64 { + return countRows(t, db, &relational.ProvidedControlImplementation{}, "id = ?", legacyProvided.ID) + }, + "responsibility": func() int64 { + return countRows(t, db, &relational.ControlImplementationResponsibility{}, "id = ?", legacyResp.ID) + }, + "inherited": func() int64 { + return countRows(t, db, &relational.InheritedControlImplementation{}, "id = ?", legacyInherited.ID) + }, + "satisfied": func() int64 { + return countRows(t, db, &relational.SatisfiedControlImplementationResponsibility{}, "id = ?", legacySatisfied.ID) + }, + } { + require.Zerof(t, count(), "expected %s rows to be cascade-deleted with the by-component", name) + } + + // The statement-anchored by-component alongside it is untouched. + require.Equal(t, int64(1), countRows(t, db, &relational.ByComponent{}, "id = ?", fx.byComponentID)) +} + +func countRows(t *testing.T, db *gorm.DB, model any, query string, args ...any) int64 { + t.Helper() + var count int64 + require.NoError(t, db.Model(model).Where(query, args...).Count(&count).Error) + return count +} + +// --- Part 5: control-centric queries ------------------------------------------------------ + +// TestByControlResolvesEveryPointer: the by-control catalog answers "what's exported for this +// control, by whom, against which statement" with everything resolved, so the Controls UI walks +// nothing. +func TestByControlResolvesEveryPointer(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := NewSSPExportOfferingHandler(zap.NewNop().Sugar(), db, nil) + + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + ctx.SetParamNames("controlId") + ctx.SetParamValues("AC-2") // deliberately different casing from the stored "ac-2" + + require.NoError(t, h.ByControl(ctx)) + require.Equal(t, http.StatusOK, rec.Code) + + var resp handler.GenericDataListResponse[ControlExportOffer] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Len(t, resp.Data, 1) + + offer := resp.Data[0] + require.Equal(t, fx.offeringID, offer.OfferingID) + require.Equal(t, "Meridian Offering", offer.OfferingTitle) + require.Equal(t, 3, offer.OfferingVersion) + require.Equal(t, relational.SSPExportOfferingStatusPublished, offer.OfferingStatus) + require.Equal(t, fx.upstreamSSPID, offer.UpstreamSSPID) + require.Equal(t, fx.itemID, offer.ItemID) + require.NotNil(t, offer.StatementID) + require.Equal(t, "ac-2_smt.a", *offer.StatementID) + require.Equal(t, "Meridian Runtime", offer.ComponentTitle) + require.NotNil(t, offer.Provided) + require.Equal(t, "provided capability", offer.Provided.Description) + require.Len(t, offer.Responsibilities, 2) + for _, r := range offer.Responsibilities { + require.Equal(t, fx.providedID, r.ProvidedUUID) + require.NotEmpty(t, r.Description) + } +} + +// TestByControlExcludesUnpublishedOfferings: only published offerings are catalogued. +func TestByControlExcludesUnpublishedOfferings(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + require.NoError(t, db.Model(&relational.SSPExportOffering{}). + Where("id = ?", fx.offeringID). + Update("status", relational.SSPExportOfferingStatusDraft).Error) + + h := NewSSPExportOfferingHandler(zap.NewNop().Sugar(), db, nil) + e := echo.New() + rec := httptest.NewRecorder() + ctx := e.NewContext(httptest.NewRequest(http.MethodGet, "/", nil), rec) + ctx.SetParamNames("controlId") + ctx.SetParamValues("ac-2") + + require.NoError(t, h.ByControl(ctx)) + require.Equal(t, http.StatusOK, rec.Code) + + var resp handler.GenericDataListResponse[ControlExportOffer] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Empty(t, resp.Data) +} + +// TestByControlHonoursDownstreamAllowList: with downstreamSspId set, only offerings that SSP is +// actually allow-listed to subscribe to come back. +func TestByControlHonoursDownstreamAllowList(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := NewSSPExportOfferingHandler(zap.NewNop().Sugar(), db, nil) + + byControl := func(downstream uuid.UUID) []ControlExportOffer { + e := echo.New() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/?downstreamSspId="+downstream.String(), nil) + ctx := e.NewContext(req, rec) + ctx.SetParamNames("controlId") + ctx.SetParamValues("ac-2") + require.NoError(t, h.ByControl(ctx)) + require.Equal(t, http.StatusOK, rec.Code) + + var resp handler.GenericDataListResponse[ControlExportOffer] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + return resp.Data + } + + // No allow-list rows: the type-level default, any downstream may subscribe. + require.Len(t, byControl(fx.downstreamSSPID), 1) + + // Once an allow-list exists, a downstream not on it sees nothing. + other := uuid.New() + require.NoError(t, db.Create(&relational.SSPExportOfferingAllowedDownstream{ + OfferingID: fx.offeringID, DownstreamSSPID: other, + }).Error) + require.Empty(t, byControl(fx.downstreamSSPID)) + require.Len(t, byControl(other), 1) +} + +// TestSharedResponsibilityRollup: provides/inherits/satisfies/legacy for a seeded SSP. +func TestSharedResponsibilityRollup(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := newSSPHandler(db) + + // A legacy requirement-anchored by-component that must be reported, not silently dropped. + requirementsType := "implemented_requirements" + legacyBC := relational.ByComponent{ + ParentID: &fx.requirementID, ParentType: &requirementsType, ComponentUUID: fx.componentID, + } + require.NoError(t, db.Create(&legacyBC).Error) + + rollup := func(sspID uuid.UUID, query string) SharedResponsibilityRollup { + e := echo.New() + rec := httptest.NewRecorder() + ctx := e.NewContext(httptest.NewRequest(http.MethodGet, "/"+query, nil), rec) + ctx.SetParamNames("id") + ctx.SetParamValues(sspID.String()) + require.NoError(t, h.SharedResponsibility(ctx)) + require.Equal(t, http.StatusOK, rec.Code) + + var resp handler.GenericDataResponse[SharedResponsibilityRollup] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + return resp.Data + } + + // Upstream: it provides, and has one legacy row. It offers the capability, so offered=true. + up := rollup(fx.upstreamSSPID, "") + require.Len(t, up.Provides, 1) + require.Equal(t, "ac-2", up.Provides[0].ControlID) + require.Equal(t, "ac-2_smt.a", up.Provides[0].StatementID) + require.Equal(t, fx.byComponentID, up.Provides[0].ByComponentUUID) + require.Equal(t, "Meridian Runtime", up.Provides[0].ComponentTitle) + require.Equal(t, fx.exportID, up.Provides[0].ExportUUID) + require.Len(t, up.Provides[0].Provided, 1) + require.Len(t, up.Provides[0].Responsibilities, 2) + require.True(t, up.Provides[0].Offered, "an offering item points at this provided-uuid") + + require.Len(t, up.Legacy, 1) + require.Equal(t, *legacyBC.ID, up.Legacy[0].ByComponentUUID) + require.Equal(t, "requirement-anchored export", up.Legacy[0].Reason) + + require.Empty(t, up.Inherits) + require.Empty(t, up.Satisfies) + + // Downstream: after subscribing it inherits and satisfies, and provides nothing. + link, _ := subscribeFixture(t, db, fx, fx.respAID) + down := rollup(fx.downstreamSSPID, "") + + require.Empty(t, down.Provides) + require.Empty(t, down.Legacy) + + require.Len(t, down.Inherits, 1) + require.Equal(t, "ac-2", down.Inherits[0].ControlID) + require.NotNil(t, down.Inherits[0].StatementID) + require.Equal(t, "ac-2_smt.a", *down.Inherits[0].StatementID) + require.Equal(t, fx.upstreamSSPID, down.Inherits[0].UpstreamSSPID) + require.Equal(t, fx.offeringID, down.Inherits[0].OfferingID) + require.Equal(t, 3, down.Inherits[0].OfferingVersion) + require.Equal(t, *link.ID, down.Inherits[0].LeverageLinkID) + require.Equal(t, relational.SSPLeverageSatisfactionPartial, down.Inherits[0].Satisfaction) + require.Equal(t, relational.SSPLeverageStatusActive, down.Inherits[0].Status) + require.Equal(t, link.InheritedUUID, down.Inherits[0].InheritedUUID) + + require.Len(t, down.Satisfies, 1) + require.Equal(t, "ac-2", down.Satisfies[0].ControlID) + require.Equal(t, "ac-2_smt.a", down.Satisfies[0].StatementID) + require.Equal(t, fx.respAID, down.Satisfies[0].ResponsibilityUUID) +} + +// TestSharedResponsibilityFiltersByControl: ?controlId= narrows every arm of the rollup. +func TestSharedResponsibilityFiltersByControl(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := newSSPHandler(db) + + subscribeFixture(t, db, fx, fx.respAID) + + rollup := func(sspID uuid.UUID, controlID string) SharedResponsibilityRollup { + e := echo.New() + rec := httptest.NewRecorder() + ctx := e.NewContext(httptest.NewRequest(http.MethodGet, "/?controlId="+controlID, nil), rec) + ctx.SetParamNames("id") + ctx.SetParamValues(sspID.String()) + require.NoError(t, h.SharedResponsibility(ctx)) + + var resp handler.GenericDataResponse[SharedResponsibilityRollup] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + return resp.Data + } + + // Case-folded match on the control this SSP actually exports. + matched := rollup(fx.upstreamSSPID, "AC-2") + require.Len(t, matched.Provides, 1) + + // A control it has nothing for. + unmatched := rollup(fx.upstreamSSPID, "ac-99") + require.Empty(t, unmatched.Provides) + + downMatched := rollup(fx.downstreamSSPID, "ac-2") + require.Len(t, downMatched.Inherits, 1) + require.Len(t, downMatched.Satisfies, 1) + + downUnmatched := rollup(fx.downstreamSSPID, "ac-99") + require.Empty(t, downUnmatched.Inherits) + require.Empty(t, downUnmatched.Satisfies) +} diff --git a/internal/api/handler/oscal/system_security_plans.go b/internal/api/handler/oscal/system_security_plans.go index 009061a8..281ecbcf 100644 --- a/internal/api/handler/oscal/system_security_plans.go +++ b/internal/api/handler/oscal/system_security_plans.go @@ -467,7 +467,15 @@ func (h *SystemSecurityPlanHandler) Register(api *echo.Group, guard middleware.R api.PUT("/:id/control-implementation/implemented-requirements/:reqId", h.UpdateImplementedRequirement, guard.Update()) api.POST("/:id/control-implementation/implemented-requirements/:reqId/statements", h.CreateImplementedRequirementStatement, guard.Create()) api.PUT("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId", h.UpdateImplementedRequirementStatement, guard.Update()) + // Requirement-level by-component routes are legacy: the statement is the canonical + // anchor for anything carrying shared responsibility, so there is deliberately no + // requirement-level POST. Read, update and delete stay so existing requirement-anchored + // rows can be wound down. + api.GET("/:id/control-implementation/implemented-requirements/:reqId/by-components/:byComponentId", h.GetImplementedRequirementByComponent, guard.Read()) api.PUT("/:id/control-implementation/implemented-requirements/:reqId/by-components/:byComponentId", h.UpdateImplementedRequirementByComponent, guard.Update()) + api.DELETE("/:id/control-implementation/implemented-requirements/:reqId/by-components/:byComponentId", h.DeleteImplementedRequirementByComponent, guard.Delete()) + api.GET("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components", h.GetImplementedRequirementStatementByComponents, guard.Read()) + api.GET("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId", h.GetImplementedRequirementStatementByComponent, guard.Read()) api.PUT("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId", h.UpdateImplementedRequirementStatementByComponent, guard.Update()) api.DELETE("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId", h.DeleteImplementedRequirementStatementByComponent, guard.Delete()) api.POST("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components", h.CreateImplementedRequirementStatementByComponent, guard.Create()) @@ -475,9 +483,11 @@ func (h *SystemSecurityPlanHandler) Register(api *echo.Group, guard middleware.R api.POST("/:id/control-implementation/implemented-requirements/:reqId/by-components/:byComponentId/export", h.CreateImplementedRequirementByComponentExport, guard.Create()) api.PUT("/:id/control-implementation/implemented-requirements/:reqId/by-components/:byComponentId/export", h.UpdateImplementedRequirementByComponentExport, guard.Update()) api.DELETE("/:id/control-implementation/implemented-requirements/:reqId/by-components/:byComponentId/export", h.DeleteImplementedRequirementByComponentExport, guard.Delete()) + api.GET("/:id/control-implementation/implemented-requirements/:reqId/by-components/:byComponentId/export/provided", h.GetImplementedRequirementByComponentExportProvided, guard.Read()) api.POST("/:id/control-implementation/implemented-requirements/:reqId/by-components/:byComponentId/export/provided", h.CreateImplementedRequirementByComponentExportProvided, guard.Create()) api.PUT("/:id/control-implementation/implemented-requirements/:reqId/by-components/:byComponentId/export/provided/:providedId", h.UpdateImplementedRequirementByComponentExportProvided, guard.Update()) api.DELETE("/:id/control-implementation/implemented-requirements/:reqId/by-components/:byComponentId/export/provided/:providedId", h.DeleteImplementedRequirementByComponentExportProvided, guard.Delete()) + api.GET("/:id/control-implementation/implemented-requirements/:reqId/by-components/:byComponentId/export/responsibilities", h.GetImplementedRequirementByComponentExportResponsibilities, guard.Read()) api.POST("/:id/control-implementation/implemented-requirements/:reqId/by-components/:byComponentId/export/responsibilities", h.CreateImplementedRequirementByComponentExportResponsibility, guard.Create()) api.PUT("/:id/control-implementation/implemented-requirements/:reqId/by-components/:byComponentId/export/responsibilities/:responsibilityId", h.UpdateImplementedRequirementByComponentExportResponsibility, guard.Update()) api.DELETE("/:id/control-implementation/implemented-requirements/:reqId/by-components/:byComponentId/export/responsibilities/:responsibilityId", h.DeleteImplementedRequirementByComponentExportResponsibility, guard.Delete()) @@ -485,12 +495,29 @@ func (h *SystemSecurityPlanHandler) Register(api *echo.Group, guard middleware.R api.POST("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId/export", h.CreateImplementedRequirementStatementByComponentExport, guard.Create()) api.PUT("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId/export", h.UpdateImplementedRequirementStatementByComponentExport, guard.Update()) api.DELETE("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId/export", h.DeleteImplementedRequirementStatementByComponentExport, guard.Delete()) + api.GET("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId/export/provided", h.GetImplementedRequirementStatementByComponentExportProvided, guard.Read()) api.POST("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId/export/provided", h.CreateImplementedRequirementStatementByComponentExportProvided, guard.Create()) api.PUT("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId/export/provided/:providedId", h.UpdateImplementedRequirementStatementByComponentExportProvided, guard.Update()) api.DELETE("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId/export/provided/:providedId", h.DeleteImplementedRequirementStatementByComponentExportProvided, guard.Delete()) + api.GET("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId/export/responsibilities", h.GetImplementedRequirementStatementByComponentExportResponsibilities, guard.Read()) api.POST("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId/export/responsibilities", h.CreateImplementedRequirementStatementByComponentExportResponsibility, guard.Create()) api.PUT("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId/export/responsibilities/:responsibilityId", h.UpdateImplementedRequirementStatementByComponentExportResponsibility, guard.Update()) api.DELETE("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId/export/responsibilities/:responsibilityId", h.DeleteImplementedRequirementStatementByComponentExportResponsibility, guard.Delete()) + + // Consumer-side CRUD: statement-level only. Inherited/Satisfied describe what this + // system consumes from an upstream and how it discharges the upstream's + // responsibilities — both hang off a statement-anchored by-component by construction, + // so they get no requirement-level surface. + api.GET("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId/inherited", h.GetImplementedRequirementStatementByComponentInherited, guard.Read()) + api.POST("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId/inherited", h.CreateImplementedRequirementStatementByComponentInherited, guard.Create()) + api.PUT("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId/inherited/:inheritedId", h.UpdateImplementedRequirementStatementByComponentInherited, guard.Update()) + api.DELETE("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId/inherited/:inheritedId", h.DeleteImplementedRequirementStatementByComponentInherited, guard.Delete()) + api.GET("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId/satisfied", h.GetImplementedRequirementStatementByComponentSatisfied, guard.Read()) + api.POST("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId/satisfied", h.CreateImplementedRequirementStatementByComponentSatisfied, guard.Create()) + api.PUT("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId/satisfied/:satisfiedId", h.UpdateImplementedRequirementStatementByComponentSatisfied, guard.Update()) + api.DELETE("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/by-components/:byComponentId/satisfied/:satisfiedId", h.DeleteImplementedRequirementStatementByComponentSatisfied, guard.Delete()) + + api.GET("/:id/shared-responsibility", h.SharedResponsibility, guard.Read()) api.DELETE("/:id/control-implementation/implemented-requirements/:reqId", h.DeleteImplementedRequirement, guard.Delete()) api.POST("/:id/control-implementation/implemented-requirements/:reqId/suggest-components", h.SuggestComponents, guard.Read()) api.POST("/:id/control-implementation/implemented-requirements/:reqId/apply-suggestion", h.ApplySuggestion, guard.Update()) @@ -3758,7 +3785,18 @@ func (h *SystemSecurityPlanHandler) UpdateImplementedRequirement(ctx echo.Contex // UpdateImplementedRequirementByComponent godoc // // @Summary Update a by-component within an implemented requirement -// @Description Updates an existing by-component that belongs to an implemented requirement for a given SSP. +// @Description Deprecated: requirement-anchored by-components are legacy — the statement is +// @Description the canonical anchor for shared responsibility. Use +// @Description PUT .../statements/{stmtId}/by-components/{byComponentId} instead. This route +// @Description remains so existing requirement-anchored rows can be edited and wound down; +// @Description there is no requirement-level POST. +// @Description +// @Description Updates metadata only — description, props, links, set-parameters, remarks, +// @Description implementation-status and responsible-roles. Any export, inherited or +// @Description satisfied entries in the body are IGNORED (they have their own sub-resource +// @Description routes); component-uuid is immutable. Previously this blind-Saved a struct +// @Description rebuilt from the request body, which zeroed every omitted field and upserted +// @Description nested associations with no cascade cleanup. // @Tags System Security Plans // @Accept json // @Produce json @@ -3770,81 +3808,15 @@ func (h *SystemSecurityPlanHandler) UpdateImplementedRequirement(ctx echo.Contex // @Failure 400 {object} api.Error // @Failure 404 {object} api.Error // @Failure 500 {object} api.Error -// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId} [put] +// @Deprecated +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId} [put] func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementByComponent(ctx echo.Context) error { - idParam := ctx.Param("id") - sspID, err := uuid.Parse(idParam) - if err != nil { - h.sugar.Warnw("Invalid SSP id", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - - reqIdParam := ctx.Param("reqId") - reqID, err := uuid.Parse(reqIdParam) - if err != nil { - h.sugar.Warnw("Invalid requirement id", "reqId", reqIdParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - - byComponentIdParam := ctx.Param("byComponentId") - byComponentID, err := uuid.Parse(byComponentIdParam) - if err != nil { - h.sugar.Warnw("Invalid component id", "byComponentId", byComponentIdParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - - var ssp relational.SystemSecurityPlan - if err := h.db.Preload("ControlImplementation"). - First(&ssp, "id = ?", sspID).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("SSP not found"))) - } - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) - } - - var req relational.ImplementedRequirement - if err := h.db.Where("id = ? AND control_implementation_id = ?", reqID, ssp.ControlImplementation.ID). - First(&req).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("requirement not found"))) - } - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) - } - - var existing relational.ByComponent - if err := h.db.Where("id = ? AND parent_id = ? AND parent_type = ?", - byComponentID, req.ID, "implemented_requirements"). - First(&existing).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("by-component not found"))) - } - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) - } - - var oscalBC oscalTypes_1_1_3.ByComponent - if err := ctx.Bind(&oscalBC); err != nil { - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - - relBC := &relational.ByComponent{} - relBC.UnmarshalOscal(oscalBC) - - if err := validateByComponentImplementationStatus(relBC); err != nil { - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - - relBC.ID = &byComponentID - relBC.ParentID = req.ID - parentType := "implemented_requirements" - relBC.ParentType = &parentType - - if err := h.db.Save(relBC).Error; err != nil { - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + bc, ok := h.resolveByComponentForRequirement(ctx) + if !ok { + return nil } - - return ctx.JSON(http.StatusOK, - handler.GenericDataResponse[oscalTypes_1_1_3.ByComponent]{Data: *relBC.MarshalOscal()}, - ) + return h.updateByComponentMetadata(ctx, bc) } // DeleteImplementedRequirement godoc @@ -4386,7 +4358,13 @@ func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementStatement(ctx ec // UpdateImplementedRequirementStatementByComponent godoc // // @Summary Update a by-component within a statement (within an implemented requirement) -// @Description Updates a by-component within an existing statement within an implemented requirement for a given SSP. +// @Description Updates metadata only — description, props, links, set-parameters, remarks, +// @Description implementation-status and responsible-roles. Any export, inherited or +// @Description satisfied entries in the body are IGNORED: those subtrees are managed through +// @Description their own sub-resource routes, which enforce the leverage bookkeeping this +// @Description route cannot. component-uuid is immutable. Previously this blind-Saved a +// @Description struct rebuilt from the request body, which zeroed every omitted field and +// @Description upserted nested associations with no cascade cleanup. // @Tags System Security Plans // @Accept json // @Produce json @@ -4399,104 +4377,114 @@ func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementStatement(ctx ec // @Failure 400 {object} api.Error // @Failure 404 {object} api.Error // @Failure 500 {object} api.Error +// @Security OAuth2Password // @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId} [put] func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementStatementByComponent(ctx echo.Context) error { - idParam := ctx.Param("id") - sspID, err := uuid.Parse(idParam) - if err != nil { - h.sugar.Warnw("Invalid SSP id", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - - reqIdParam := ctx.Param("reqId") - reqID, err := uuid.Parse(reqIdParam) - if err != nil { - h.sugar.Warnw("Invalid requirement id", "reqId", reqIdParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + bc, ok := h.resolveByComponentForStatement(ctx) + if !ok { + return nil } + return h.updateByComponentMetadata(ctx, bc) +} - stmtIdParam := ctx.Param("stmtId") - stmtID, err := uuid.Parse(stmtIdParam) - if err != nil { - h.sugar.Warnw("Invalid statement id", "stmtId", stmtIdParam, "error", err) +// updateByComponentMetadata updates an already-resolved by-component's own scalar and +// metadata fields, leaving its Export/Inherited/Satisfied subtrees strictly alone. +// +// Both by-component PUTs used to `db.Save()` a ByComponent freshly built by UnmarshalOscal +// from the request body. That had two failure modes: any field the client omitted was zeroed +// (a PUT sending only a new description silently wiped remarks, props, links, set-parameters +// and implementation-status), and any nested export/inherited/satisfied in the body was +// upserted as a GORM association with no cascade cleanup — diverging from the careful +// deleteByComponentCascade path the DELETE routes use, and bypassing the leverage bookkeeping +// (409-on-subscription-owned-inherited, satisfaction re-derivation) the sub-resource routes +// enforce. So: only the fields this route owns are written, and nested subtrees in the body +// are ignored rather than half-applied. +func (h *SystemSecurityPlanHandler) updateByComponentMetadata(ctx echo.Context, bc *relational.ByComponent) error { + var oscalBC oscalTypes_1_1_3.ByComponent + if err := ctx.Bind(&oscalBC); err != nil { return ctx.JSON(http.StatusBadRequest, api.NewError(err)) } - byComponentIdParam := ctx.Param("byComponentId") - byComponentID, err := uuid.Parse(byComponentIdParam) - if err != nil { - h.sugar.Warnw("Invalid component id", "byComponentId", byComponentIdParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + // UnmarshalOscal MustParses both uuids; a body missing (or malformed in) either would + // panic rather than 400. Echoing the path-resolved by-component's own ids keeps the + // parse total without making uuid a required body field. + oscalBC.UUID = bc.ID.String() + if strings.TrimSpace(oscalBC.ComponentUuid) == "" { + oscalBC.ComponentUuid = bc.ComponentUUID.String() + } else if _, err := uuid.Parse(oscalBC.ComponentUuid); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("component-uuid must be a valid UUID"))) } - // Step 1: Verify SSP exists - var ssp relational.SystemSecurityPlan - if err := h.db.Preload("ControlImplementation"). - First(&ssp, "id = ?", sspID).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("SSP not found"))) - } - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) - } + parsed := &relational.ByComponent{} + parsed.UnmarshalOscal(oscalBC) - // Step 2: Verify Implemented Requirement belongs to SSP - var req relational.ImplementedRequirement - if err := h.db.Where("id = ? AND control_implementation_id = ?", reqID, ssp.ControlImplementation.ID). - First(&req).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("requirement not found"))) - } - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + if err := validateByComponentImplementationStatus(parsed); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) } - // Step 3: Verify Statement belongs to Requirement - var stmt relational.Statement - if err := h.db.Where("id = ? AND implemented_requirement_id = ?", stmtID, req.ID). - First(&stmt).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("statement not found"))) + if err := h.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&relational.ByComponent{}). + Where("id = ?", bc.ID). + Updates(map[string]any{ + "description": parsed.Description, + "remarks": parsed.Remarks, + "props": parsed.Props, + "links": parsed.Links, + "set_parameters": parsed.SetParameters, + "implementation_status": parsed.ImplementationStatus, + }).Error; err != nil { + return err } + return replaceResponsibleRoles(tx, bc, parsed.ResponsibleRoles) + }); err != nil { + h.sugar.Errorf("Failed to update by-component: %v", err) return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } - // Step 4: Verify ByComponent belongs to Statement - var existing relational.ByComponent - if err := h.db.Where("id = ? AND parent_id = ? AND parent_type = ?", - byComponentID, stmt.ID, "statements"). - First(&existing).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("by-component not found"))) - } + updated, err := h.reloadByComponent(*bc.ID) + if err != nil { return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } + return ctx.JSON(http.StatusOK, + handler.GenericDataResponse[oscalTypes_1_1_3.ByComponent]{Data: *updated.MarshalOscal()}, + ) +} - // Step 5: Parse request body - var oscalBC oscalTypes_1_1_3.ByComponent - if err := ctx.Bind(&oscalBC); err != nil { - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) +// replaceResponsibleRoles swaps a parent's polymorphic ResponsibleRoles for a new set. The +// old rows go through deleteResponsibleRoles so their responsible_role_parties join rows are +// cleared first (Party records themselves are shared and must survive); the new ones are +// appended through the association so GORM fills in parent_id/parent_type — a plain Replace +// would orphan the old rows rather than delete them. +func replaceResponsibleRoles(tx *gorm.DB, parent any, roles []relational.ResponsibleRole) error { + // Read the current roles through the association so GORM supplies the polymorphic + // parent_type itself. deleteResponsibleRoles clears each role's Parties join rows by + // role id, so they don't need preloading here. + var existing []relational.ResponsibleRole + if err := tx.Model(parent).Association("ResponsibleRoles").Find(&existing); err != nil { + return err } - - // Step 6: Map and update - relBC := &relational.ByComponent{} - relBC.UnmarshalOscal(oscalBC) - - if err := validateByComponentImplementationStatus(relBC); err != nil { - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + if err := deleteResponsibleRoles(tx, existing); err != nil { + return err } - - relBC.ID = &byComponentID - relBC.ParentID = stmt.ID - parentType := "statements" - relBC.ParentType = &parentType - - if err := h.db.Save(relBC).Error; err != nil { - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + if len(roles) == 0 { + return nil } + return tx.Model(parent).Association("ResponsibleRoles").Append(roles) +} - // Step 7: Return updated - return ctx.JSON(http.StatusOK, - handler.GenericDataResponse[oscalTypes_1_1_3.ByComponent]{Data: *relBC.MarshalOscal()}, - ) +// reloadByComponent re-fetches a by-component with every subtree the single-by-component GET +// contract promises: export (with provided/responsibilities and their responsible-roles), +// inherited, satisfied, and the by-component's own responsible-roles. +func (h *SystemSecurityPlanHandler) reloadByComponent(byComponentID uuid.UUID) (*relational.ByComponent, error) { + var bc relational.ByComponent + err := h.db. + Preload("ResponsibleRoles.Parties"). + Preload("Inherited.ResponsibleRoles.Parties"). + Preload("Satisfied.ResponsibleRoles.Parties"). + Preload("Export.Provided.ResponsibleRoles.Parties"). + Preload("Export.Responsibilities.ResponsibleRoles.Parties"). + First(&bc, "id = ?", byComponentID).Error + return &bc, err } // DeleteImplementedRequirementStatementByComponent godoc @@ -5061,6 +5049,11 @@ func deleteByComponentCascade(tx *gorm.DB, byComponentID uuid.UUID) error { // // @Summary Get the export for a control-level by-component // @Description Retrieves the Export (with nested Provided and Responsibilities) for a by-component within an implemented requirement. +// @Description +// @Description Deprecated: requirement-anchored exports are legacy. Shared responsibility is +// @Description tracked per statement — use the statement-level equivalent under +// @Description .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so +// @Description existing requirement-anchored exports can be read and wound down. // @Tags System Security Plans // @Produce json // @Param id path string true "SSP ID" @@ -5070,8 +5063,9 @@ func deleteByComponentCascade(tx *gorm.DB, byComponentID uuid.UUID) error { // @Failure 400 {object} api.Error // @Failure 404 {object} api.Error // @Failure 500 {object} api.Error -// @Security OAuth2Password -// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export [get] +// @Deprecated +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export [get] func (h *SystemSecurityPlanHandler) GetImplementedRequirementByComponentExport(ctx echo.Context) error { bc, ok := h.resolveByComponentForRequirement(ctx) if !ok { @@ -5084,6 +5078,11 @@ func (h *SystemSecurityPlanHandler) GetImplementedRequirementByComponentExport(c // // @Summary Create the export for a control-level by-component // @Description Creates the Export for a by-component within an implemented requirement. A by-component may have at most one Export. +// @Description +// @Description Deprecated: requirement-anchored exports are legacy. Shared responsibility is +// @Description tracked per statement — use the statement-level equivalent under +// @Description .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so +// @Description existing requirement-anchored exports can be read and wound down. // @Tags System Security Plans // @Accept json // @Produce json @@ -5096,8 +5095,9 @@ func (h *SystemSecurityPlanHandler) GetImplementedRequirementByComponentExport(c // @Failure 404 {object} api.Error // @Failure 409 {object} api.Error // @Failure 500 {object} api.Error -// @Security OAuth2Password -// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export [post] +// @Deprecated +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export [post] func (h *SystemSecurityPlanHandler) CreateImplementedRequirementByComponentExport(ctx echo.Context) error { bc, ok := h.resolveByComponentForRequirement(ctx) if !ok { @@ -5110,6 +5110,11 @@ func (h *SystemSecurityPlanHandler) CreateImplementedRequirementByComponentExpor // // @Summary Update the export for a control-level by-component // @Description Updates the scalar fields of an existing Export for a by-component within an implemented requirement. Provided and Responsibilities entries are managed via their own routes. +// @Description +// @Description Deprecated: requirement-anchored exports are legacy. Shared responsibility is +// @Description tracked per statement — use the statement-level equivalent under +// @Description .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so +// @Description existing requirement-anchored exports can be read and wound down. // @Tags System Security Plans // @Accept json // @Produce json @@ -5121,8 +5126,9 @@ func (h *SystemSecurityPlanHandler) CreateImplementedRequirementByComponentExpor // @Failure 400 {object} api.Error // @Failure 404 {object} api.Error // @Failure 500 {object} api.Error -// @Security OAuth2Password -// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export [put] +// @Deprecated +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export [put] func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementByComponentExport(ctx echo.Context) error { bc, ok := h.resolveByComponentForRequirement(ctx) if !ok { @@ -5135,6 +5141,11 @@ func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementByComponentExpor // // @Summary Delete the export for a control-level by-component // @Description Deletes the Export (and its Provided/Responsibilities entries) for a by-component within an implemented requirement. +// @Description +// @Description Deprecated: requirement-anchored exports are legacy. Shared responsibility is +// @Description tracked per statement — use the statement-level equivalent under +// @Description .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so +// @Description existing requirement-anchored exports can be read and wound down. // @Tags System Security Plans // @Param id path string true "SSP ID" // @Param reqId path string true "Requirement ID" @@ -5143,8 +5154,9 @@ func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementByComponentExpor // @Failure 400 {object} api.Error // @Failure 404 {object} api.Error // @Failure 500 {object} api.Error -// @Security OAuth2Password -// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export [delete] +// @Deprecated +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export [delete] func (h *SystemSecurityPlanHandler) DeleteImplementedRequirementByComponentExport(ctx echo.Context) error { bc, ok := h.resolveByComponentForRequirement(ctx) if !ok { @@ -5362,6 +5374,11 @@ func (h *SystemSecurityPlanHandler) deleteByComponentExportProvided(ctx echo.Con // // @Summary Create a provided entry on a control-level by-component's export // @Description Creates a ProvidedControlImplementation entry under the Export of a by-component within an implemented requirement. +// @Description +// @Description Deprecated: requirement-anchored exports are legacy. Shared responsibility is +// @Description tracked per statement — use the statement-level equivalent under +// @Description .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so +// @Description existing requirement-anchored exports can be read and wound down. // @Tags System Security Plans // @Accept json // @Produce json @@ -5373,8 +5390,9 @@ func (h *SystemSecurityPlanHandler) deleteByComponentExportProvided(ctx echo.Con // @Failure 400 {object} api.Error // @Failure 404 {object} api.Error // @Failure 500 {object} api.Error -// @Security OAuth2Password -// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/provided [post] +// @Deprecated +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/provided [post] func (h *SystemSecurityPlanHandler) CreateImplementedRequirementByComponentExportProvided(ctx echo.Context) error { bc, ok := h.resolveByComponentForRequirement(ctx) if !ok { @@ -5387,6 +5405,11 @@ func (h *SystemSecurityPlanHandler) CreateImplementedRequirementByComponentExpor // // @Summary Update a provided entry on a control-level by-component's export // @Description Replaces an existing ProvidedControlImplementation entry under the Export of a by-component within an implemented requirement. +// @Description +// @Description Deprecated: requirement-anchored exports are legacy. Shared responsibility is +// @Description tracked per statement — use the statement-level equivalent under +// @Description .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so +// @Description existing requirement-anchored exports can be read and wound down. // @Tags System Security Plans // @Accept json // @Produce json @@ -5399,8 +5422,9 @@ func (h *SystemSecurityPlanHandler) CreateImplementedRequirementByComponentExpor // @Failure 400 {object} api.Error // @Failure 404 {object} api.Error // @Failure 500 {object} api.Error -// @Security OAuth2Password -// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/provided/{providedId} [put] +// @Deprecated +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/provided/{providedId} [put] func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementByComponentExportProvided(ctx echo.Context) error { bc, ok := h.resolveByComponentForRequirement(ctx) if !ok { @@ -5413,6 +5437,11 @@ func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementByComponentExpor // // @Summary Delete a provided entry on a control-level by-component's export // @Description Deletes an existing ProvidedControlImplementation entry under the Export of a by-component within an implemented requirement. +// @Description +// @Description Deprecated: requirement-anchored exports are legacy. Shared responsibility is +// @Description tracked per statement — use the statement-level equivalent under +// @Description .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so +// @Description existing requirement-anchored exports can be read and wound down. // @Tags System Security Plans // @Param id path string true "SSP ID" // @Param reqId path string true "Requirement ID" @@ -5422,8 +5451,9 @@ func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementByComponentExpor // @Failure 400 {object} api.Error // @Failure 404 {object} api.Error // @Failure 500 {object} api.Error -// @Security OAuth2Password -// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/provided/{providedId} [delete] +// @Deprecated +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/provided/{providedId} [delete] func (h *SystemSecurityPlanHandler) DeleteImplementedRequirementByComponentExportProvided(ctx echo.Context) error { bc, ok := h.resolveByComponentForRequirement(ctx) if !ok { @@ -5603,6 +5633,11 @@ func (h *SystemSecurityPlanHandler) deleteByComponentExportResponsibility(ctx ec // // @Summary Create a responsibility entry on a control-level by-component's export // @Description Creates a ControlImplementationResponsibility entry under the Export of a by-component within an implemented requirement. +// @Description +// @Description Deprecated: requirement-anchored exports are legacy. Shared responsibility is +// @Description tracked per statement — use the statement-level equivalent under +// @Description .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so +// @Description existing requirement-anchored exports can be read and wound down. // @Tags System Security Plans // @Accept json // @Produce json @@ -5614,8 +5649,9 @@ func (h *SystemSecurityPlanHandler) deleteByComponentExportResponsibility(ctx ec // @Failure 400 {object} api.Error // @Failure 404 {object} api.Error // @Failure 500 {object} api.Error -// @Security OAuth2Password -// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/responsibilities [post] +// @Deprecated +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/responsibilities [post] func (h *SystemSecurityPlanHandler) CreateImplementedRequirementByComponentExportResponsibility(ctx echo.Context) error { bc, ok := h.resolveByComponentForRequirement(ctx) if !ok { @@ -5628,6 +5664,11 @@ func (h *SystemSecurityPlanHandler) CreateImplementedRequirementByComponentExpor // // @Summary Update a responsibility entry on a control-level by-component's export // @Description Replaces an existing ControlImplementationResponsibility entry under the Export of a by-component within an implemented requirement. +// @Description +// @Description Deprecated: requirement-anchored exports are legacy. Shared responsibility is +// @Description tracked per statement — use the statement-level equivalent under +// @Description .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so +// @Description existing requirement-anchored exports can be read and wound down. // @Tags System Security Plans // @Accept json // @Produce json @@ -5640,8 +5681,9 @@ func (h *SystemSecurityPlanHandler) CreateImplementedRequirementByComponentExpor // @Failure 400 {object} api.Error // @Failure 404 {object} api.Error // @Failure 500 {object} api.Error -// @Security OAuth2Password -// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/responsibilities/{responsibilityId} [put] +// @Deprecated +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/responsibilities/{responsibilityId} [put] func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementByComponentExportResponsibility(ctx echo.Context) error { bc, ok := h.resolveByComponentForRequirement(ctx) if !ok { @@ -5654,6 +5696,11 @@ func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementByComponentExpor // // @Summary Delete a responsibility entry on a control-level by-component's export // @Description Deletes an existing ControlImplementationResponsibility entry under the Export of a by-component within an implemented requirement. +// @Description +// @Description Deprecated: requirement-anchored exports are legacy. Shared responsibility is +// @Description tracked per statement — use the statement-level equivalent under +// @Description .../statements/{stmtId}/by-components/{byComponentId}/export. This route stays so +// @Description existing requirement-anchored exports can be read and wound down. // @Tags System Security Plans // @Param id path string true "SSP ID" // @Param reqId path string true "Requirement ID" @@ -5663,8 +5710,9 @@ func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementByComponentExpor // @Failure 400 {object} api.Error // @Failure 404 {object} api.Error // @Failure 500 {object} api.Error -// @Security OAuth2Password -// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/responsibilities/{responsibilityId} [delete] +// @Deprecated +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/by-components/{byComponentId}/export/responsibilities/{responsibilityId} [delete] func (h *SystemSecurityPlanHandler) DeleteImplementedRequirementByComponentExportResponsibility(ctx echo.Context) error { bc, ok := h.resolveByComponentForRequirement(ctx) if !ok { diff --git a/internal/service/migrator.go b/internal/service/migrator.go index c4149204..3a9a7a6e 100644 --- a/internal/service/migrator.go +++ b/internal/service/migrator.go @@ -17,6 +17,7 @@ import ( suggestionrel "github.com/compliance-framework/api/internal/service/relational/suggestions" templaterel "github.com/compliance-framework/api/internal/service/relational/templates" "github.com/compliance-framework/api/internal/service/relational/workflows" + "github.com/google/uuid" "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -236,6 +237,9 @@ func MigrateUpWithConfig(db *gorm.DB, cfg *config.Config) error { if err := migrateBackfillCatalogActive(db); err != nil { return err } + if err := migrateBackfillOfferingItemStatementIDs(db); err != nil { + return err + } // Create functional index for case-insensitive control_id lookups in filter_controls join table // This improves performance of UPPER(control_id) queries in the suggestion service @@ -705,6 +709,128 @@ func migrateBackfillCatalogActive(db *gorm.DB) error { Update("active", true).Error } +// migrateBackfillOfferingItemStatementIDs normalizes legacy ssp_export_offering_items +// rows written before the statement became the canonical anchor for shared responsibility. +// For each item with a NULL statement_id it walks provided_uuid -> export -> by_component +// and, when that by-component is statement-anchored, copies the parent Statement's +// statement-id onto the item. +// +// A requirement-anchored by-component has no statement to derive, so those rows keep a NULL +// statement_id: they stay readable and deletable, but Subscribe now rejects them with a 422 +// rather than silently falling back to requirement-anchoring. The count of both outcomes is +// logged so an operator can see how much legacy data still needs winding down. +// +// The DB column stays nullable on purpose — statement_id is enforced on the write path +// (createExportOfferingItemRequest.validate), not by the schema, so legacy rows survive. +func migrateBackfillOfferingItemStatementIDs(db *gorm.DB) error { + if !db.Migrator().HasTable(&relational.SSPExportOfferingItem{}) { + return nil + } + + var items []relational.SSPExportOfferingItem + if err := db.Where("statement_id IS NULL").Find(&items).Error; err != nil { + return fmt.Errorf("failed to load legacy offering items: %w", err) + } + if len(items) == 0 { + return nil + } + + providedUUIDs := make([]uuid.UUID, 0, len(items)) + seen := make(map[uuid.UUID]bool, len(items)) + for _, item := range items { + if !seen[item.ProvidedUUID] { + seen[item.ProvidedUUID] = true + providedUUIDs = append(providedUUIDs, item.ProvidedUUID) + } + } + + var provided []relational.ProvidedControlImplementation + if err := db.Where("id IN ?", providedUUIDs).Find(&provided).Error; err != nil { + return fmt.Errorf("failed to load provided control implementations: %w", err) + } + exportIDByProvided := make(map[uuid.UUID]uuid.UUID, len(provided)) + exportIDs := make([]uuid.UUID, 0, len(provided)) + for _, p := range provided { + exportIDByProvided[*p.ID] = p.ExportId + exportIDs = append(exportIDs, p.ExportId) + } + + var exports []relational.Export + if err := db.Where("id IN ?", exportIDs).Find(&exports).Error; err != nil { + return fmt.Errorf("failed to load exports: %w", err) + } + byComponentIDByExport := make(map[uuid.UUID]uuid.UUID, len(exports)) + byComponentIDs := make([]uuid.UUID, 0, len(exports)) + for _, e := range exports { + byComponentIDByExport[*e.ID] = e.ByComponentId + byComponentIDs = append(byComponentIDs, e.ByComponentId) + } + + var byComponents []relational.ByComponent + if err := db.Where("id IN ?", byComponentIDs).Find(&byComponents).Error; err != nil { + return fmt.Errorf("failed to load by-components: %w", err) + } + statementIDByComponent := make(map[uuid.UUID]uuid.UUID, len(byComponents)) + statementRowIDs := make([]uuid.UUID, 0, len(byComponents)) + for _, bc := range byComponents { + if bc.ParentType == nil || *bc.ParentType != "statements" || bc.ParentID == nil { + continue + } + statementIDByComponent[*bc.ID] = *bc.ParentID + statementRowIDs = append(statementRowIDs, *bc.ParentID) + } + + var statements []relational.Statement + if len(statementRowIDs) > 0 { + if err := db.Where("id IN ?", statementRowIDs).Find(&statements).Error; err != nil { + return fmt.Errorf("failed to load statements: %w", err) + } + } + statementIDByRow := make(map[uuid.UUID]string, len(statements)) + for _, s := range statements { + statementIDByRow[*s.ID] = s.StatementId + } + + backfilled, undeterminable := 0, 0 + for _, item := range items { + exportID, ok := exportIDByProvided[item.ProvidedUUID] + if !ok { + undeterminable++ + continue + } + byComponentID, ok := byComponentIDByExport[exportID] + if !ok { + undeterminable++ + continue + } + statementRowID, ok := statementIDByComponent[byComponentID] + if !ok { + undeterminable++ + continue + } + statementID, ok := statementIDByRow[statementRowID] + if !ok || statementID == "" { + undeterminable++ + continue + } + + if err := db.Model(&relational.SSPExportOfferingItem{}). + Where("id = ?", item.ID). + Update("statement_id", statementID).Error; err != nil { + return fmt.Errorf("failed to backfill statement_id on offering item %s: %w", item.ID, err) + } + backfilled++ + } + + db.Logger.Info( + context.Background(), + "Backfilled statement_id on %d legacy export offering item(s); %d could not be derived (requirement-anchored or dangling provided-uuid) and remain NULL", + backfilled, + undeterminable, + ) + return nil +} + // migrateSSPProfileIDToJoinTable copies the legacy single profile_id FK from // system_security_plans into the new ssp_profiles join table. Rows that already // exist (ON CONFLICT DO NOTHING) are skipped, making the migration idempotent. diff --git a/internal/service/migrator_test.go b/internal/service/migrator_test.go index e0cd0b9e..350ad79c 100644 --- a/internal/service/migrator_test.go +++ b/internal/service/migrator_test.go @@ -7,6 +7,7 @@ import ( "github.com/compliance-framework/api/internal/service/notification" slackprovider "github.com/compliance-framework/api/internal/service/notification/providers/slack" "github.com/compliance-framework/api/internal/service/relational" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/datatypes" @@ -213,3 +214,93 @@ func TestMigrateLegacySystemNotificationDestinationsSkipsWhenTableAlreadyExists( require.NoError(t, db.Model(&relational.SystemNotificationDestination{}).Count(&count).Error) assert.Zero(t, count) } + +// TestMigrateBackfillOfferingItemStatementIDs: legacy offering items written before the +// statement became the canonical anchor carry a NULL statement_id. The backfill derives it by +// walking provided_uuid -> export -> by_component for statement-anchored rows, and leaves it +// NULL for requirement-anchored ones (where no statement exists to derive) and for dangling +// provided-uuids — those are the rows Subscribe now rejects with a 422. +func TestMigrateBackfillOfferingItemStatementIDs(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &relational.SSPExportOffering{}, + &relational.SSPExportOfferingItem{}, + &relational.ProvidedControlImplementation{}, + &relational.Export{}, + &relational.ByComponent{}, + &relational.Statement{}, + &relational.ImplementedRequirement{}, + )) + + offering := relational.SSPExportOffering{Title: "Legacy offering"} + require.NoError(t, db.Create(&offering).Error) + + requirement := relational.ImplementedRequirement{ControlId: "ac-2"} + require.NoError(t, db.Create(&requirement).Error) + statement := relational.Statement{ImplementedRequirementId: *requirement.ID, StatementId: "ac-2_smt.a"} + require.NoError(t, db.Create(&statement).Error) + + // A statement-anchored export: the statement-id IS derivable. + statementsType := "statements" + statementBC := relational.ByComponent{ParentID: statement.ID, ParentType: &statementsType} + require.NoError(t, db.Create(&statementBC).Error) + statementExport := relational.Export{ByComponentId: *statementBC.ID} + require.NoError(t, db.Create(&statementExport).Error) + statementProvided := relational.ProvidedControlImplementation{ExportId: *statementExport.ID} + require.NoError(t, db.Create(&statementProvided).Error) + + // A requirement-anchored export: the statement-id is NOT derivable. + requirementsType := "implemented_requirements" + requirementBC := relational.ByComponent{ParentID: requirement.ID, ParentType: &requirementsType} + require.NoError(t, db.Create(&requirementBC).Error) + requirementExport := relational.Export{ByComponentId: *requirementBC.ID} + require.NoError(t, db.Create(&requirementExport).Error) + requirementProvided := relational.ProvidedControlImplementation{ExportId: *requirementExport.ID} + require.NoError(t, db.Create(&requirementProvided).Error) + + derivable := relational.SSPExportOfferingItem{ + OfferingID: *offering.ID, ControlID: "ac-2", ProvidedUUID: *statementProvided.ID, + } + require.NoError(t, db.Create(&derivable).Error) + + undeivable := relational.SSPExportOfferingItem{ + OfferingID: *offering.ID, ControlID: "ac-2", ProvidedUUID: *requirementProvided.ID, + } + require.NoError(t, db.Create(&undeivable).Error) + + dangling := relational.SSPExportOfferingItem{ + OfferingID: *offering.ID, ControlID: "ac-2", ProvidedUUID: uuid.New(), + } + require.NoError(t, db.Create(&dangling).Error) + + alreadySet := relational.SSPExportOfferingItem{ + OfferingID: *offering.ID, ControlID: "ac-2", ProvidedUUID: *statementProvided.ID, + StatementID: func() *string { s := "ac-2_smt.z"; return &s }(), + } + require.NoError(t, db.Create(&alreadySet).Error) + + require.NoError(t, migrateBackfillOfferingItemStatementIDs(db)) + + reload := func(id *uuid.UUID) relational.SSPExportOfferingItem { + var item relational.SSPExportOfferingItem + require.NoError(t, db.First(&item, "id = ?", id).Error) + return item + } + + backfilled := reload(derivable.ID) + require.NotNil(t, backfilled.StatementID) + assert.Equal(t, "ac-2_smt.a", *backfilled.StatementID) + + assert.Nil(t, reload(undeivable.ID).StatementID, "requirement-anchored: no statement to derive") + assert.Nil(t, reload(dangling.ID).StatementID, "dangling provided-uuid: nothing to walk") + + untouched := reload(alreadySet.ID) + require.NotNil(t, untouched.StatementID) + assert.Equal(t, "ac-2_smt.z", *untouched.StatementID, "an item that already had a statement-id is not rewritten") + + // Idempotent: a second run changes nothing. + require.NoError(t, migrateBackfillOfferingItemStatementIDs(db)) + assert.Equal(t, "ac-2_smt.a", *reload(derivable.ID).StatementID) + assert.Nil(t, reload(undeivable.ID).StatementID) +} diff --git a/internal/service/relational/system_security_plan.go b/internal/service/relational/system_security_plan.go index 8b0054fc..fff44aa5 100644 --- a/internal/service/relational/system_security_plan.go +++ b/internal/service/relational/system_security_plan.go @@ -1286,9 +1286,13 @@ func (ir *ImplementedRequirement) MarshalOscal() *oscalTypes_1_1_3.ImplementedRe type ByComponent struct { UUIDModel - // As ByComponent can be found in Implemented Requirements & Statements, using GORM polymorphism to tell us where to attach - ParentID *uuid.UUID `gorm:"type:uuid"` - ParentType *string + // As ByComponent can be found in Implemented Requirements & Statements, using GORM polymorphism to tell us where to attach. + // (parent_id, parent_type) is the lookup key for every by-component resolution — the + // resolveByComponentFor* walkers, findOrCreateByComponent, and the shared-responsibility + // rollup all filter on it — so it carries a composite index rather than being an + // unindexed scan. + ParentID *uuid.UUID `gorm:"type:uuid;index:idx_by_components_parent,priority:1"` + ParentType *string `gorm:"index:idx_by_components_parent,priority:2"` ComponentUUID uuid.UUID `gorm:"type:uuid" json:"component-uuid"` Description string `json:"description"` From 6881fa2b2b3c0636e4cd91213b463b92858568fa Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:13:52 -0300 Subject: [PATCH 02/13] self-review: fix responsible-role m2m join rows, batch allow-list, rename subtree lock --- .../api/handler/oscal/ssp_by_components.go | 8 +- .../api/handler/oscal/ssp_export_offerings.go | 12 +- internal/api/handler/oscal/ssp_leverage.go | 37 +++ .../oscal/ssp_shared_responsibility_test.go | 240 ++++++++++++++++++ .../handler/oscal/system_security_plans.go | 49 +++- 5 files changed, 322 insertions(+), 24 deletions(-) diff --git a/internal/api/handler/oscal/ssp_by_components.go b/internal/api/handler/oscal/ssp_by_components.go index 9e6cb0e5..4c5f0f1e 100644 --- a/internal/api/handler/oscal/ssp_by_components.go +++ b/internal/api/handler/oscal/ssp_by_components.go @@ -459,7 +459,7 @@ func (h *SystemSecurityPlanHandler) CreateImplementedRequirementStatementByCompo // Same advisory-lock guard the Export create uses: nothing in the schema stops two // concurrent creates from racing on the same by-component, and the satisfaction // re-derivation below reads the rows this write produces. - if err := lockByComponentExportCreate(tx, *bc.ID); err != nil { + if err := lockByComponentSubtreeCreate(tx, *bc.ID); err != nil { return err } return tx.Create(relInherited).Error @@ -542,7 +542,7 @@ func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementStatementByCompo }).Error; err != nil { return err } - return replaceResponsibleRoles(tx, &existing, parsed.ResponsibleRoles) + return replaceResponsibleRoles(tx, &existing, *existing.ID, parsed.ResponsibleRoles) }); err != nil { h.sugar.Errorf("Failed to update inherited control implementation: %v", err) return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) @@ -722,7 +722,7 @@ func (h *SystemSecurityPlanHandler) CreateImplementedRequirementStatementByCompo relSatisfied.ByComponentId = *bc.ID if err := h.db.Transaction(func(tx *gorm.DB) error { - if err := lockByComponentExportCreate(tx, *bc.ID); err != nil { + if err := lockByComponentSubtreeCreate(tx, *bc.ID); err != nil { return err } if err := tx.Create(relSatisfied).Error; err != nil { @@ -809,7 +809,7 @@ func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementStatementByCompo }).Error; err != nil { return err } - return replaceResponsibleRoles(tx, &existing, parsed.ResponsibleRoles) + return replaceResponsibleRoles(tx, &existing, *existing.ID, parsed.ResponsibleRoles) }); err != nil { h.sugar.Errorf("Failed to update satisfied responsibility: %v", err) return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) diff --git a/internal/api/handler/oscal/ssp_export_offerings.go b/internal/api/handler/oscal/ssp_export_offerings.go index 98f7ee28..a426d99c 100644 --- a/internal/api/handler/oscal/ssp_export_offerings.go +++ b/internal/api/handler/oscal/ssp_export_offerings.go @@ -1321,14 +1321,10 @@ func (h *SSPExportOfferingHandler) ByControl(ctx echo.Context) error { // Drop items whose offering the downstream isn't allow-listed for, before spending any // further resolution on them. if downstreamSSPID != nil { - allowedOfferings := make(map[uuid.UUID]bool, len(offerings)) - for _, o := range offerings { - allowed, err := isDownstreamAllowed(h.db, *o.ID, *downstreamSSPID) - if err != nil { - h.sugar.Errorf("Failed to check offering allow-list: %v", err) - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) - } - allowedOfferings[*o.ID] = allowed + allowedOfferings, err := bulkAllowedOfferings(h.db, offeringIDs, *downstreamSSPID) + if err != nil { + h.sugar.Errorf("Failed to check offering allow-list: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } filtered := make([]relational.SSPExportOfferingItem, 0, len(items)) for _, item := range items { diff --git a/internal/api/handler/oscal/ssp_leverage.go b/internal/api/handler/oscal/ssp_leverage.go index f1cea428..08082ed2 100644 --- a/internal/api/handler/oscal/ssp_leverage.go +++ b/internal/api/handler/oscal/ssp_leverage.go @@ -247,6 +247,43 @@ func isDownstreamAllowed(db *gorm.DB, offeringID, downstreamSSPID uuid.UUID) (bo return matching > 0, nil } +// bulkAllowedOfferings is the batched form of isDownstreamAllowed: two queries total for any +// number of offerings, rather than two per offering (the by-control catalog filters a whole +// control's worth of offerings against one downstream in a single request). +// +// It encodes exactly the same rule — an offering with zero allow-list rows keeps the type-level +// default (any downstream permitted); one with rows admits only the listed downstreams — and +// lives next to isDownstreamAllowed so the two can't drift apart. +func bulkAllowedOfferings(db *gorm.DB, offeringIDs []uuid.UUID, downstreamSSPID uuid.UUID) (map[uuid.UUID]bool, error) { + allowed := make(map[uuid.UUID]bool, len(offeringIDs)) + if len(offeringIDs) == 0 { + return allowed, nil + } + + var rows []relational.SSPExportOfferingAllowedDownstream + if err := db.Where("offering_id IN ?", offeringIDs).Find(&rows).Error; err != nil { + return nil, fmt.Errorf("failed to load offering allow-lists: %w", err) + } + + hasAllowList := make(map[uuid.UUID]bool, len(offeringIDs)) + listsDownstream := make(map[uuid.UUID]bool, len(offeringIDs)) + for _, row := range rows { + hasAllowList[row.OfferingID] = true + if row.DownstreamSSPID == downstreamSSPID { + listsDownstream[row.OfferingID] = true + } + } + + for _, offeringID := range offeringIDs { + if !hasAllowList[offeringID] { + allowed[offeringID] = true + continue + } + allowed[offeringID] = listsDownstream[offeringID] + } + return allowed, nil +} + // findOrCreateThisSystemComponent finds the downstream's placeholder "this-system" // component, creating one if none exists — not every SSP has one, and there's no // guarantee the subscribing downstream does either. diff --git a/internal/api/handler/oscal/ssp_shared_responsibility_test.go b/internal/api/handler/oscal/ssp_shared_responsibility_test.go index bea76bd3..84209e2e 100644 --- a/internal/api/handler/oscal/ssp_shared_responsibility_test.go +++ b/internal/api/handler/oscal/ssp_shared_responsibility_test.go @@ -1030,3 +1030,243 @@ func TestSharedResponsibilityFiltersByControl(t *testing.T) { require.Empty(t, downUnmatched.Inherits) require.Empty(t, downUnmatched.Satisfies) } + +// --- responsible-roles: the polymorphic m2m nobody was exercising ------------------------ + +// countRoleParties reports how many responsible_role_parties join rows exist for the +// responsible-roles hanging off one polymorphic parent. Party rows themselves are shared and +// must never be deleted — only the join rows and the roles. +func countRoleParties(t *testing.T, db *gorm.DB, parentID uuid.UUID) int64 { + t.Helper() + var count int64 + require.NoError(t, db.Table("responsible_role_parties"). + Joins("JOIN responsible_roles ON responsible_roles.id = responsible_role_parties.responsible_role_id"). + Where("responsible_roles.parent_id = ?", parentID). + Count(&count).Error) + return count +} + +// seedParty creates a Party with an explicit id. Party overrides BeforeCreate to add an +// OnConflict-DoNothing clause, which shadows UUIDModel's id-assigning hook — so a Party created +// without an id never gets one. Every production path sets it from the OSCAL uuid; so does this. +func seedParty(t *testing.T, db *gorm.DB) uuid.UUID { + t.Helper() + id := uuid.New() + name := "Acme" + party := relational.Party{ + UUIDModel: relational.UUIDModel{ID: &id}, + Type: "organization", + Name: &name, + } + require.NoError(t, db.Create(&party).Error) + return *party.ID +} + +// TestInheritedResponsibleRolesSurviveCreateUpdateAndClearOnDelete exercises the polymorphic +// ResponsibleRole + responsible_role_parties m2m under an inherited entry: roles land on create, +// a PUT replaces them (deleting the old rows and their join rows rather than orphaning them), +// and a DELETE clears both the roles and the join rows while leaving the shared Party alone. +func TestInheritedResponsibleRolesSurviveCreateUpdateAndClearOnDelete(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := newSSPHandler(db) + partyID := seedParty(t, db) + + createBody := fmt.Sprintf(`{ + "provided-uuid": %q, + "description": "inherited with roles", + "responsible-roles": [{"role-id": "provider", "party-uuids": [%q]}] + }`, uuid.New().String(), partyID.String()) + + ctx, rec := newByComponentContext(http.MethodPost, createBody, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID) + require.NoError(t, h.CreateImplementedRequirementStatementByComponentInherited(ctx)) + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) + + var created handler.GenericDataResponse[oscalTypes_1_1_3.InheritedControlImplementation] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created)) + require.NotNil(t, created.Data.ResponsibleRoles) + require.Len(t, *created.Data.ResponsibleRoles, 1) + require.Equal(t, "provider", (*created.Data.ResponsibleRoles)[0].RoleId) + + inheritedID := uuid.MustParse(created.Data.UUID) + require.Equal(t, int64(1), countRows(t, db, &relational.ResponsibleRole{}, "parent_id = ?", inheritedID)) + require.Equal(t, int64(1), countRoleParties(t, db, inheritedID)) + + // A PUT replaces the role set: the old role and its join row go, the new one lands. + updateBody := fmt.Sprintf(`{ + "description": "revised", + "responsible-roles": [{"role-id": "auditor", "party-uuids": [%q]}] + }`, partyID.String()) + + ctx, rec = newByComponentContext(http.MethodPut, updateBody, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID, + [2]string{"inheritedId", inheritedID.String()}) + require.NoError(t, h.UpdateImplementedRequirementStatementByComponentInherited(ctx)) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var updated handler.GenericDataResponse[oscalTypes_1_1_3.InheritedControlImplementation] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated)) + require.NotNil(t, updated.Data.ResponsibleRoles) + require.Len(t, *updated.Data.ResponsibleRoles, 1, "the replaced role set must not accumulate") + require.Equal(t, "auditor", (*updated.Data.ResponsibleRoles)[0].RoleId) + + require.Equal(t, int64(1), countRows(t, db, &relational.ResponsibleRole{}, "parent_id = ?", inheritedID), + "the superseded role must be deleted, not orphaned") + require.Equal(t, int64(1), countRoleParties(t, db, inheritedID)) + + // DELETE clears the roles and their join rows... + ctx, rec = newByComponentContext(http.MethodDelete, "", fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID, + [2]string{"inheritedId", inheritedID.String()}) + require.NoError(t, h.DeleteImplementedRequirementStatementByComponentInherited(ctx)) + require.Equal(t, http.StatusNoContent, rec.Code) + + require.Zero(t, countRows(t, db, &relational.ResponsibleRole{}, "parent_id = ?", inheritedID)) + require.Zero(t, countRoleParties(t, db, inheritedID)) + + // ...but the Party is shared and must survive. + require.Equal(t, int64(1), countRows(t, db, &relational.Party{}, "id = ?", partyID)) +} + +// TestSatisfiedResponsibleRolesSurviveCreateUpdateAndClearOnDelete is the same contract on the +// satisfied side. +func TestSatisfiedResponsibleRolesSurviveCreateUpdateAndClearOnDelete(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := newSSPHandler(db) + partyID := seedParty(t, db) + + // Hand-authored inherited entry so respA is a legitimate thing to satisfy. + inherited := relational.InheritedControlImplementation{ + ByComponentId: fx.byComponentID, ProvidedUuid: fx.providedID, Description: "hand-authored", + } + require.NoError(t, db.Create(&inherited).Error) + + createBody := fmt.Sprintf(`{ + "responsibility-uuid": %q, + "description": "we do this", + "responsible-roles": [{"role-id": "operator", "party-uuids": [%q]}] + }`, fx.respAID.String(), partyID.String()) + + ctx, rec := newByComponentContext(http.MethodPost, createBody, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID) + require.NoError(t, h.CreateImplementedRequirementStatementByComponentSatisfied(ctx)) + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) + + var created handler.GenericDataResponse[oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created)) + require.NotNil(t, created.Data.ResponsibleRoles) + require.Len(t, *created.Data.ResponsibleRoles, 1) + + satisfiedID := uuid.MustParse(created.Data.UUID) + require.Equal(t, int64(1), countRows(t, db, &relational.ResponsibleRole{}, "parent_id = ?", satisfiedID)) + require.Equal(t, int64(1), countRoleParties(t, db, satisfiedID)) + + updateBody := fmt.Sprintf(`{ + "description": "revised", + "responsible-roles": [ + {"role-id": "operator", "party-uuids": [%q]}, + {"role-id": "reviewer", "party-uuids": [%q]} + ] + }`, partyID.String(), partyID.String()) + + ctx, rec = newByComponentContext(http.MethodPut, updateBody, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID, + [2]string{"satisfiedId", satisfiedID.String()}) + require.NoError(t, h.UpdateImplementedRequirementStatementByComponentSatisfied(ctx)) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var updated handler.GenericDataResponse[oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated)) + require.NotNil(t, updated.Data.ResponsibleRoles) + require.Len(t, *updated.Data.ResponsibleRoles, 2) + require.Equal(t, int64(2), countRows(t, db, &relational.ResponsibleRole{}, "parent_id = ?", satisfiedID)) + require.Equal(t, int64(2), countRoleParties(t, db, satisfiedID)) + + ctx, rec = newByComponentContext(http.MethodDelete, "", fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID, + [2]string{"satisfiedId", satisfiedID.String()}) + require.NoError(t, h.DeleteImplementedRequirementStatementByComponentSatisfied(ctx)) + require.Equal(t, http.StatusNoContent, rec.Code) + + require.Zero(t, countRows(t, db, &relational.ResponsibleRole{}, "parent_id = ?", satisfiedID)) + require.Zero(t, countRoleParties(t, db, satisfiedID)) + require.Equal(t, int64(1), countRows(t, db, &relational.Party{}, "id = ?", partyID)) +} + +// TestUpdateByComponentReplacesResponsibleRoles: the by-component PUT owns responsible-roles, so +// it replaces them — and the superseded rows (and their join rows) are deleted, not orphaned. +func TestUpdateByComponentReplacesResponsibleRoles(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := newSSPHandler(db) + partyID := seedParty(t, db) + + put := func(body string) *httptest.ResponseRecorder { + ctx, rec := newByComponentContext(http.MethodPut, body, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID) + require.NoError(t, h.UpdateImplementedRequirementStatementByComponent(ctx)) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + return rec + } + + put(fmt.Sprintf(`{ + "uuid": %q, "component-uuid": %q, "description": "with roles", + "responsible-roles": [{"role-id": "owner", "party-uuids": [%q]}] + }`, fx.byComponentID.String(), fx.componentID.String(), partyID.String())) + + require.Equal(t, int64(1), countRows(t, db, &relational.ResponsibleRole{}, "parent_id = ?", fx.byComponentID)) + require.Equal(t, int64(1), countRoleParties(t, db, fx.byComponentID)) + + // A PUT with no responsible-roles clears them — the field is one this route owns. + rec := put(fmt.Sprintf(`{"uuid": %q, "component-uuid": %q, "description": "no roles"}`, + fx.byComponentID.String(), fx.componentID.String())) + + var updated handler.GenericDataResponse[oscalTypes_1_1_3.ByComponent] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated)) + require.Nil(t, updated.Data.ResponsibleRoles) + + require.Zero(t, countRows(t, db, &relational.ResponsibleRole{}, "parent_id = ?", fx.byComponentID)) + require.Zero(t, countRoleParties(t, db, fx.byComponentID)) + require.Equal(t, int64(1), countRows(t, db, &relational.Party{}, "id = ?", partyID), "Party rows are shared and must survive") + + // The export subtree is still untouched by any of this. + require.Equal(t, int64(1), countRows(t, db, &relational.Export{}, "by_component_id = ?", fx.byComponentID)) +} + +// TestBulkAllowedOfferingsMatchesIsDownstreamAllowed: the batched allow-list resolution the +// by-control catalog uses must agree, offering for offering, with the per-offering rule Subscribe +// enforces. Two encodings of one rule is exactly how they drift. +func TestBulkAllowedOfferingsMatchesIsDownstreamAllowed(t *testing.T) { + db := newSSPLeverageTestDB(t) + + downstream := uuid.New() + other := uuid.New() + + noAllowList := relational.SSPExportOffering{Title: "open to all"} + require.NoError(t, db.Create(&noAllowList).Error) + + allowsDownstream := relational.SSPExportOffering{Title: "allows our downstream"} + require.NoError(t, db.Create(&allowsDownstream).Error) + require.NoError(t, db.Create(&relational.SSPExportOfferingAllowedDownstream{ + OfferingID: *allowsDownstream.ID, DownstreamSSPID: downstream, + }).Error) + + excludesDownstream := relational.SSPExportOffering{Title: "allows someone else"} + require.NoError(t, db.Create(&excludesDownstream).Error) + require.NoError(t, db.Create(&relational.SSPExportOfferingAllowedDownstream{ + OfferingID: *excludesDownstream.ID, DownstreamSSPID: other, + }).Error) + + offeringIDs := []uuid.UUID{*noAllowList.ID, *allowsDownstream.ID, *excludesDownstream.ID} + bulk, err := bulkAllowedOfferings(db, offeringIDs, downstream) + require.NoError(t, err) + + for _, offeringID := range offeringIDs { + single, err := isDownstreamAllowed(db, offeringID, downstream) + require.NoError(t, err) + require.Equalf(t, single, bulk[offeringID], "batched and per-offering allow-list disagree for %s", offeringID) + } + + require.True(t, bulk[*noAllowList.ID], "no allow-list rows means the type-level default: any downstream") + require.True(t, bulk[*allowsDownstream.ID]) + require.False(t, bulk[*excludesDownstream.ID]) + + empty, err := bulkAllowedOfferings(db, nil, downstream) + require.NoError(t, err) + require.Empty(t, empty) +} diff --git a/internal/api/handler/oscal/system_security_plans.go b/internal/api/handler/oscal/system_security_plans.go index 281ecbcf..d4f30166 100644 --- a/internal/api/handler/oscal/system_security_plans.go +++ b/internal/api/handler/oscal/system_security_plans.go @@ -4435,7 +4435,7 @@ func (h *SystemSecurityPlanHandler) updateByComponentMetadata(ctx echo.Context, }).Error; err != nil { return err } - return replaceResponsibleRoles(tx, bc, parsed.ResponsibleRoles) + return replaceResponsibleRoles(tx, bc, *bc.ID, parsed.ResponsibleRoles) }); err != nil { h.sugar.Errorf("Failed to update by-component: %v", err) return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) @@ -4455,12 +4455,19 @@ func (h *SystemSecurityPlanHandler) updateByComponentMetadata(ctx echo.Context, // cleared first (Party records themselves are shared and must survive); the new ones are // appended through the association so GORM fills in parent_id/parent_type — a plain Replace // would orphan the old rows rather than delete them. -func replaceResponsibleRoles(tx *gorm.DB, parent any, roles []relational.ResponsibleRole) error { - // Read the current roles through the association so GORM supplies the polymorphic - // parent_type itself. deleteResponsibleRoles clears each role's Parties join rows by - // role id, so they don't need preloading here. +func replaceResponsibleRoles(tx *gorm.DB, parent any, parentID uuid.UUID, roles []relational.ResponsibleRole) error { + // A polymorphic parent_type is filled with the owner's table name. Derive it from the parsed + // schema rather than hardcoding "by_components" / "inherited_control_implementations" / ... + // at each call site, so a table rename can't silently strand a role set. + stmt := &gorm.Statement{DB: tx} + if err := stmt.Parse(parent); err != nil { + return err + } + parentType := stmt.Schema.Table + var existing []relational.ResponsibleRole - if err := tx.Model(parent).Association("ResponsibleRoles").Find(&existing); err != nil { + if err := tx.Where("parent_id = ? AND parent_type = ?", parentID, parentType). + Find(&existing).Error; err != nil { return err } if err := deleteResponsibleRoles(tx, existing); err != nil { @@ -4469,7 +4476,17 @@ func replaceResponsibleRoles(tx *gorm.DB, parent any, roles []relational.Respons if len(roles) == 0 { return nil } - return tx.Model(parent).Association("ResponsibleRoles").Append(roles) + + for i := range roles { + roles[i].ID = nil + roles[i].ParentID = &parentID + roles[i].ParentType = parentType + } + // Create, not Association("ResponsibleRoles").Append: Append writes the role rows but does + // NOT cascade into each role's own Parties many2many, silently leaving every + // responsible_role_parties join row unwritten. A plain Create does cascade — it is the same + // path the nested by-component create already relies on. + return tx.Create(&roles).Error } // reloadByComponent re-fetches a by-component with every subtree the single-by-component GET @@ -4844,7 +4861,7 @@ func (h *SystemSecurityPlanHandler) createByComponentExport(ctx echo.Context, bc // Export.ByComponentId has no unique DB constraint (this ticket makes no // schema change), so an advisory lock keyed on the by-component closes the // race between the existence check and the insert for concurrent creates. - if err := lockByComponentExportCreate(tx, *bc.ID); err != nil { + if err := lockByComponentSubtreeCreate(tx, *bc.ID); err != nil { return err } @@ -4873,10 +4890,18 @@ func (h *SystemSecurityPlanHandler) createByComponentExport(ctx echo.Context, bc return ctx.JSON(http.StatusCreated, handler.GenericDataResponse[oscalTypes_1_1_3.Export]{Data: *created.MarshalOscal()}) } -// lockByComponentExportCreate serializes concurrent Export creation for the same -// by-component using a transaction-scoped Postgres advisory lock keyed on the -// by-component's UUID. It is a no-op against non-Postgres test drivers. -func lockByComponentExportCreate(tx *gorm.DB, byComponentID uuid.UUID) error { +// lockByComponentSubtreeCreate serializes concurrent creates under the same by-component — its +// singleton Export, and its Inherited/Satisfied entries — with a transaction-scoped Postgres +// advisory lock keyed on the by-component's UUID. Nothing in the schema enforces +// exports.by_component_id uniqueness, and the satisfaction re-derivation a satisfied-write does +// reads rows a concurrent write is producing, so both need the same guard. +// +// The lock key string stays "export-create:" despite the now-wider scope: it is only meaningful +// as a value all writers agree on, and changing it would stop old and new pods serializing +// against each other during a rolling deploy. +// +// It is a no-op against non-Postgres test drivers. +func lockByComponentSubtreeCreate(tx *gorm.DB, byComponentID uuid.UUID) error { if tx.Name() != "postgres" { return nil } From fd000a9d5c3a183d17568f685140d9e06d70c2ca Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:19:06 -0300 Subject: [PATCH 03/13] self-review: pin by-control route dispatch and optional implementation-status --- .../oscal/ssp_shared_responsibility_test.go | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/internal/api/handler/oscal/ssp_shared_responsibility_test.go b/internal/api/handler/oscal/ssp_shared_responsibility_test.go index 84209e2e..8576ae66 100644 --- a/internal/api/handler/oscal/ssp_shared_responsibility_test.go +++ b/internal/api/handler/oscal/ssp_shared_responsibility_test.go @@ -15,6 +15,7 @@ import ( "gorm.io/gorm" "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/relational" oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" @@ -514,6 +515,12 @@ func TestUpdateByComponentDoesNotClobberSubtreesOrOmittedFields(t *testing.T) { require.NoError(t, db.First(&stored, "id = ?", fx.byComponentID).Error) require.Equal(t, "updated description", stored.Description) + // implementation-status is optional: a by-component that never had one must not gain an + // empty {"state": ""} from a PUT that doesn't mention it. + var updatedBC handler.GenericDataResponse[oscalTypes_1_1_3.ByComponent] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updatedBC)) + require.Nil(t, updatedBC.Data.ImplementationStatus) + // The export subtree is untouched: still exactly one export with its original description, // one provided, two responsibilities. The smuggled export was not upserted. var exports []relational.Export @@ -1270,3 +1277,37 @@ func TestBulkAllowedOfferingsMatchesIsDownstreamAllowed(t *testing.T) { require.NoError(t, err) require.Empty(t, empty) } + +// TestByControlRouteIsNotShadowedByGetByID: /ssp-export-offerings/by-control/:controlId lives under +// the same prefix as /ssp-export-offerings/:id, so a router that resolved the param route first +// would send "by-control" to GetByID and fail with "invalid offering id" — silently, since both +// routes are registered and both are reachable in isolation. This drives the real echo router to +// pin the dispatch. +func TestByControlRouteIsNotShadowedByGetByID(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + + pep := middleware.NewPEP(&stubPDP{allow: true}, authz.FailClosed, zap.NewNop().Sugar()) + e := echo.New() + NewSSPExportOfferingHandler(zap.NewNop().Sugar(), db, nil). + Register(e.Group("/api/oscal/ssp-export-offerings"), pep.For(authz.ResourceSSPExportOffering)) + + // The static by-control segment must win over :id. + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/oscal/ssp-export-offerings/by-control/ac-2", nil)) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var byControl handler.GenericDataListResponse[ControlExportOffer] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &byControl)) + require.Len(t, byControl.Data, 1) + require.Equal(t, fx.itemID, byControl.Data[0].ItemID) + + // ...and a real offering id still reaches GetByID. + rec = httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/oscal/ssp-export-offerings/"+fx.offeringID.String(), nil)) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var byID handler.GenericDataResponse[catalogOffering] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &byID)) + require.Equal(t, fx.offeringID, *byID.Data.ID) +} From d519896f3f0213a870137c76aa8a55f66d64ab02 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:38:22 -0300 Subject: [PATCH 04/13] fix: address review feedback --- docs/docs.go | 4 +- docs/swagger.json | 4 +- docs/swagger.yaml | 11 ++- .../oscal/ssp_shared_responsibility.go | 18 ++-- .../oscal/ssp_shared_responsibility_test.go | 84 +++++++++++++++++++ .../handler/oscal/system_security_plans.go | 35 +++++--- 6 files changed, 133 insertions(+), 23 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index ff17c4a4..4ea7dedb 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -23105,7 +23105,7 @@ const docTemplate = `{ }, "/oscal/system-security-plans/{id}/shared-responsibility": { "get": { - "description": "A flat, statement-keyed projection of this SSP's shared-responsibility posture:\nwhat it exports (with the provided capabilities and the responsibilities it\npushes onto consumers, and whether each is actually offered), what it inherits\nfrom upstreams (with live-recomputed satisfaction and link status), how it\ndischarges upstream responsibilities, and any legacy requirement-anchored\nby-components still to be migrated.\n\nThe inherits arm reuses the same batched projection GET /leveraged-controls\nserves, so the two can never disagree about satisfaction. Optionally filter the\nwhole rollup to one control with controlId.", + "description": "A flat, statement-keyed projection of this SSP's shared-responsibility posture:\nwhat it exports (with the provided capabilities and the responsibilities it\npushes onto consumers, and whether each is offered on a *published* offering —\na draft/deprecated/revoked one does not count, since no downstream can reach\nit), what it inherits\nfrom upstreams (with live-recomputed satisfaction and link status), how it\ndischarges upstream responsibilities, and any legacy requirement-anchored\nby-components still to be migrated.\n\nThe inherits arm reuses the same batched projection GET /leveraged-controls\nserves, so the two can never disagree about satisfaction. Optionally filter the\nwhole rollup to one control with controlId.", "produces": [ "application/json" ], @@ -40002,7 +40002,7 @@ const docTemplate = `{ "type": "string" }, "offered": { - "description": "Offered reports whether an offering item on this SSP already points at one of these\nprovided-uuids — i.e. whether the capability is actually published for a downstream to\nfind, as opposed to merely existing in the control-implementation tree.", + "description": "Offered reports whether an item on one of this SSP's *published* offerings already points\nat one of these provided-uuids — i.e. whether a downstream can actually find and subscribe\nto the capability, as opposed to it merely existing in the control-implementation tree.\nAn item on a draft/deprecated/revoked offering does not count: nothing can reach it.", "type": "boolean" }, "provided": { diff --git a/docs/swagger.json b/docs/swagger.json index 190d5d09..77b66e9d 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -23099,7 +23099,7 @@ }, "/oscal/system-security-plans/{id}/shared-responsibility": { "get": { - "description": "A flat, statement-keyed projection of this SSP's shared-responsibility posture:\nwhat it exports (with the provided capabilities and the responsibilities it\npushes onto consumers, and whether each is actually offered), what it inherits\nfrom upstreams (with live-recomputed satisfaction and link status), how it\ndischarges upstream responsibilities, and any legacy requirement-anchored\nby-components still to be migrated.\n\nThe inherits arm reuses the same batched projection GET /leveraged-controls\nserves, so the two can never disagree about satisfaction. Optionally filter the\nwhole rollup to one control with controlId.", + "description": "A flat, statement-keyed projection of this SSP's shared-responsibility posture:\nwhat it exports (with the provided capabilities and the responsibilities it\npushes onto consumers, and whether each is offered on a *published* offering —\na draft/deprecated/revoked one does not count, since no downstream can reach\nit), what it inherits\nfrom upstreams (with live-recomputed satisfaction and link status), how it\ndischarges upstream responsibilities, and any legacy requirement-anchored\nby-components still to be migrated.\n\nThe inherits arm reuses the same batched projection GET /leveraged-controls\nserves, so the two can never disagree about satisfaction. Optionally filter the\nwhole rollup to one control with controlId.", "produces": [ "application/json" ], @@ -39996,7 +39996,7 @@ "type": "string" }, "offered": { - "description": "Offered reports whether an offering item on this SSP already points at one of these\nprovided-uuids — i.e. whether the capability is actually published for a downstream to\nfind, as opposed to merely existing in the control-implementation tree.", + "description": "Offered reports whether an item on one of this SSP's *published* offerings already points\nat one of these provided-uuids — i.e. whether a downstream can actually find and subscribe\nto the capability, as opposed to it merely existing in the control-implementation tree.\nAn item on a draft/deprecated/revoked offering does not count: nothing can reach it.", "type": "boolean" }, "provided": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 617cccf5..ba886fc6 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -4074,9 +4074,10 @@ definitions: type: string offered: description: |- - Offered reports whether an offering item on this SSP already points at one of these - provided-uuids — i.e. whether the capability is actually published for a downstream to - find, as opposed to merely existing in the control-implementation tree. + Offered reports whether an item on one of this SSP's *published* offerings already points + at one of these provided-uuids — i.e. whether a downstream can actually find and subscribe + to the capability, as opposed to it merely existing in the control-implementation tree. + An item on a draft/deprecated/revoked offering does not count: nothing can reach it. type: boolean provided: items: @@ -27048,7 +27049,9 @@ paths: description: |- A flat, statement-keyed projection of this SSP's shared-responsibility posture: what it exports (with the provided capabilities and the responsibilities it - pushes onto consumers, and whether each is actually offered), what it inherits + pushes onto consumers, and whether each is offered on a *published* offering — + a draft/deprecated/revoked one does not count, since no downstream can reach + it), what it inherits from upstreams (with live-recomputed satisfaction and link status), how it discharges upstream responsibilities, and any legacy requirement-anchored by-components still to be migrated. diff --git a/internal/api/handler/oscal/ssp_shared_responsibility.go b/internal/api/handler/oscal/ssp_shared_responsibility.go index d688dbb2..893752b3 100644 --- a/internal/api/handler/oscal/ssp_shared_responsibility.go +++ b/internal/api/handler/oscal/ssp_shared_responsibility.go @@ -31,9 +31,10 @@ type SharedResponsibilityProvides struct { Provided []controlExportProvided `json:"provided"` Responsibilities []controlExportResponsibility `json:"responsibilities"` - // Offered reports whether an offering item on this SSP already points at one of these - // provided-uuids — i.e. whether the capability is actually published for a downstream to - // find, as opposed to merely existing in the control-implementation tree. + // Offered reports whether an item on one of this SSP's *published* offerings already points + // at one of these provided-uuids — i.e. whether a downstream can actually find and subscribe + // to the capability, as opposed to it merely existing in the control-implementation tree. + // An item on a draft/deprecated/revoked offering does not count: nothing can reach it. Offered bool `json:"offered"` } @@ -99,7 +100,9 @@ type SharedResponsibilityRollup struct { // @Summary Roll up everything one SSP provides, inherits and satisfies // @Description A flat, statement-keyed projection of this SSP's shared-responsibility posture: // @Description what it exports (with the provided capabilities and the responsibilities it -// @Description pushes onto consumers, and whether each is actually offered), what it inherits +// @Description pushes onto consumers, and whether each is offered on a *published* offering — +// @Description a draft/deprecated/revoked one does not count, since no downstream can reach +// @Description it), what it inherits // @Description from upstreams (with live-recomputed satisfaction and link status), how it // @Description discharges upstream responsibilities, and any legacy requirement-anchored // @Description by-components still to be migrated. @@ -243,10 +246,15 @@ func (h *SystemSecurityPlanHandler) collectOwnedSharedResponsibility(rollup *Sha } // Which of this SSP's provided-uuids are actually published in one of its own offerings. + // Published-only, deliberately: a draft/deprecated/revoked offering is invisible to every + // downstream (ListAll and ByControl are published-only, and Subscribe 404s on anything else), + // so counting one as "offered" would claim a capability is available to leverage when nothing + // can reach it. A curator sees their own drafts via GET /:id/export-offerings. var offeredItems []relational.SSPExportOfferingItem if err := h.db. Joins("JOIN ssp_export_offerings ON ssp_export_offerings.id = ssp_export_offering_items.offering_id"). - Where("ssp_export_offerings.ssp_id = ?", sspID). + Where("ssp_export_offerings.ssp_id = ? AND ssp_export_offerings.status = ?", + sspID, relational.SSPExportOfferingStatusPublished). Find(&offeredItems).Error; err != nil { return err } diff --git a/internal/api/handler/oscal/ssp_shared_responsibility_test.go b/internal/api/handler/oscal/ssp_shared_responsibility_test.go index 8576ae66..4087fdef 100644 --- a/internal/api/handler/oscal/ssp_shared_responsibility_test.go +++ b/internal/api/handler/oscal/ssp_shared_responsibility_test.go @@ -1311,3 +1311,87 @@ func TestByControlRouteIsNotShadowedByGetByID(t *testing.T) { require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &byID)) require.Equal(t, fx.offeringID, *byID.Data.ID) } + +// TestSharedResponsibilityOfferedIsPublishedOnly: `offered` claims a downstream can actually +// find and subscribe to the capability. A draft/deprecated/revoked offering is invisible to +// every downstream (ListAll and ByControl are published-only, Subscribe 404s on anything else), +// so an item on one must not set the flag — otherwise the rollup advertises a capability nothing +// can reach. +func TestSharedResponsibilityOfferedIsPublishedOnly(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := newSSPHandler(db) + + provides := func() SharedResponsibilityProvides { + e := echo.New() + rec := httptest.NewRecorder() + ctx := e.NewContext(httptest.NewRequest(http.MethodGet, "/", nil), rec) + ctx.SetParamNames("id") + ctx.SetParamValues(fx.upstreamSSPID.String()) + require.NoError(t, h.SharedResponsibility(ctx)) + require.Equal(t, http.StatusOK, rec.Code) + + var resp handler.GenericDataResponse[SharedResponsibilityRollup] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Len(t, resp.Data.Provides, 1) + return resp.Data.Provides[0] + } + + // The fixture's offering is published, and its item points at this provided-uuid. + require.True(t, provides().Offered) + + setStatus := func(status relational.SSPExportOfferingStatus) { + require.NoError(t, db.Model(&relational.SSPExportOffering{}). + Where("id = ?", fx.offeringID). + Update("status", status).Error) + } + + // Every non-published status hides the offering from downstreams, so none of them count. + for _, status := range []relational.SSPExportOfferingStatus{ + relational.SSPExportOfferingStatusDraft, + relational.SSPExportOfferingStatusDeprecated, + relational.SSPExportOfferingStatusRevoked, + } { + setStatus(status) + row := provides() + require.Falsef(t, row.Offered, "an item on a %q offering must not count as offered", status) + // The capability itself is still reported — only the flag changes. + require.Equal(t, fx.byComponentID, row.ByComponentUUID) + require.Len(t, row.Provided, 1) + } + + setStatus(relational.SSPExportOfferingStatusPublished) + require.True(t, provides().Offered) +} + +// TestUpdateByComponentRejectsComponentUuidChange: component-uuid identifies which component the +// by-component describes, and the offering-item coherence check joins on it — so a PUT naming a +// different one is rejected rather than silently ignored. Omitting it stays legal. +func TestUpdateByComponentRejectsComponentUuidChange(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := newSSPHandler(db) + + body := fmt.Sprintf(`{"uuid": %q, "component-uuid": %q, "description": "moved"}`, + fx.byComponentID.String(), uuid.New().String()) + ctx, rec := newByComponentContext(http.MethodPut, body, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID) + require.NoError(t, h.UpdateImplementedRequirementStatementByComponent(ctx)) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "immutable") + + var stored relational.ByComponent + require.NoError(t, db.First(&stored, "id = ?", fx.byComponentID).Error) + require.Equal(t, fx.componentID, stored.ComponentUUID, "the rejected PUT must not have moved the by-component") + require.Equal(t, "original description", stored.Description, "the rejected PUT must not have written anything") + + // Omitting component-uuid entirely is still legal — it defaults to the stored value. + body = fmt.Sprintf(`{"uuid": %q, "description": "renamed"}`, fx.byComponentID.String()) + ctx, rec = newByComponentContext(http.MethodPut, body, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID) + require.NoError(t, h.UpdateImplementedRequirementStatementByComponent(ctx)) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + require.NoError(t, db.First(&stored, "id = ?", fx.byComponentID).Error) + require.Equal(t, fx.componentID, stored.ComponentUUID) + require.Equal(t, "renamed", stored.Description) +} diff --git a/internal/api/handler/oscal/system_security_plans.go b/internal/api/handler/oscal/system_security_plans.go index d4f30166..c3e82517 100644 --- a/internal/api/handler/oscal/system_security_plans.go +++ b/internal/api/handler/oscal/system_security_plans.go @@ -4406,13 +4406,24 @@ func (h *SystemSecurityPlanHandler) updateByComponentMetadata(ctx echo.Context, } // UnmarshalOscal MustParses both uuids; a body missing (or malformed in) either would - // panic rather than 400. Echoing the path-resolved by-component's own ids keeps the - // parse total without making uuid a required body field. + // panic rather than 400. Echoing the path-resolved by-component's own id keeps the parse + // total without making uuid a required body field. oscalBC.UUID = bc.ID.String() - if strings.TrimSpace(oscalBC.ComponentUuid) == "" { + + // component-uuid is immutable, so a body naming a *different* one is rejected rather than + // silently ignored — the same treatment provided-uuid and responsibility-uuid get on the + // inherited/satisfied PUTs. Omitting it stays legal: it defaults to the stored value. + if trimmed := strings.TrimSpace(oscalBC.ComponentUuid); trimmed == "" { oscalBC.ComponentUuid = bc.ComponentUUID.String() - } else if _, err := uuid.Parse(oscalBC.ComponentUuid); err != nil { - return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("component-uuid must be a valid UUID"))) + } else { + componentUUID, err := uuid.Parse(trimmed) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("component-uuid must be a valid UUID"))) + } + if componentUUID != bc.ComponentUUID { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf( + "component-uuid is immutable: it identifies which component this by-component describes, and the offering-item coherence check joins on it"))) + } } parsed := &relational.ByComponent{} @@ -4450,11 +4461,15 @@ func (h *SystemSecurityPlanHandler) updateByComponentMetadata(ctx echo.Context, ) } -// replaceResponsibleRoles swaps a parent's polymorphic ResponsibleRoles for a new set. The -// old rows go through deleteResponsibleRoles so their responsible_role_parties join rows are -// cleared first (Party records themselves are shared and must survive); the new ones are -// appended through the association so GORM fills in parent_id/parent_type — a plain Replace -// would orphan the old rows rather than delete them. +// replaceResponsibleRoles swaps a parent's polymorphic ResponsibleRoles for a new set. +// +// The old rows go through deleteResponsibleRoles, so their responsible_role_parties join rows +// are cleared before the roles are deleted (the Party records themselves are shared and must +// survive). The new rows are written with tx.Create and an explicitly-set parent_id/parent_type +// — NOT through Association("ResponsibleRoles").Append, which writes the role rows but does not +// cascade into each role's own Parties many2many, silently leaving every responsible_role_parties +// join row unwritten. Create does cascade; it is the same path the nested by-component create +// already relies on. func replaceResponsibleRoles(tx *gorm.DB, parent any, parentID uuid.UUID, roles []relational.ResponsibleRole) error { // A polymorphic parent_type is filled with the owner's table name. Derive it from the parsed // schema rather than hardcoding "by_components" / "inherited_control_implementations" / ... From ff12cb49751346785fe706b27aa5700b85f85a4e Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:32:25 -0300 Subject: [PATCH 05/13] fix: address review feedback --- docs/docs.go | 24 ++- docs/swagger.json | 24 ++- docs/swagger.yaml | 30 +++- .../api/handler/oscal/ssp_by_components.go | 21 ++- .../api/handler/oscal/ssp_export_offerings.go | 24 ++- .../oscal/ssp_shared_responsibility_test.go | 144 +++++++++++++++++- .../handler/oscal/system_security_plans.go | 70 +++++++-- 7 files changed, 306 insertions(+), 31 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 4ea7dedb..84089dc8 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -17312,7 +17312,7 @@ const docTemplate = `{ } }, "delete": { - "description": "Deletes an existing implemented requirement for a given SSP.", + "description": "Deletes an existing implemented requirement for a given SSP.\n\nCascades through every by-component beneath the requirement, so it returns 409\nif any inherited entry under it is owned by a leverage subscription.", "tags": [ "System Security Plans" ], @@ -17349,6 +17349,12 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -17567,7 +17573,7 @@ const docTemplate = `{ ] }, "delete": { - "description": "Deprecated: requirement-anchored by-components are legacy. This delete exists\npurely so legacy and orphaned requirement-anchored rows can be wound down —\nthere is deliberately no requirement-level POST to create new ones.\n\nCascades exactly like the statement-level delete: the by-component's own\nresponsible-roles, its inherited and satisfied entries (each with their\nresponsible-roles), and its export with nested provided/responsibilities are all\nremoved, along with the responsible_role_parties join rows.", + "description": "Deprecated: requirement-anchored by-components are legacy. This delete exists\npurely so legacy and orphaned requirement-anchored rows can be wound down —\nthere is deliberately no requirement-level POST to create new ones.\n\nCascades exactly like the statement-level delete: the by-component's own\nresponsible-roles, its inherited and satisfied entries (each with their\nresponsible-roles), and its export with nested provided/responsibilities are all\nremoved, along with the responsible_role_parties join rows.\n\nReturns 409 if any of the by-component's inherited entries is owned by a\nleverage subscription — deleting the parent must not be a way around the same\nguard the inherited sub-resource DELETE enforces. Unsubscribe first.", "tags": [ "System Security Plans" ], @@ -17612,6 +17618,12 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -19068,7 +19080,7 @@ const docTemplate = `{ ] }, "delete": { - "description": "Deletes a by-component within an existing statement within an implemented requirement for a given SSP.", + "description": "Deletes a by-component within an existing statement within an implemented requirement for a given SSP.\n\nReturns 409 if any of the by-component's inherited entries is owned by a\nleverage subscription — deleting the parent must not be a way around the same\nguard the inherited sub-resource DELETE enforces. Unsubscribe first.", "consumes": [ "application/json" ], @@ -19128,6 +19140,12 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { diff --git a/docs/swagger.json b/docs/swagger.json index 77b66e9d..52ab32eb 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -17306,7 +17306,7 @@ } }, "delete": { - "description": "Deletes an existing implemented requirement for a given SSP.", + "description": "Deletes an existing implemented requirement for a given SSP.\n\nCascades through every by-component beneath the requirement, so it returns 409\nif any inherited entry under it is owned by a leverage subscription.", "tags": [ "System Security Plans" ], @@ -17343,6 +17343,12 @@ "$ref": "#/definitions/api.Error" } }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -17561,7 +17567,7 @@ ] }, "delete": { - "description": "Deprecated: requirement-anchored by-components are legacy. This delete exists\npurely so legacy and orphaned requirement-anchored rows can be wound down —\nthere is deliberately no requirement-level POST to create new ones.\n\nCascades exactly like the statement-level delete: the by-component's own\nresponsible-roles, its inherited and satisfied entries (each with their\nresponsible-roles), and its export with nested provided/responsibilities are all\nremoved, along with the responsible_role_parties join rows.", + "description": "Deprecated: requirement-anchored by-components are legacy. This delete exists\npurely so legacy and orphaned requirement-anchored rows can be wound down —\nthere is deliberately no requirement-level POST to create new ones.\n\nCascades exactly like the statement-level delete: the by-component's own\nresponsible-roles, its inherited and satisfied entries (each with their\nresponsible-roles), and its export with nested provided/responsibilities are all\nremoved, along with the responsible_role_parties join rows.\n\nReturns 409 if any of the by-component's inherited entries is owned by a\nleverage subscription — deleting the parent must not be a way around the same\nguard the inherited sub-resource DELETE enforces. Unsubscribe first.", "tags": [ "System Security Plans" ], @@ -17606,6 +17612,12 @@ "$ref": "#/definitions/api.Error" } }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -19062,7 +19074,7 @@ ] }, "delete": { - "description": "Deletes a by-component within an existing statement within an implemented requirement for a given SSP.", + "description": "Deletes a by-component within an existing statement within an implemented requirement for a given SSP.\n\nReturns 409 if any of the by-component's inherited entries is owned by a\nleverage subscription — deleting the parent must not be a way around the same\nguard the inherited sub-resource DELETE enforces. Unsubscribe first.", "consumes": [ "application/json" ], @@ -19122,6 +19134,12 @@ "$ref": "#/definitions/api.Error" } }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index ba886fc6..9a7aeebd 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -23009,7 +23009,11 @@ paths: - System Security Plans /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}: delete: - description: Deletes an existing implemented requirement for a given SSP. + description: |- + Deletes an existing implemented requirement for a given SSP. + + Cascades through every by-component beneath the requirement, so it returns 409 + if any inherited entry under it is owned by a leverage subscription. parameters: - description: SSP ID in: path @@ -23032,6 +23036,10 @@ paths: description: Not Found schema: $ref: '#/definitions/api.Error' + "409": + description: Conflict + schema: + $ref: '#/definitions/api.Error' "500": description: Internal Server Error schema: @@ -23137,6 +23145,10 @@ paths: responsible-roles, its inherited and satisfied entries (each with their responsible-roles), and its export with nested provided/responsibilities are all removed, along with the responsible_role_parties join rows. + + Returns 409 if any of the by-component's inherited entries is owned by a + leverage subscription — deleting the parent must not be a way around the same + guard the inherited sub-resource DELETE enforces. Unsubscribe first. parameters: - description: SSP ID in: path @@ -23164,6 +23176,10 @@ paths: description: Not Found schema: $ref: '#/definitions/api.Error' + "409": + description: Conflict + schema: + $ref: '#/definitions/api.Error' "500": description: Internal Server Error schema: @@ -24211,8 +24227,12 @@ paths: : delete: consumes: - application/json - description: Deletes a by-component within an existing statement within an implemented - requirement for a given SSP. + description: |- + Deletes a by-component within an existing statement within an implemented requirement for a given SSP. + + Returns 409 if any of the by-component's inherited entries is owned by a + leverage subscription — deleting the parent must not be a way around the same + guard the inherited sub-resource DELETE enforces. Unsubscribe first. parameters: - description: SSP ID in: path @@ -24249,6 +24269,10 @@ paths: description: Not Found schema: $ref: '#/definitions/api.Error' + "409": + description: Conflict + schema: + $ref: '#/definitions/api.Error' "500": description: Internal Server Error schema: diff --git a/internal/api/handler/oscal/ssp_by_components.go b/internal/api/handler/oscal/ssp_by_components.go index 4c5f0f1e..4411dd4b 100644 --- a/internal/api/handler/oscal/ssp_by_components.go +++ b/internal/api/handler/oscal/ssp_by_components.go @@ -203,6 +203,10 @@ func (h *SystemSecurityPlanHandler) resolveStatement(ctx echo.Context) (stmt *re // @Description responsible-roles, its inherited and satisfied entries (each with their // @Description responsible-roles), and its export with nested provided/responsibilities are all // @Description removed, along with the responsible_role_parties join rows. +// @Description +// @Description Returns 409 if any of the by-component's inherited entries is owned by a +// @Description leverage subscription — deleting the parent must not be a way around the same +// @Description guard the inherited sub-resource DELETE enforces. Unsubscribe first. // @Tags System Security Plans // @Param id path string true "SSP ID" // @Param reqId path string true "Requirement ID" @@ -210,6 +214,7 @@ func (h *SystemSecurityPlanHandler) resolveStatement(ctx echo.Context) (stmt *re // @Success 204 "No Content" // @Failure 400 {object} api.Error // @Failure 404 {object} api.Error +// @Failure 409 {object} api.Error // @Failure 500 {object} api.Error // @Deprecated // @Security OAuth2Password @@ -223,6 +228,9 @@ func (h *SystemSecurityPlanHandler) DeleteImplementedRequirementByComponent(ctx if err := h.db.Transaction(func(tx *gorm.DB) error { return deleteByComponentCascade(tx, *bc.ID) }); err != nil { + if errors.Is(err, errInheritedOwnedBySubscription) { + return ctx.JSON(http.StatusConflict, api.NewError(err)) + } h.sugar.Errorf("Failed to delete by-component: %v", err) return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } @@ -459,7 +467,7 @@ func (h *SystemSecurityPlanHandler) CreateImplementedRequirementStatementByCompo // Same advisory-lock guard the Export create uses: nothing in the schema stops two // concurrent creates from racing on the same by-component, and the satisfaction // re-derivation below reads the rows this write produces. - if err := lockByComponentSubtreeCreate(tx, *bc.ID); err != nil { + if err := lockByComponentSubtreeWrite(tx, *bc.ID); err != nil { return err } return tx.Create(relInherited).Error @@ -722,7 +730,7 @@ func (h *SystemSecurityPlanHandler) CreateImplementedRequirementStatementByCompo relSatisfied.ByComponentId = *bc.ID if err := h.db.Transaction(func(tx *gorm.DB) error { - if err := lockByComponentSubtreeCreate(tx, *bc.ID); err != nil { + if err := lockByComponentSubtreeWrite(tx, *bc.ID); err != nil { return err } if err := tx.Create(relSatisfied).Error; err != nil { @@ -870,6 +878,15 @@ func (h *SystemSecurityPlanHandler) DeleteImplementedRequirementStatementByCompo } if err := h.db.Transaction(func(tx *gorm.DB) error { + // Same lock the satisfied CREATE takes, and for the same reason: this transaction is a + // read-modify-write over the by-component's satisfied set (delete a row, then re-derive + // the owning link's Satisfaction from what remains). If only the create side locked, a + // concurrent create and delete would each compute satisfaction from a snapshot taken + // before the other's write was visible, and the second UPDATE would overwrite the first + // with a stale value. + if err := lockByComponentSubtreeWrite(tx, *bc.ID); err != nil { + return err + } if err := deleteResponsibleRoles(tx, existing.ResponsibleRoles); err != nil { return err } diff --git a/internal/api/handler/oscal/ssp_export_offerings.go b/internal/api/handler/oscal/ssp_export_offerings.go index a426d99c..8f719e78 100644 --- a/internal/api/handler/oscal/ssp_export_offerings.go +++ b/internal/api/handler/oscal/ssp_export_offerings.go @@ -545,7 +545,14 @@ func (r createExportOfferingItemRequest) validate() error { // resolveItemImplementationStatuses walks the first half of the same chain, but only ever // projects the ByComponent's implementation-status out of it — it discards the parent // identities this check exists to compare — so the walk is done explicitly here. -func (h *SSPExportOfferingHandler) validateOfferingItemCoherence(sspID uuid.UUID, req createExportOfferingItemRequest) error { +// +// It also NORMALIZES req.ControlID to the requirement's catalog-canonical casing (hence the +// pointer). Control-ids are matched case-insensitively but stored canonically; without this the +// item would persist the client's casing, and Subscribe's findOrCreateImplementedRequirement — +// which matches control_id EXACTLY — would miss a downstream requirement that differs only in +// case and insert a duplicate, splitting the downstream's tree across two requirement rows for +// the same control. +func (h *SSPExportOfferingHandler) validateOfferingItemCoherence(sspID uuid.UUID, req *createExportOfferingItemRequest) error { providedUUID := uuid.MustParse(req.ProvidedUUID) componentUUID := uuid.MustParse(req.ComponentUUID) @@ -587,8 +594,14 @@ func (h *SSPExportOfferingHandler) validateOfferingItemCoherence(sspID uuid.UUID } return err } + // Statement-ids are compared byte-exactly, unlike control-ids below. That asymmetry is + // deliberate: a statement-id is a free-form OSCAL identifier the SSP author coins, with no + // catalog-canonical form to fold toward, and findOrCreateStatement matches it exactly — so + // accepting a case variant here would create exactly the split this function prevents for + // control-ids. Control-ids, by contrast, DO have a canonical catalog casing, which is why + // they fold on compare and are normalized on store. if statement.StatementId != *req.StatementID { - return fmt.Errorf("statementId %q does not match the statement exporting providedUuid %q", *req.StatementID, req.ProvidedUUID) + return fmt.Errorf("statementId %q does not match the statement exporting providedUuid %q (statement-ids are matched exactly)", *req.StatementID, req.ProvidedUUID) } var requirement relational.ImplementedRequirement @@ -601,6 +614,9 @@ func (h *SSPExportOfferingHandler) validateOfferingItemCoherence(sspID uuid.UUID if !strings.EqualFold(requirement.ControlId, req.ControlID) { return fmt.Errorf("controlId %q does not match the control exporting providedUuid %q", req.ControlID, req.ProvidedUUID) } + // Adopt the requirement's catalog-canonical casing, so the item stores the same bytes + // findOrCreateImplementedRequirement will later match on exactly. + req.ControlID = requirement.ControlId var ssp relational.SystemSecurityPlan if err := h.db.Preload("ControlImplementation").First(&ssp, "id = ?", sspID).Error; err != nil { @@ -645,7 +661,7 @@ func (h *SSPExportOfferingHandler) CreateItem(ctx echo.Context) error { if err := req.validate(); err != nil { return ctx.JSON(http.StatusBadRequest, api.NewError(err)) } - if err := h.validateOfferingItemCoherence(offering.SSPID, req); err != nil { + if err := h.validateOfferingItemCoherence(offering.SSPID, &req); err != nil { h.sugar.Warnw("Incoherent export offering item", "offeringId", offering.ID, "error", err) return ctx.JSON(http.StatusBadRequest, api.NewError(err)) } @@ -710,7 +726,7 @@ func (h *SSPExportOfferingHandler) UpdateItem(ctx echo.Context) error { if err := req.validate(); err != nil { return ctx.JSON(http.StatusBadRequest, api.NewError(err)) } - if err := h.validateOfferingItemCoherence(offering.SSPID, req); err != nil { + if err := h.validateOfferingItemCoherence(offering.SSPID, &req); err != nil { h.sugar.Warnw("Incoherent export offering item", "offeringId", offering.ID, "itemId", itemID, "error", err) return ctx.JSON(http.StatusBadRequest, api.NewError(err)) } diff --git a/internal/api/handler/oscal/ssp_shared_responsibility_test.go b/internal/api/handler/oscal/ssp_shared_responsibility_test.go index 4087fdef..aa2e226f 100644 --- a/internal/api/handler/oscal/ssp_shared_responsibility_test.go +++ b/internal/api/handler/oscal/ssp_shared_responsibility_test.go @@ -206,29 +206,29 @@ func TestOfferingItemCoherenceRejectsIncoherentTuples(t *testing.T) { ComponentUUID: fx.componentID.String(), ProvidedUUID: fx.providedID.String(), } - require.NoError(t, h.validateOfferingItemCoherence(fx.upstreamSSPID, coherent)) + require.NoError(t, h.validateOfferingItemCoherence(fx.upstreamSSPID, &coherent)) wrongControl := coherent wrongControl.ControlID = "ac-3" - require.ErrorContains(t, h.validateOfferingItemCoherence(fx.upstreamSSPID, wrongControl), "controlId") + require.ErrorContains(t, h.validateOfferingItemCoherence(fx.upstreamSSPID, &wrongControl), "controlId") wrongStatement := coherent wrongStatement.StatementID = statementID("ac-2_smt.b") - require.ErrorContains(t, h.validateOfferingItemCoherence(fx.upstreamSSPID, wrongStatement), "statementId") + require.ErrorContains(t, h.validateOfferingItemCoherence(fx.upstreamSSPID, &wrongStatement), "statementId") wrongComponent := coherent wrongComponent.ComponentUUID = uuid.New().String() - require.ErrorContains(t, h.validateOfferingItemCoherence(fx.upstreamSSPID, wrongComponent), "componentUuid") + require.ErrorContains(t, h.validateOfferingItemCoherence(fx.upstreamSSPID, &wrongComponent), "componentUuid") unknownProvided := coherent unknownProvided.ProvidedUUID = uuid.New().String() - require.ErrorContains(t, h.validateOfferingItemCoherence(fx.upstreamSSPID, unknownProvided), "does not exist") + require.ErrorContains(t, h.validateOfferingItemCoherence(fx.upstreamSSPID, &unknownProvided), "does not exist") // Coherent in itself, but belonging to a different SSP entirely. otherSSP := relational.SystemSecurityPlan{} require.NoError(t, db.Create(&otherSSP).Error) require.NoError(t, db.Create(&relational.ControlImplementation{SystemSecurityPlanId: *otherSSP.ID}).Error) - require.ErrorContains(t, h.validateOfferingItemCoherence(*otherSSP.ID, coherent), "does not resolve inside this SSP") + require.ErrorContains(t, h.validateOfferingItemCoherence(*otherSSP.ID, &coherent), "does not resolve inside this SSP") } // TestOfferingItemCoherenceRejectsRequirementAnchoredProvided: a provided capability exported @@ -249,7 +249,7 @@ func TestOfferingItemCoherenceRejectsRequirementAnchoredProvided(t *testing.T) { legacyProvided := relational.ProvidedControlImplementation{ExportId: *legacyExport.ID} require.NoError(t, db.Create(&legacyProvided).Error) - err := h.validateOfferingItemCoherence(fx.upstreamSSPID, createExportOfferingItemRequest{ + err := h.validateOfferingItemCoherence(fx.upstreamSSPID, &createExportOfferingItemRequest{ ControlID: "ac-2", StatementID: statementID("ac-2_smt.a"), ComponentUUID: fx.componentID.String(), @@ -1395,3 +1395,133 @@ func TestUpdateByComponentRejectsComponentUuidChange(t *testing.T) { require.Equal(t, fx.componentID, stored.ComponentUUID) require.Equal(t, "renamed", stored.Description) } + +// --- the guard must hold on EVERY delete path, not just the sub-resource route ------------- + +// TestDeletingByComponentWithSubscribedInheritedConflicts: the 409 that protects a +// subscription-owned inherited entry must not be bypassable by deleting its PARENT. Every path +// that destroys a by-component destroys its Inherited rows with it, and SSPLeverageLink.InheritedUUID +// is a bare value with no FK — so an unguarded parent delete leaves the link pointing at nothing. +// +// A link with a dangling InheritedUUID is not merely untidy: projectLeveragedControls finds no +// inherited row, falls back to the zero by-component id, and derives satisfaction against an empty +// satisfied set — silently reporting `partial` with every responsibility outstanding on a link that +// was fully satisfied. ReAttest, meanwhile, 500s on the missing row and the link can never be +// re-attested. +func TestDeletingByComponentWithSubscribedInheritedConflicts(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + + link, created := subscribeFixture(t, db, fx, fx.respAID) + reqID := created.ImplementedRequirements[0].UUID + stmtID := created.Statements[0].UUID + bcID := created.ByComponents[0].UUID + + h := newSSPHandler(db) + + // The statement-level by-component DELETE — this is the by-component Subscribe always creates. + ctx, rec := newByComponentContext(http.MethodDelete, "", fx.downstreamSSPID, reqID, stmtID, bcID) + require.NoError(t, h.DeleteImplementedRequirementStatementByComponent(ctx)) + require.Equal(t, http.StatusConflict, rec.Code, rec.Body.String()) + require.Contains(t, rec.Body.String(), "unsubscribe") + + // The requirement DELETE, which cascades through every by-component beneath it. + e := echo.New() + rec = httptest.NewRecorder() + ctx = e.NewContext(httptest.NewRequest(http.MethodDelete, "/", nil), rec) + ctx.SetParamNames("id", "reqId") + ctx.SetParamValues(fx.downstreamSSPID.String(), reqID.String()) + require.NoError(t, h.DeleteImplementedRequirement(ctx)) + require.Equal(t, http.StatusConflict, rec.Code, rec.Body.String()) + + // Nothing was destroyed, and the link still resolves to its inherited row. + require.Equal(t, int64(1), countRows(t, db, &relational.ByComponent{}, "id = ?", bcID)) + require.Equal(t, int64(1), countRows(t, db, &relational.InheritedControlImplementation{}, "id = ?", link.InheritedUUID)) + require.Equal(t, int64(1), countRows(t, db, &relational.ImplementedRequirement{}, "id = ?", reqID)) + + projection, err := projectLeveragedControls(db, fx.downstreamSSPID) + require.NoError(t, err) + require.Len(t, projection, 1) + require.NotNil(t, projection[0].Inherited, "the link must still resolve to a live inherited row") + require.Equal(t, bcID, projection[0].ByComponentID) +} + +// TestDeletingByComponentWithHandAuthoredInheritedSucceeds is the control for the test above: the +// guard keys on an actual SSPLeverageLink, so a hand-authored inherited entry (no link) does not +// block the delete — otherwise the cascade would be unusable. +func TestDeletingByComponentWithHandAuthoredInheritedSucceeds(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + + inherited := relational.InheritedControlImplementation{ + ByComponentId: fx.byComponentID, ProvidedUuid: uuid.New(), Description: "hand-authored", + } + require.NoError(t, db.Create(&inherited).Error) + + h := newSSPHandler(db) + ctx, rec := newByComponentContext(http.MethodDelete, "", fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID) + require.NoError(t, h.DeleteImplementedRequirementStatementByComponent(ctx)) + require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String()) + + require.Zero(t, countRows(t, db, &relational.ByComponent{}, "id = ?", fx.byComponentID)) + require.Zero(t, countRows(t, db, &relational.InheritedControlImplementation{}, "id = ?", inherited.ID)) +} + +// TestOfferingItemStoresCanonicalControlIdCasing: coherence folds case when matching a control-id, +// but the stored item must carry the requirement's catalog-canonical casing — because Subscribe's +// findOrCreateImplementedRequirement matches control_id EXACTLY. Store the client's casing and a +// downstream that already implements the control under the canonical casing gets a SECOND +// requirement row, splitting its tree across two rows for the same control. +func TestOfferingItemStoresCanonicalControlIdCasing(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := NewSSPExportOfferingHandler(zap.NewNop().Sugar(), db, nil) + + // The upstream requirement is stored as "ac-2"; the curator sends "AC-2". + req := createExportOfferingItemRequest{ + ControlID: "AC-2", + StatementID: statementID("ac-2_smt.a"), + ComponentUUID: fx.componentID.String(), + ProvidedUUID: fx.providedID.String(), + } + require.NoError(t, h.validateOfferingItemCoherence(fx.upstreamSSPID, &req)) + require.Equal(t, "ac-2", req.ControlID, + "coherence must normalize the control-id to the requirement's canonical casing before it is stored") + + // End-to-end through the write path: the persisted item carries the canonical casing... + e := echo.New() + body := `{"controlId":"AC-2","statementId":"ac-2_smt.a","componentUuid":"` + fx.componentID.String() + + `","providedUuid":"` + fx.providedID.String() + `"}` + httpReq := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body)) + httpReq.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + ctx := e.NewContext(httpReq, rec) + ctx.SetParamNames("id", "offeringId") + ctx.SetParamValues(fx.upstreamSSPID.String(), fx.offeringID.String()) + + require.NoError(t, h.CreateItem(ctx)) + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) + + var createdItem handler.GenericDataResponse[relational.SSPExportOfferingItem] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &createdItem)) + require.Equal(t, "ac-2", createdItem.Data.ControlID) + + // ...so Subscribe's exact match finds the downstream's existing requirement rather than + // inserting a duplicate for the same control. + var downstreamImpl relational.ControlImplementation + require.NoError(t, db.First(&downstreamImpl, "system_security_plan_id = ?", fx.downstreamSSPID).Error) + existing := relational.ImplementedRequirement{ControlImplementationId: *downstreamImpl.ID, ControlId: "ac-2"} + require.NoError(t, db.Create(&existing).Error) + + pdp := &stubPDP{allow: true} + lh := NewSSPLeverageHandler(zap.NewNop().Sugar(), db, pdp, authz.FailClosed) + sctx, _, srec := newSubscribeRequestContext(fx.offeringID, subscribeBody(fx.downstreamSSPID, *createdItem.Data.ID)) + require.NoError(t, lh.Subscribe(sctx)) + require.Equal(t, http.StatusCreated, srec.Code, srec.Body.String()) + + var count int64 + require.NoError(t, db.Model(&relational.ImplementedRequirement{}). + Where("control_implementation_id = ?", downstreamImpl.ID).Count(&count).Error) + require.Equal(t, int64(1), count, + "subscribe must reuse the downstream's existing ac-2 requirement, not insert a case-variant duplicate") +} diff --git a/internal/api/handler/oscal/system_security_plans.go b/internal/api/handler/oscal/system_security_plans.go index c3e82517..c9d09f89 100644 --- a/internal/api/handler/oscal/system_security_plans.go +++ b/internal/api/handler/oscal/system_security_plans.go @@ -3823,12 +3823,16 @@ func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementByComponent(ctx // // @Summary Delete an implemented requirement from a SSP // @Description Deletes an existing implemented requirement for a given SSP. +// @Description +// @Description Cascades through every by-component beneath the requirement, so it returns 409 +// @Description if any inherited entry under it is owned by a leverage subscription. // @Tags System Security Plans // @Param id path string true "SSP ID" // @Param reqId path string true "Requirement ID" // @Success 204 "No Content" // @Failure 400 {object} api.Error // @Failure 404 {object} api.Error +// @Failure 409 {object} api.Error // @Failure 500 {object} api.Error // @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId} [delete] func (h *SystemSecurityPlanHandler) DeleteImplementedRequirement(ctx echo.Context) error { @@ -3894,6 +3898,11 @@ func (h *SystemSecurityPlanHandler) DeleteImplementedRequirement(ctx echo.Contex } return tx.Delete(&req).Error }); err != nil { + // Deleting a requirement cascades through every by-component beneath it, so it inherits + // the same subscription guard: it cannot be used as a back door around the 409. + if errors.Is(err, errInheritedOwnedBySubscription) { + return ctx.JSON(http.StatusConflict, api.NewError(err)) + } h.sugar.Errorf("Failed to delete implemented requirement: %v", err) return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } @@ -4523,6 +4532,10 @@ func (h *SystemSecurityPlanHandler) reloadByComponent(byComponentID uuid.UUID) ( // // @Summary Delete a by-component within a statement (within an implemented requirement) // @Description Deletes a by-component within an existing statement within an implemented requirement for a given SSP. +// @Description +// @Description Returns 409 if any of the by-component's inherited entries is owned by a +// @Description leverage subscription — deleting the parent must not be a way around the same +// @Description guard the inherited sub-resource DELETE enforces. Unsubscribe first. // @Tags System Security Plans // @Accept json // @Produce json @@ -4533,6 +4546,7 @@ func (h *SystemSecurityPlanHandler) reloadByComponent(byComponentID uuid.UUID) ( // @Success 200 {object} handler.GenericDataResponse[oscalTypes_1_1_3.ByComponent] // @Failure 400 {object} api.Error // @Failure 404 {object} api.Error +// @Failure 409 {object} api.Error // @Failure 500 {object} api.Error // @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId} [delete] func (h *SystemSecurityPlanHandler) DeleteImplementedRequirementStatementByComponent(ctx echo.Context) error { @@ -4608,6 +4622,9 @@ func (h *SystemSecurityPlanHandler) DeleteImplementedRequirementStatementByCompo if err := h.db.Transaction(func(tx *gorm.DB) error { return deleteByComponentCascade(tx, *existing.ID) }); err != nil { + if errors.Is(err, errInheritedOwnedBySubscription) { + return ctx.JSON(http.StatusConflict, api.NewError(err)) + } h.sugar.Errorf("Failed to delete by-component: %v", err) return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } @@ -4876,7 +4893,7 @@ func (h *SystemSecurityPlanHandler) createByComponentExport(ctx echo.Context, bc // Export.ByComponentId has no unique DB constraint (this ticket makes no // schema change), so an advisory lock keyed on the by-component closes the // race between the existence check and the insert for concurrent creates. - if err := lockByComponentSubtreeCreate(tx, *bc.ID); err != nil { + if err := lockByComponentSubtreeWrite(tx, *bc.ID); err != nil { return err } @@ -4905,18 +4922,28 @@ func (h *SystemSecurityPlanHandler) createByComponentExport(ctx echo.Context, bc return ctx.JSON(http.StatusCreated, handler.GenericDataResponse[oscalTypes_1_1_3.Export]{Data: *created.MarshalOscal()}) } -// lockByComponentSubtreeCreate serializes concurrent creates under the same by-component — its +// lockByComponentSubtreeWrite serializes concurrent WRITES to one by-component's subtree — its // singleton Export, and its Inherited/Satisfied entries — with a transaction-scoped Postgres -// advisory lock keyed on the by-component's UUID. Nothing in the schema enforces -// exports.by_component_id uniqueness, and the satisfaction re-derivation a satisfied-write does -// reads rows a concurrent write is producing, so both need the same guard. +// advisory lock keyed on the by-component's UUID. +// +// Every writer that participates in a read-modify-write over that subtree must take it, not just +// the creates: nothing in the schema enforces exports.by_component_id uniqueness, and both the +// satisfied CREATE and the satisfied DELETE re-derive SSPLeverageLink.Satisfaction from the +// satisfied set they just changed. If only one side locked, the lock would serialize +// create-vs-create and nothing else, and a concurrent create and delete could each compute +// satisfaction from a snapshot taken before the other's write was visible — the SET value is +// computed in Go, so the second UPDATE would simply overwrite the first with a stale value (a +// plain lost update). The cached Satisfaction is what the drift detector and the notification +// path read, which is the whole reason resyncLeverageSatisfaction exists. // -// The lock key string stays "export-create:" despite the now-wider scope: it is only meaningful -// as a value all writers agree on, and changing it would stop old and new pods serializing -// against each other during a rolling deploy. +// (Named "...Create" until a delete needed it too — the name is why it got missed.) +// +// The lock key string stays "export-create:" despite the wider scope: it is only meaningful as a +// value all writers agree on, and changing it would stop old and new pods serializing against +// each other during a rolling deploy. // // It is a no-op against non-Postgres test drivers. -func lockByComponentSubtreeCreate(tx *gorm.DB, byComponentID uuid.UUID) error { +func lockByComponentSubtreeWrite(tx *gorm.DB, byComponentID uuid.UUID) error { if tx.Name() != "postgres" { return nil } @@ -5054,6 +5081,31 @@ func deleteByComponentCascade(tx *gorm.DB, byComponentID uuid.UUID) error { return err } + // An SSPLeverageLink must never be left pointing at nothing: the drift detector and the + // notification path both read through link.InheritedUUID, and InheritedUUID is a bare value + // with no FK, so nothing at the DB level stops the row vanishing underneath it. + // + // The guard lives HERE rather than in the inherited sub-resource DELETE alone, because every + // path that destroys a by-component destroys its Inherited rows with it — the statement-level + // and requirement-level by-component DELETEs, and the requirement DELETE that cascades through + // them. Guarding only the sub-resource route left the invariant trivially bypassable by + // deleting the parent instead. Callers map this to 409, same as the sub-resource route. + if len(bc.Inherited) > 0 { + inheritedIDs := make([]uuid.UUID, 0, len(bc.Inherited)) + for i := range bc.Inherited { + inheritedIDs = append(inheritedIDs, *bc.Inherited[i].ID) + } + var linkCount int64 + if err := tx.Model(&relational.SSPLeverageLink{}). + Where("inherited_uuid IN ?", inheritedIDs). + Count(&linkCount).Error; err != nil { + return err + } + if linkCount > 0 { + return errInheritedOwnedBySubscription + } + } + if err := deleteResponsibleRoles(tx, bc.ResponsibleRoles); err != nil { return err } From 87c4f0b04a43231eb181c6faeaecb028436d4271 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:42:46 -0300 Subject: [PATCH 06/13] fix: address review feedback --- docs/docs.go | 3 +- docs/swagger.json | 3 +- docs/swagger.yaml | 15 ++ .../api/handler/oscal/import_unit_test.go | 14 +- .../api/handler/oscal/ssp_by_components.go | 17 +- .../api/handler/oscal/ssp_export_offerings.go | 12 ++ internal/api/handler/oscal/ssp_leverage.go | 21 +++ .../oscal/ssp_shared_responsibility.go | 54 +++++- .../oscal/ssp_shared_responsibility_test.go | 170 +++++++++++++++++- 9 files changed, 294 insertions(+), 15 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 84089dc8..8eab6983 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -16219,7 +16219,7 @@ const docTemplate = `{ }, "/oscal/ssp-export-offerings/by-control/{controlId}": { "get": { - "description": "Cross-SSP catalog of what is exported for a control, by whom, and against\nwhich statement — every published offering item whose control-id matches\n(case-insensitively), with the offering, upstream SSP, component, provided\ncapability and responsibility set all resolved server-side. Honours the same\ntrust boundary as the flat catalog: gated by ssp-export-offering:read only,\nnever ssp:read on the upstream SSP. Pass downstreamSspId to narrow the result\nto offerings that SSP is actually allow-listed to subscribe to (BCH-1342).", + "description": "Cross-SSP catalog of what is exported for a control, by whom, and against\nwhich statement — every published offering item whose control-id matches\n(case-insensitively), with the offering, upstream SSP, component, provided\ncapability and responsibility set all resolved server-side. Honours the same\ntrust boundary as the flat catalog: gated by ssp-export-offering:read only,\nnever ssp:read on the upstream SSP. Pass downstreamSspId to narrow the result\nto offerings that SSP is actually allow-listed to subscribe to (BCH-1342).\n\ndownstreamSspId is a CONVENIENCE FILTER, not an authorization boundary: no\npermission is evaluated against the SSP it names — unlike Subscribe, which\nevaluates ssp:update on the downstream precisely because that id arrives in the\npayload rather than the URL. The filter cannot surface an offering the caller\ncould not already list; it only removes rows. It does mean a catalog reader can\ninfer an offering's allow-list membership by diffing the filtered and unfiltered\nresponses. That is an accepted trade-off, decided rather than inherited by\nomission: allow-list membership (who may leverage whom) is treated as\nnon-confidential topology, and a per-request PDP round-trip on a read path is not\nworth paying to hide it. If it ever must be confidential, evaluate ssp:read on\ndownstreamSspId here the way Subscribe does.", "produces": [ "application/json" ], @@ -39997,6 +39997,7 @@ const docTemplate = `{ "type": "string" }, "reason": { + "description": "Reason is \"requirement-anchored export\", or \"requirement-anchored inherited/satisfied\" when\nthe by-component carries consumer-side rows but no export of its own.", "type": "string" } } diff --git a/docs/swagger.json b/docs/swagger.json index 52ab32eb..28234382 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -16213,7 +16213,7 @@ }, "/oscal/ssp-export-offerings/by-control/{controlId}": { "get": { - "description": "Cross-SSP catalog of what is exported for a control, by whom, and against\nwhich statement — every published offering item whose control-id matches\n(case-insensitively), with the offering, upstream SSP, component, provided\ncapability and responsibility set all resolved server-side. Honours the same\ntrust boundary as the flat catalog: gated by ssp-export-offering:read only,\nnever ssp:read on the upstream SSP. Pass downstreamSspId to narrow the result\nto offerings that SSP is actually allow-listed to subscribe to (BCH-1342).", + "description": "Cross-SSP catalog of what is exported for a control, by whom, and against\nwhich statement — every published offering item whose control-id matches\n(case-insensitively), with the offering, upstream SSP, component, provided\ncapability and responsibility set all resolved server-side. Honours the same\ntrust boundary as the flat catalog: gated by ssp-export-offering:read only,\nnever ssp:read on the upstream SSP. Pass downstreamSspId to narrow the result\nto offerings that SSP is actually allow-listed to subscribe to (BCH-1342).\n\ndownstreamSspId is a CONVENIENCE FILTER, not an authorization boundary: no\npermission is evaluated against the SSP it names — unlike Subscribe, which\nevaluates ssp:update on the downstream precisely because that id arrives in the\npayload rather than the URL. The filter cannot surface an offering the caller\ncould not already list; it only removes rows. It does mean a catalog reader can\ninfer an offering's allow-list membership by diffing the filtered and unfiltered\nresponses. That is an accepted trade-off, decided rather than inherited by\nomission: allow-list membership (who may leverage whom) is treated as\nnon-confidential topology, and a per-request PDP round-trip on a read path is not\nworth paying to hide it. If it ever must be confidential, evaluate ssp:read on\ndownstreamSspId here the way Subscribe does.", "produces": [ "application/json" ], @@ -39991,6 +39991,7 @@ "type": "string" }, "reason": { + "description": "Reason is \"requirement-anchored export\", or \"requirement-anchored inherited/satisfied\" when\nthe by-component carries consumer-side rows but no export of its own.", "type": "string" } } diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 9a7aeebd..55114828 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -4058,6 +4058,9 @@ definitions: controlId: type: string reason: + description: |- + Reason is "requirement-anchored export", or "requirement-anchored inherited/satisfied" when + the by-component carries consumer-side rows but no export of its own. type: string type: object oscal.SharedResponsibilityProvides: @@ -22420,6 +22423,18 @@ paths: trust boundary as the flat catalog: gated by ssp-export-offering:read only, never ssp:read on the upstream SSP. Pass downstreamSspId to narrow the result to offerings that SSP is actually allow-listed to subscribe to (BCH-1342). + + downstreamSspId is a CONVENIENCE FILTER, not an authorization boundary: no + permission is evaluated against the SSP it names — unlike Subscribe, which + evaluates ssp:update on the downstream precisely because that id arrives in the + payload rather than the URL. The filter cannot surface an offering the caller + could not already list; it only removes rows. It does mean a catalog reader can + infer an offering's allow-list membership by diffing the filtered and unfiltered + responses. That is an accepted trade-off, decided rather than inherited by + omission: allow-list membership (who may leverage whom) is treated as + non-confidential topology, and a per-request PDP round-trip on a read path is not + worth paying to hide it. If it ever must be confidential, evaluate ssp:read on + downstreamSspId here the way Subscribe does. parameters: - description: Control ID (e.g. AC-2) in: path diff --git a/internal/api/handler/oscal/import_unit_test.go b/internal/api/handler/oscal/import_unit_test.go index 6e1aad3e..0e5d580b 100644 --- a/internal/api/handler/oscal/import_unit_test.go +++ b/internal/api/handler/oscal/import_unit_test.go @@ -147,9 +147,19 @@ func TestStatementLevelInheritedAndSatisfiedRoutesExist(t *testing.T) { // Inherited/Satisfied describe the downstream half of the export -> inherit -> satisfy loop, // which is anchored on a statement — there is deliberately no requirement-level surface. - for _, suffix := range []string{"/inherited", "/satisfied"} { + // + // Assert BOTH the collection and the item shapes. A requirement-level PUT/DELETE would be + // registered at .../inherited/:inheritedId, never at .../inherited — so asserting PUT/DELETE + // only against the collection path proves nothing, and would keep passing even if someone + // added the item routes, which is the exact regression this guard exists to prevent. + for _, path := range []string{ + requirementBC + "/inherited", + requirementBC + "/inherited/:inheritedId", + requirementBC + "/satisfied", + requirementBC + "/satisfied/:satisfiedId", + } { for _, method := range []string{"GET", "POST", "PUT", "DELETE"} { - require.NotContains(t, routes, method+" "+requirementBC+suffix) + require.NotContains(t, routes, method+" "+path) } } } diff --git a/internal/api/handler/oscal/ssp_by_components.go b/internal/api/handler/oscal/ssp_by_components.go index 4411dd4b..ce65b8a2 100644 --- a/internal/api/handler/oscal/ssp_by_components.go +++ b/internal/api/handler/oscal/ssp_by_components.go @@ -455,9 +455,24 @@ func (h *SystemSecurityPlanHandler) CreateImplementedRequirementStatementByCompo if oscalInherited.ProvidedUuid == "" { return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("provided-uuid is required"))) } - if _, err := uuid.Parse(oscalInherited.ProvidedUuid); err != nil { + providedUUID, err := uuid.Parse(oscalInherited.ProvidedUuid) + if err != nil { return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("provided-uuid must be a valid UUID"))) } + // Well-formed is not enough: it has to resolve. An inherited row pointing at a + // ProvidedControlImplementation that doesn't exist is inert — inheritableResponsibilities + // resolves nothing for it, so no satisfied entry can ever be accepted against it — yet it + // still reads back as a real inherited capability. Same class of defect the offering-item + // coherence check rejects, one layer down. The satisfied POST already validates its + // responsibility-uuid this way. + if err := h.db.First(&relational.ProvidedControlImplementation{}, "id = ?", providedUUID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf( + "provided-uuid %q does not resolve to a provided control implementation", providedUUID))) + } + h.sugar.Errorf("Failed to resolve provided control implementation: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } relInherited := &relational.InheritedControlImplementation{} relInherited.UnmarshalOscal(oscalInherited) diff --git a/internal/api/handler/oscal/ssp_export_offerings.go b/internal/api/handler/oscal/ssp_export_offerings.go index 8f719e78..32a40a3e 100644 --- a/internal/api/handler/oscal/ssp_export_offerings.go +++ b/internal/api/handler/oscal/ssp_export_offerings.go @@ -1282,6 +1282,18 @@ type ControlExportOffer struct { // @Description trust boundary as the flat catalog: gated by ssp-export-offering:read only, // @Description never ssp:read on the upstream SSP. Pass downstreamSspId to narrow the result // @Description to offerings that SSP is actually allow-listed to subscribe to (BCH-1342). +// @Description +// @Description downstreamSspId is a CONVENIENCE FILTER, not an authorization boundary: no +// @Description permission is evaluated against the SSP it names — unlike Subscribe, which +// @Description evaluates ssp:update on the downstream precisely because that id arrives in the +// @Description payload rather than the URL. The filter cannot surface an offering the caller +// @Description could not already list; it only removes rows. It does mean a catalog reader can +// @Description infer an offering's allow-list membership by diffing the filtered and unfiltered +// @Description responses. That is an accepted trade-off, decided rather than inherited by +// @Description omission: allow-list membership (who may leverage whom) is treated as +// @Description non-confidential topology, and a per-request PDP round-trip on a read path is not +// @Description worth paying to hide it. If it ever must be confidential, evaluate ssp:read on +// @Description downstreamSspId here the way Subscribe does. // @Tags SSP Export Offerings // @Produce json // @Param controlId path string true "Control ID (e.g. AC-2)" diff --git a/internal/api/handler/oscal/ssp_leverage.go b/internal/api/handler/oscal/ssp_leverage.go index 08082ed2..c331e331 100644 --- a/internal/api/handler/oscal/ssp_leverage.go +++ b/internal/api/handler/oscal/ssp_leverage.go @@ -815,8 +815,29 @@ func (h *SSPLeverageHandler) Subscribe(ctx echo.Context) error { } return err } + + // deriveSatisfaction is shared, but its *input* was not: the satisfaction computed + // above sees only the responsibilities THIS request asked to satisfy, while every + // reader recomputes from ALL satisfied rows on the by-component. Those disagree when + // the by-component already carries a hand-authored satisfied row for one of this + // provided-uuid's responsibilities — the link would store "partial" while the + // projection reports "full", and the stored value is what the drift detector and the + // notification path consume. Re-deriving through the same helper the satisfied + // CRUD uses makes the "derived in exactly one place" claim literally true. + if err := resyncLeverageSatisfaction(tx, downstreamSSPID, *byComponent.ID); err != nil { + return err + } + links = append(links, link) } + + // The links slice was built from the pre-resync values; re-read so the response reports + // what was actually committed. + for i := range links { + if err := tx.First(&links[i], "id = ?", links[i].ID).Error; err != nil { + return err + } + } return nil }); err != nil { if errors.Is(err, errDuplicateLeverageLink) { diff --git a/internal/api/handler/oscal/ssp_shared_responsibility.go b/internal/api/handler/oscal/ssp_shared_responsibility.go index 893752b3..52ed49ac 100644 --- a/internal/api/handler/oscal/ssp_shared_responsibility.go +++ b/internal/api/handler/oscal/ssp_shared_responsibility.go @@ -75,14 +75,20 @@ type SharedResponsibilitySatisfies struct { ResponsibleRoles []oscalTypes_1_1_3.ResponsibleRole `json:"responsibleRoles"` } -// SharedResponsibilityLegacy is one requirement-anchored by-component still carrying shared -// responsibility. These can't be expressed in the statement-anchored model, so they're -// surfaced explicitly rather than silently dropped: they are exactly the rows the -// requirement-level DELETE exists to wind down. +// SharedResponsibilityLegacy is one requirement-anchored by-component that ACTUALLY CARRIES +// shared responsibility — it has an export, an inherited entry, or a satisfied entry. These +// can't be expressed in the statement-anchored model, so they're surfaced explicitly rather than +// silently dropped: they are exactly the rows the requirement-level DELETE exists to wind down. +// +// A requirement-anchored by-component with none of those is NOT reported: it is the ordinary +// OSCAL shape for "this component implements this control", which every normal SSP is full of, +// and calling it legacy debt would invite the user to delete their own control implementation. type SharedResponsibilityLegacy struct { ControlID string `json:"controlId"` ByComponentUUID uuid.UUID `json:"byComponentUuid"` - Reason string `json:"reason"` + // Reason is "requirement-anchored export", or "requirement-anchored inherited/satisfied" when + // the by-component carries consumer-side rows but no export of its own. + Reason string `json:"reason"` } // SharedResponsibilityRollup is everything one SSP provides, inherits and satisfies — @@ -234,6 +240,24 @@ func (h *SystemSecurityPlanHandler) collectOwnedSharedResponsibility(rollup *Sha Find(&satisfiedRows).Error; err != nil { return err } + satisfiedByComponent := make(map[uuid.UUID]bool, len(satisfiedRows)) + for i := range satisfiedRows { + satisfiedByComponent[satisfiedRows[i].ByComponentId] = true + } + + // Presence only — used to tell a requirement-anchored by-component that carries shared + // responsibility (real legacy debt) from one that is merely an ordinary control implementation. + var inheritedRows []relational.InheritedControlImplementation + if err := h.db. + Select("id, by_component_id"). + Where("by_component_id IN ?", byComponentIDs). + Find(&inheritedRows).Error; err != nil { + return err + } + inheritedByComponent := make(map[uuid.UUID]bool, len(inheritedRows)) + for i := range inheritedRows { + inheritedByComponent[inheritedRows[i].ByComponentId] = true + } componentUUIDs := uniqueUUIDs(byComponents, func(bc relational.ByComponent) uuid.UUID { return bc.ComponentUUID }) var components []relational.SystemComponent @@ -299,10 +323,28 @@ func (h *SystemSecurityPlanHandler) collectOwnedSharedResponsibility(rollup *Sha } if a.legacy { + // Only a requirement-anchored by-component that ACTUALLY CARRIES shared responsibility + // is legacy debt. A requirement-anchored by-component with no export, no inherited and + // no satisfied row is just the ordinary OSCAL shape for "this component implements this + // control" — ImplementedRequirement.ByComponents is a first-class association, and every + // normal SSP created or imported through POST /api/oscal/import is full of them. + // Reporting those would hand the UI an SSP's entire control implementation as debt and + // invite the user to wind down their own work. + _, hasExport := exportByComponent[*bc.ID] + hasInherited := inheritedByComponent[*bc.ID] + hasSatisfied := satisfiedByComponent[*bc.ID] + if !hasExport && !hasInherited && !hasSatisfied { + continue + } + + reason := "requirement-anchored export" + if !hasExport { + reason = "requirement-anchored inherited/satisfied" + } rollup.Legacy = append(rollup.Legacy, SharedResponsibilityLegacy{ ControlID: a.controlID, ByComponentUUID: *bc.ID, - Reason: "requirement-anchored export", + Reason: reason, }) continue } diff --git a/internal/api/handler/oscal/ssp_shared_responsibility_test.go b/internal/api/handler/oscal/ssp_shared_responsibility_test.go index aa2e226f..32149f5c 100644 --- a/internal/api/handler/oscal/ssp_shared_responsibility_test.go +++ b/internal/api/handler/oscal/ssp_shared_responsibility_test.go @@ -555,7 +555,8 @@ func TestInheritedCRUDRoundTrip(t *testing.T) { fx := newSharedResponsibilityFixture(t, db) h := newSSPHandler(db) - providedUUID := uuid.New() + // A provided-uuid that actually resolves — the create now rejects one that doesn't. + providedUUID := fx.providedID body := fmt.Sprintf(`{"provided-uuid": %q, "description": "we inherit this"}`, providedUUID.String()) ctx, rec := newByComponentContext(http.MethodPost, body, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID) require.NoError(t, h.CreateImplementedRequirementStatementByComponentInherited(ctx)) @@ -935,12 +936,24 @@ func TestSharedResponsibilityRollup(t *testing.T) { fx := newSharedResponsibilityFixture(t, db) h := newSSPHandler(db) - // A legacy requirement-anchored by-component that must be reported, not silently dropped. + // A legacy requirement-anchored by-component that must be reported, not silently dropped — + // it carries an actual export, which is what makes it shared-responsibility debt. requirementsType := "implemented_requirements" legacyBC := relational.ByComponent{ ParentID: &fx.requirementID, ParentType: &requirementsType, ComponentUUID: fx.componentID, } require.NoError(t, db.Create(&legacyBC).Error) + legacyExport := relational.Export{ByComponentId: *legacyBC.ID, Description: "legacy export"} + require.NoError(t, db.Create(&legacyExport).Error) + + // ...and a bare requirement-anchored by-component that carries NOTHING. This is the ordinary + // OSCAL shape for "this component implements this control", which every imported SSP is full + // of — it must NOT be reported as debt. + ordinaryBC := relational.ByComponent{ + ParentID: &fx.requirementID, ParentType: &requirementsType, ComponentUUID: uuid.New(), + Description: "an ordinary requirement-level implementation", + } + require.NoError(t, db.Create(&ordinaryBC).Error) rollup := func(sspID uuid.UUID, query string) SharedResponsibilityRollup { e := echo.New() @@ -968,7 +981,7 @@ func TestSharedResponsibilityRollup(t *testing.T) { require.Len(t, up.Provides[0].Responsibilities, 2) require.True(t, up.Provides[0].Offered, "an offering item points at this provided-uuid") - require.Len(t, up.Legacy, 1) + require.Len(t, up.Legacy, 1, "only the by-component actually carrying an export is debt") require.Equal(t, *legacyBC.ID, up.Legacy[0].ByComponentUUID) require.Equal(t, "requirement-anchored export", up.Legacy[0].Reason) @@ -1015,6 +1028,10 @@ func TestSharedResponsibilityFiltersByControl(t *testing.T) { ctx.SetParamNames("id") ctx.SetParamValues(sspID.String()) require.NoError(t, h.SharedResponsibility(ctx)) + // Without this, an error response still returns nil from the handler and its JSON body + // unmarshals into a ZERO-VALUE rollup — so every "Empty" assertion below would pass just + // as happily on a 500 as on a correctly-filtered 200. + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) var resp handler.GenericDataResponse[SharedResponsibilityRollup] require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) @@ -1083,7 +1100,7 @@ func TestInheritedResponsibleRolesSurviveCreateUpdateAndClearOnDelete(t *testing "provided-uuid": %q, "description": "inherited with roles", "responsible-roles": [{"role-id": "provider", "party-uuids": [%q]}] - }`, uuid.New().String(), partyID.String()) + }`, fx.providedID.String(), partyID.String()) ctx, rec := newByComponentContext(http.MethodPost, createBody, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID) require.NoError(t, h.CreateImplementedRequirementStatementByComponentInherited(ctx)) @@ -1525,3 +1542,148 @@ func TestOfferingItemStoresCanonicalControlIdCasing(t *testing.T) { require.Equal(t, int64(1), count, "subscribe must reuse the downstream's existing ac-2 requirement, not insert a case-variant duplicate") } + +// TestSharedResponsibilityLegacyExcludesOrdinaryImplementations: a requirement-anchored +// by-component that carries no export, no inherited and no satisfied row is NOT legacy debt — it +// is the ordinary OSCAL shape for "this component implements this control", and every SSP created +// or imported through POST /api/oscal/import is full of them. Reporting those would hand the UI an +// SSP's entire control implementation as debt and invite the user to delete their own work. +func TestSharedResponsibilityLegacyExcludesOrdinaryImplementations(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := newSSPHandler(db) + + requirementsType := "implemented_requirements" + newLegacyBC := func(desc string) relational.ByComponent { + bc := relational.ByComponent{ + ParentID: &fx.requirementID, ParentType: &requirementsType, + ComponentUUID: uuid.New(), Description: desc, + } + require.NoError(t, db.Create(&bc).Error) + return bc + } + + // Three ordinary requirement-level implementations — the shape a normal imported SSP is full of. + ordinaryA := newLegacyBC("ordinary a") + newLegacyBC("ordinary b") + newLegacyBC("ordinary c") + + // One that carries an export, and one that carries only consumer-side rows. + withExport := newLegacyBC("carries an export") + require.NoError(t, db.Create(&relational.Export{ByComponentId: *withExport.ID}).Error) + + withInherited := newLegacyBC("carries an inherited entry") + require.NoError(t, db.Create(&relational.InheritedControlImplementation{ + ByComponentId: *withInherited.ID, ProvidedUuid: uuid.New(), Description: "inherited", + }).Error) + + withSatisfied := newLegacyBC("carries a satisfied entry") + require.NoError(t, db.Create(&relational.SatisfiedControlImplementationResponsibility{ + ByComponentId: *withSatisfied.ID, ResponsibilityUuid: uuid.New(), Description: "satisfied", + }).Error) + + e := echo.New() + rec := httptest.NewRecorder() + ctx := e.NewContext(httptest.NewRequest(http.MethodGet, "/", nil), rec) + ctx.SetParamNames("id") + ctx.SetParamValues(fx.upstreamSSPID.String()) + require.NoError(t, h.SharedResponsibility(ctx)) + require.Equal(t, http.StatusOK, rec.Code) + + var resp handler.GenericDataResponse[SharedResponsibilityRollup] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + + reported := make(map[uuid.UUID]string, len(resp.Data.Legacy)) + for _, l := range resp.Data.Legacy { + reported[l.ByComponentUUID] = l.Reason + } + + require.Len(t, resp.Data.Legacy, 3, "only the three by-components actually carrying shared responsibility are debt") + require.Equal(t, "requirement-anchored export", reported[*withExport.ID]) + require.Equal(t, "requirement-anchored inherited/satisfied", reported[*withInherited.ID]) + require.Equal(t, "requirement-anchored inherited/satisfied", reported[*withSatisfied.ID]) + + require.NotContains(t, reported, *ordinaryA.ID, + "an ordinary requirement-level implementation is not shared-responsibility debt") +} + +// TestCreateInheritedRejectsUnresolvableProvidedUuid: a well-formed but unresolvable provided-uuid +// creates an inert row — inheritableResponsibilities resolves nothing for it, so no satisfied entry +// can ever be accepted against it, yet it reads back as a real inherited capability. The satisfied +// POST already validates its responsibility-uuid this way; this closes the same gap one layer down. +func TestCreateInheritedRejectsUnresolvableProvidedUuid(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := newSSPHandler(db) + + body := fmt.Sprintf(`{"provided-uuid": %q, "description": "points at nothing"}`, uuid.New().String()) + ctx, rec := newByComponentContext(http.MethodPost, body, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID) + require.NoError(t, h.CreateImplementedRequirementStatementByComponentInherited(ctx)) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "does not resolve") + require.Zero(t, countRows(t, db, &relational.InheritedControlImplementation{}, "by_component_id = ?", fx.byComponentID)) + + // A provided-uuid that does resolve is accepted. + body = fmt.Sprintf(`{"provided-uuid": %q, "description": "real"}`, fx.providedID.String()) + ctx, rec = newByComponentContext(http.MethodPost, body, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID) + require.NoError(t, h.CreateImplementedRequirementStatementByComponentInherited(ctx)) + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) + require.Equal(t, int64(1), countRows(t, db, &relational.InheritedControlImplementation{}, "by_component_id = ?", fx.byComponentID)) +} + +// TestSubscribeStoresSatisfactionAgreeingWithTheProjection: deriveSatisfaction is shared, but its +// INPUT used to differ — Subscribe saw only the responsibilities this request asked to satisfy, +// while every reader recomputes from all satisfied rows on the by-component. A pre-existing +// hand-authored satisfied row therefore made the stored value ("partial") disagree with what the +// projection reports ("full"), and the stored value is what the drift detector consumes. +func TestSubscribeStoresSatisfactionAgreeingWithTheProjection(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + + // The downstream already hand-authored the whole tree for this statement, satisfying BOTH of + // the upstream's responsibilities — no leverage link, so the duplicate-link pre-check is silent. + var downstreamImpl relational.ControlImplementation + require.NoError(t, db.First(&downstreamImpl, "system_security_plan_id = ?", fx.downstreamSSPID).Error) + var downstreamSysImpl relational.SystemImplementation + require.NoError(t, db.First(&downstreamSysImpl, "system_security_plan_id = ?", fx.downstreamSSPID).Error) + + thisSystem := relational.SystemComponent{ + Type: thisSystemComponentType, Title: "This System", + SystemImplementationId: *downstreamSysImpl.ID, + } + require.NoError(t, db.Create(&thisSystem).Error) + + req := relational.ImplementedRequirement{ControlImplementationId: *downstreamImpl.ID, ControlId: "ac-2"} + require.NoError(t, db.Create(&req).Error) + stmt := relational.Statement{ImplementedRequirementId: *req.ID, StatementId: "ac-2_smt.a"} + require.NoError(t, db.Create(&stmt).Error) + + statementsType := "statements" + bc := relational.ByComponent{ParentID: stmt.ID, ParentType: &statementsType, ComponentUUID: *thisSystem.ID} + require.NoError(t, db.Create(&bc).Error) + + for _, respID := range []uuid.UUID{fx.respAID, fx.respBID} { + require.NoError(t, db.Create(&relational.SatisfiedControlImplementationResponsibility{ + ByComponentId: *bc.ID, ResponsibilityUuid: respID, Description: "hand-authored", + }).Error) + } + + // Subscribe WITHOUT naming any responsibility to satisfy: the request's own satisfied set is + // empty, so the old code would have stored "partial". + link, _ := subscribeFixture(t, db, fx) + + var stored relational.SSPLeverageLink + require.NoError(t, db.First(&stored, "id = ?", link.ID).Error) + + projection, err := projectLeveragedControls(db, fx.downstreamSSPID) + require.NoError(t, err) + require.Len(t, projection, 1) + + require.Equal(t, relational.SSPLeverageSatisfactionFull, projection[0].Satisfaction, + "the reader sees both responsibilities covered by the pre-existing satisfied rows") + require.Equal(t, projection[0].Satisfaction, stored.Satisfaction, + "the value Subscribe stored — what the drift detector reads — must agree with the projection") + require.Equal(t, projection[0].Satisfaction, link.Satisfaction, + "and the subscribe response must report what was actually committed") +} From 58a8d91d4a942d91e75c1a6c30c0929e497f13a1 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:48:39 -0300 Subject: [PATCH 07/13] fix: address review feedback --- internal/api/handler/oscal/ssp_leverage.go | 19 ++++++++++++++ .../handler/oscal/system_security_plans.go | 26 ++++++++++++------- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/internal/api/handler/oscal/ssp_leverage.go b/internal/api/handler/oscal/ssp_leverage.go index c331e331..203f970d 100644 --- a/internal/api/handler/oscal/ssp_leverage.go +++ b/internal/api/handler/oscal/ssp_leverage.go @@ -1150,6 +1150,25 @@ func (h *SSPLeverageHandler) ReAttest(ctx echo.Context) error { return fmt.Errorf("failed to load inherited control implementation: %w", err) } + // ReAttest is a third read-modify-write over this by-component's subtree: it reads the + // satisfied set, derives satisfaction in Go, and UPDATEs the link with the computed value. + // It must take the same lock the satisfied CREATE/DELETE take, or the identical lost-update + // applies with ReAttest as one of the racers — it could read the satisfied set before a + // concurrent satisfied write commits and then overwrite that writer's freshly-derived + // satisfaction with a stale one. (The `WHERE status = drifted` guard below defends against a + // concurrent re-attest, not against a concurrent satisfied write, which never touches status.) + // + // The race is new even though ReAttest isn't: before this PR there were no Inherited/Satisfied + // handlers at all — satisfied rows were written only by Subscribe — so ReAttest had no + // concurrent writer to race. The satisfied CRUD and resyncLeverageSatisfaction are what + // create the second writer. + // + // Taken after the inherited row is loaded, since that is what names the by-component, but + // before every read that feeds the derivation below. + if err := lockByComponentSubtreeWrite(tx, inherited.ByComponentId); err != nil { + return err + } + var satisfiedRows []relational.SatisfiedControlImplementationResponsibility if err := tx.Where("by_component_id = ?", inherited.ByComponentId).Find(&satisfiedRows).Error; err != nil { return fmt.Errorf("failed to load satisfied responsibilities: %w", err) diff --git a/internal/api/handler/oscal/system_security_plans.go b/internal/api/handler/oscal/system_security_plans.go index c9d09f89..9a408166 100644 --- a/internal/api/handler/oscal/system_security_plans.go +++ b/internal/api/handler/oscal/system_security_plans.go @@ -4926,17 +4926,23 @@ func (h *SystemSecurityPlanHandler) createByComponentExport(ctx echo.Context, bc // singleton Export, and its Inherited/Satisfied entries — with a transaction-scoped Postgres // advisory lock keyed on the by-component's UUID. // -// Every writer that participates in a read-modify-write over that subtree must take it, not just -// the creates: nothing in the schema enforces exports.by_component_id uniqueness, and both the -// satisfied CREATE and the satisfied DELETE re-derive SSPLeverageLink.Satisfaction from the -// satisfied set they just changed. If only one side locked, the lock would serialize -// create-vs-create and nothing else, and a concurrent create and delete could each compute -// satisfaction from a snapshot taken before the other's write was visible — the SET value is -// computed in Go, so the second UPDATE would simply overwrite the first with a stale value (a -// plain lost update). The cached Satisfaction is what the drift detector and the notification -// path read, which is the whole reason resyncLeverageSatisfaction exists. +// EVERY writer that performs a read-modify-write over that subtree must take it, not just the +// creates. Nothing in the schema enforces exports.by_component_id uniqueness, and four writers +// re-derive SSPLeverageLink.Satisfaction from a satisfied set they read earlier in the same +// transaction: the satisfied CREATE, the satisfied DELETE, Subscribe (via +// resyncLeverageSatisfaction), and ReAttest. Each computes the SET value in Go from a snapshot, so +// any writer that skips the lock can overwrite a concurrent writer's freshly-derived value with a +// stale one — a plain lost update. Partial adoption is worse than useless: it serializes only the +// writers that opted in, while reading as though the subtree were safe. // -// (Named "...Create" until a delete needed it too — the name is why it got missed.) +// ReAttest's `WHERE status = drifted` guard is not a substitute — it defends against a concurrent +// re-attest, not against a concurrent satisfied write, which never touches status. +// +// The cached Satisfaction is what the drift detector and the notification path read, which is the +// whole reason resyncLeverageSatisfaction exists. +// +// (Named "...Create" until a delete needed it too — that create-only name is exactly why the +// delete and ReAttest were missed. It is a subtree-WRITE lock; keep the name honest.) // // The lock key string stays "export-create:" despite the wider scope: it is only meaningful as a // value all writers agree on, and changing it would stop old and new pods serializing against From 8f45bcf8b6efb57f9480aab7203550b8264e739e Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:00:54 -0300 Subject: [PATCH 08/13] fix: address review feedback --- .../api/handler/oscal/ssp_by_components.go | 11 +++ internal/api/handler/oscal/ssp_leverage.go | 21 +++++ .../oscal/subtree_lock_invariant_test.go | 90 +++++++++++++++++++ .../handler/oscal/system_security_plans.go | 7 ++ 4 files changed, 129 insertions(+) create mode 100644 internal/api/handler/oscal/subtree_lock_invariant_test.go diff --git a/internal/api/handler/oscal/ssp_by_components.go b/internal/api/handler/oscal/ssp_by_components.go index ce65b8a2..df694c02 100644 --- a/internal/api/handler/oscal/ssp_by_components.go +++ b/internal/api/handler/oscal/ssp_by_components.go @@ -955,7 +955,18 @@ func (h *SystemSecurityPlanHandler) inheritableResponsibilities(byComponentID uu // // Runs inside the caller's transaction so the satisfied write and the satisfaction it implies // commit together. +// +// It takes lockByComponentSubtreeWrite itself rather than trusting every caller to remember. This +// function IS the read-modify-write the lock exists to serialize (it reads the satisfied set and +// UPDATEs link satisfaction with a value computed in Go), and "every writer must take the lock" is +// an invariant that has already been half-missed twice — the satisfied DELETE, then Subscribe. +// Postgres advisory locks are re-entrant within a transaction, so callers that correctly take it +// earlier (to cover their own writes too, which they should) pay nothing for taking it twice. func resyncLeverageSatisfaction(tx *gorm.DB, downstreamSSPID, byComponentID uuid.UUID) error { + if err := lockByComponentSubtreeWrite(tx, byComponentID); err != nil { + return err + } + var inherited []relational.InheritedControlImplementation if err := tx.Where("by_component_id = ?", byComponentID).Find(&inherited).Error; err != nil { return err diff --git a/internal/api/handler/oscal/ssp_leverage.go b/internal/api/handler/oscal/ssp_leverage.go index 203f970d..f197757a 100644 --- a/internal/api/handler/oscal/ssp_leverage.go +++ b/internal/api/handler/oscal/ssp_leverage.go @@ -755,6 +755,27 @@ func (h *SSPLeverageHandler) Subscribe(ctx echo.Context) error { if err != nil { return err } + + // Subscribe is the fourth read-modify-write over this by-component's subtree: the + // resyncLeverageSatisfaction below reads its satisfied set and UPDATEs link + // satisfaction with a value computed in Go, so it takes the same lock the satisfied + // CREATE/DELETE and ReAttest take. + // + // findOrCreateByComponent REUSES an existing by-component, so subscribing to a second + // provided-uuid on the same (statement, component) pair lands on one that may already + // carry inherited entries and satisfied rows — and resyncLeverageSatisfaction rewrites + // the satisfaction of EVERY link inherited on that by-component, not just the one being + // created here. Skipping the lock would therefore let a stale value from this + // transaction clobber a concurrent satisfied write's freshly-derived one on a + // PRE-EXISTING link, which is a corruption that doesn't even point back at the subscribe + // that caused it. + // + // Postgres advisory locks are re-entrant within a transaction, so taking it once per + // item is safe when several items in one subscribe share a by-component. + if err := lockByComponentSubtreeWrite(tx, *byComponent.ID); err != nil { + return err + } + tracker.addByComponent(byComponent, *stmt.ID, bcCreated) inherited := relational.InheritedControlImplementation{ diff --git a/internal/api/handler/oscal/subtree_lock_invariant_test.go b/internal/api/handler/oscal/subtree_lock_invariant_test.go new file mode 100644 index 00000000..5c90c51c --- /dev/null +++ b/internal/api/handler/oscal/subtree_lock_invariant_test.go @@ -0,0 +1,90 @@ +package oscal + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestEveryLeverageSatisfactionWriterTakesTheSubtreeLock pins the invariant +// lockByComponentSubtreeWrite exists to enforce: every writer that performs a read-modify-write +// over a by-component's subtree — reading its satisfied set and UPDATEing SSPLeverageLink.Satisfaction +// with a value computed in Go — must take the lock, or a concurrent writer's freshly-derived value +// gets overwritten with a stale one. +// +// This is asserted structurally rather than by exercising a race because the lock is a Postgres +// advisory lock and a no-op under the sqlite unit driver, so no unit test can observe it. The +// invariant has been half-missed twice (the satisfied DELETE, then Subscribe — each time while the +// comment claimed full coverage), and "partial adoption is worse than useless" is precisely the +// failure this guards: three of four writers locking reads as though the subtree were safe. +// +// resyncLeverageSatisfaction takes the lock itself, so the three writers that derive through it are +// covered by construction. It is still asserted here — if someone removes it from resync as +// "redundant", this test names the writers that would silently lose their protection. +func TestEveryLeverageSatisfactionWriterTakesTheSubtreeLock(t *testing.T) { + const lockCall = "lockByComponentSubtreeWrite" + + // Every function that reaches a satisfaction derivation, and the file it lives in. + writers := map[string]string{ + // Derives inline (does not go through resyncLeverageSatisfaction), so its lock is + // load-bearing on its own. + "ReAttest": "ssp_leverage.go", + // Reuses an existing by-component that may already carry other links' inherited/satisfied + // rows, and resync rewrites the satisfaction of EVERY link on it — so a stale value here + // corrupts a pre-existing link, not the one being created. + "Subscribe": "ssp_leverage.go", + // The read-modify-write itself. Self-locking, so its callers cannot forget. + "resyncLeverageSatisfaction": "ssp_by_components.go", + // Lock taken early, to cover their own insert/delete as well as the derivation. + "CreateImplementedRequirementStatementByComponentSatisfied": "ssp_by_components.go", + "DeleteImplementedRequirementStatementByComponentSatisfied": "ssp_by_components.go", + } + + fset := token.NewFileSet() + for fn, file := range writers { + parsed, err := parser.ParseFile(fset, file, nil, 0) + require.NoErrorf(t, err, "parsing %s", file) + + decl := findFuncDecl(parsed, fn) + require.NotNilf(t, decl, "expected to find %s in %s — if it was renamed, update this test rather than deleting the case", fn, file) + + require.Truef(t, callsFunc(decl, lockCall), + "%s (%s) performs a read-modify-write over a by-component's subtree but never calls %s.\n"+ + "Every such writer must take the lock: it reads the satisfied set and UPDATEs "+ + "SSPLeverageLink.Satisfaction with a value computed in Go, so skipping the lock lets it "+ + "overwrite a concurrent writer's freshly-derived value with a stale one. Partial adoption "+ + "is worse than none — it serializes only the writers that opted in while reading as "+ + "though the subtree were safe.", fn, file, lockCall) + } +} + +func findFuncDecl(file *ast.File, name string) *ast.FuncDecl { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if ok && fn.Name.Name == name { + return fn + } + } + return nil +} + +// callsFunc reports whether decl's body contains a call to the named function, including inside +// closures (the handlers do their work inside a db.Transaction(func(tx *gorm.DB) error { ... })). +func callsFunc(decl *ast.FuncDecl, name string) bool { + found := false + ast.Inspect(decl, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if ident, ok := call.Fun.(*ast.Ident); ok && ident.Name == name { + found = true + return false + } + return true + }) + return found +} diff --git a/internal/api/handler/oscal/system_security_plans.go b/internal/api/handler/oscal/system_security_plans.go index 9a408166..83fada0b 100644 --- a/internal/api/handler/oscal/system_security_plans.go +++ b/internal/api/handler/oscal/system_security_plans.go @@ -4938,6 +4938,13 @@ func (h *SystemSecurityPlanHandler) createByComponentExport(ctx echo.Context, bc // ReAttest's `WHERE status = drifted` guard is not a substitute — it defends against a concurrent // re-attest, not against a concurrent satisfied write, which never touches status. // +// Three of those four writers reach the derivation through resyncLeverageSatisfaction, which now +// takes this lock ITSELF — so the invariant is enforced by construction on that path rather than by +// everyone remembering. (It was half-missed twice: first the satisfied DELETE, then Subscribe.) +// Callers still take it explicitly and earlier, to cover their own inserts/deletes as well as the +// derivation; advisory locks are re-entrant within a transaction, so the double take is free. +// ReAttest derives inline rather than via resync, so its lock is genuinely load-bearing. +// // The cached Satisfaction is what the drift detector and the notification path read, which is the // whole reason resyncLeverageSatisfaction exists. // From 16037009f682d3e7ecc0a6a149f55a918be2da72 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Thu, 16 Jul 2026 05:41:32 -0300 Subject: [PATCH 09/13] fix: more fixes Signed-off-by: Gustavo Carvalho --- docs/docs.go | 299 ++++++++++++++- docs/swagger.json | 299 ++++++++++++++- docs/swagger.yaml | 267 +++++++++++++- internal/api/handler/filter.go | 220 +++++++++++ .../oscal/filter_responsibilities_test.go | 349 ++++++++++++++++++ .../oscal/ssp_export_offering_status_test.go | 2 +- .../oscal/ssp_export_offerings_unit_test.go | 2 +- internal/api/handler/oscal/ssp_leverage.go | 290 ++++++++++----- ...ssp_leverage_allowlist_integration_test.go | 4 - .../oscal/ssp_leverage_drift_enqueue_test.go | 6 +- .../ssp_leverage_drift_integration_test.go | 18 +- .../handler/oscal/ssp_leverage_drift_test.go | 11 +- .../api/handler/oscal/ssp_leverage_test.go | 24 +- .../handler/oscal/ssp_leverage_unit_test.go | 55 ++- .../oscal/ssp_shared_responsibility_test.go | 23 +- ...ecurity_plans_leveraged_auth_drift_test.go | 4 +- internal/api/handler/request.go | 14 + internal/service/migrator.go | 9 + internal/service/relational/filters.go | 24 ++ internal/service/relational/ssp_leverage.go | 17 +- 20 files changed, 1740 insertions(+), 197 deletions(-) create mode 100644 internal/api/handler/oscal/filter_responsibilities_test.go diff --git a/docs/docs.go b/docs/docs.go index 8eab6983..39c81e63 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -4389,6 +4389,126 @@ const docTemplate = `{ } } }, + "/filters/{id}/responsibilities": { + "post": { + "description": "Associates this filter with an upstream responsibility the given downstream\nSSP inherits (BCH-1339's filter_responsibilities), so the responsibility's\nposture is computed live from the filter's evidence. When controlId is given,\nthe filter is also linked to that control (so control-level compliance\nsurfaces include it) with provenance recorded: detaching removes the control\nlink only if this attach created it.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Filters" + ], + "summary": "Attach a filter to an inherited responsibility", + "parameters": [ + { + "type": "string", + "description": "Filter ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Responsibility to attach", + "name": "attachment", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.attachFilterResponsibilityRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-relational_FilterResponsibility" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + } + } + }, + "/filters/{id}/responsibilities/{responsibilityUuid}": { + "delete": { + "description": "Removes the filter↔responsibility association for the given downstream SSP\n(sspId query param — the association's key is the full triple). The filter's\ncontrol link is removed only if it was created by a responsibility attachment\nand no other attachment on this filter still claims that control.", + "tags": [ + "Filters" + ], + "summary": "Detach a filter from an inherited responsibility", + "parameters": [ + { + "type": "string", + "description": "Filter ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Responsibility UUID", + "name": "responsibilityUuid", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Downstream SSP ID", + "name": "sspId", + "in": "query", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "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" + } + } + } + } + }, "/lineage/nodes/{key}/children": { "get": { "description": "Returns one level of children for a node. Key is a composite like catalog:\u003cuuid\u003e, group:\u003ccatalogId\u003e/\u003cgroupId\u003e, control:\u003ccatalogId\u003e/\u003ccontrolId\u003e, linkcat:\u003cchildCatalogId\u003e/\u003crelationship\u003e/\u003cparentCatalogId\u003e/\u003cparentControlId\u003e, risk:\u003criskId\u003e, evidence:\u003cstreamUuid\u003e. A control expands to synthetic linkcat catalog nodes (its implementing/documenting controls grouped by their catalog) plus its directly-linked risks; a linkcat node expands to that group's controls; a risk expands to its latest evidence per linked stream; evidence is a leaf.", @@ -16323,7 +16443,7 @@ const docTemplate = `{ }, "/oscal/ssp-export-offerings/{id}/subscribe": { "post": { - "description": "Records, on the downstream SSP named in the request body, an OSCAL\ninherited-control-implementation and (optionally) satisfied-responsibility\nentries per chosen offering item, plus one leveraged-authorization for the\nwhole request — all in a single atomic write. Never checks ssp:read on the\nupstream SSP: the trust boundary is that subscribing to a published offering\nonly requires ssp-export-offering:subscribe on the offering and ssp:update on\nthe downstream SSP.\n\nEvery subscribed item must be statement-anchored: a legacy offering item with\nno statement-id is rejected with 422. The materialized downstream tree is\nalways requirement -\u003e statement -\u003e by-component -\u003e inherited + satisfied, and is\nreported back in meta.created (each row flagged created:true when inserted,\nfalse when an existing row was reused) so the caller can render newly-created\nrequirements without re-walking the SSP. The data payload is unchanged.", + "description": "Records, on the downstream SSP named in the request body, an OSCAL\ninherited-control-implementation and (optionally) satisfied-responsibility\nentries per chosen offering item — all in a single atomic write. Never checks\nssp:read on the upstream SSP: the trust boundary is that subscribing to a\npublished offering only requires ssp-export-offering:subscribe on the offering\nand ssp:update on the downstream SSP.\n\nNo leveraged-authorization is created: sharing is decoupled from an Authority\nto Operate. A Leveraged Authorization is an independent, human-authored record\nof the downstream's real ATO and never gates importing.\n\nEvery subscribed item must be statement-anchored: a legacy offering item with\nno statement-id is rejected with 422. The materialized downstream tree is\nalways requirement -\u003e statement -\u003e by-component -\u003e inherited + satisfied, and is\nreported back in meta.created (each row flagged created:true when inserted,\nfalse when an existing row was reused) so the caller can render newly-created\nrequirements without re-walking the SSP. The data payload is unchanged.", "consumes": [ "application/json" ], @@ -23121,6 +23241,58 @@ const docTemplate = `{ ] } }, + "/oscal/system-security-plans/{id}/responsibility-filters": { + "get": { + "description": "Every filter attached to an upstream responsibility this SSP inherits\n(BCH-1339's filter_responsibilities), with filter names resolved — one call\nfor the whole SSP so per-responsibility evidence bars need no N+1. Writes\nare on the filters API (POST/DELETE /filters/:id/responsibilities).", + "produces": [ + "application/json" + ], + "tags": [ + "SSP Export Offerings" + ], + "summary": "List a downstream SSP's filter↔responsibility attachments", + "parameters": [ + { + "type": "string", + "description": "Downstream SSP ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-oscal_responsibilityFilterResponse" + } + }, + "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": [] + } + ] + } + }, "/oscal/system-security-plans/{id}/shared-responsibility": { "get": { "description": "A flat, statement-keyed projection of this SSP's shared-responsibility posture:\nwhat it exports (with the provided capabilities and the responsibilities it\npushes onto consumers, and whether each is offered on a *published* offering —\na draft/deprecated/revoked one does not count, since no downstream can reach\nit), what it inherits\nfrom upstreams (with live-recomputed satisfaction and link status), how it\ndischarges upstream responsibilities, and any legacy requirement-anchored\nby-components still to be migrated.\n\nThe inherits arm reuses the same batched projection GET /leveraged-controls\nserves, so the two can never disagree about satisfaction. Optionally filter the\nwhole rollup to one control with controlId.", @@ -35341,6 +35513,19 @@ const docTemplate = `{ "meta": {} } }, + "handler.GenericDataListResponse-oscal_responsibilityFilterResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/oscal.responsibilityFilterResponse" + } + }, + "meta": {} + } + }, "handler.GenericDataListResponse-poam_PoamItemControlLink": { "type": "object", "properties": { @@ -36845,6 +37030,19 @@ const docTemplate = `{ } } }, + "handler.GenericDataResponse-relational_FilterResponsibility": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/relational.FilterResponsibility" + } + ] + } + } + }, "handler.GenericDataResponse-relational_SSOGroupMapping": { "type": "object", "properties": { @@ -37491,6 +37689,29 @@ const docTemplate = `{ } } }, + "handler.attachFilterResponsibilityRequest": { + "type": "object", + "required": [ + "responsibilityUuid", + "sspId" + ], + "properties": { + "controlId": { + "description": "Optional control to also link the filter to (so control-level compliance\nsurfaces include it). The link's provenance is recorded: detaching the\nresponsibility removes the control link only if this attach created it.", + "type": "string" + }, + "responsibilityUuid": { + "description": "The upstream ControlImplementationResponsibility this filter should evidence.", + "type": "string", + "format": "uuid" + }, + "sspId": { + "description": "The DOWNSTREAM SSP that inherits the responsibility (matched against\nssp_leverage_links.downstream_ssp_id).", + "type": "string", + "format": "uuid" + } + } + }, "handler.availableNotificationProviderResponse": { "type": "object", "properties": { @@ -40825,6 +41046,10 @@ const docTemplate = `{ "oscal.leveragedControlResponse": { "type": "object", "properties": { + "byComponentId": { + "description": "ByComponentId is the downstream by-component the link's inherited entry hangs off —\nthe anchor for authoring satisfied entries against this link's responsibilities.\nNil only if the inherited row was deleted out from under the link.", + "type": "string" + }, "controlId": { "type": "string" }, @@ -40844,6 +41069,17 @@ const docTemplate = `{ "$ref": "#/definitions/oscal.upstreamResponsibility" } }, + "providedUuid": { + "description": "ProvidedUuid is the upstream provided capability this link consumes — the key the\ndownstream's inherited entries reference.", + "type": "string" + }, + "responsibilities": { + "description": "Responsibilities is the FULL upstream responsibility set under this link (uuid +\ndescription). Downstream surfaces label every responsibility from this — including ones\nalready satisfied — so the responsibility's own text is never replaced by a satisfied\nentry's \"how we handle this\" wording. OutstandingResponsibilities is the not-yet-covered\nsubset (unchanged).", + "type": "array", + "items": { + "$ref": "#/definitions/oscal.upstreamResponsibility" + } + }, "responsibilityPosture": { "description": "ResponsibilityPosture is the live, evidence-backed posture (satisfied /\nnot-satisfied / unknown) per upstream responsibility uuid under this link's\nprovided-uuid — computed via filter_responsibilities (BCH-1339), independent of\nSatisfaction/OutstandingResponsibilities above (which reflect what was attested at\nsubscribe time, not current evidence).", "type": "object", @@ -40890,6 +41126,27 @@ const docTemplate = `{ } } }, + "oscal.responsibilityFilterResponse": { + "type": "object", + "properties": { + "controlId": { + "type": "string" + }, + "controlLinkCreated": { + "description": "ControlLinkCreated reports whether the attachment created (or co-owns) the\nfilter→control link — detaching such an attachment may also unlink the control.", + "type": "boolean" + }, + "filterId": { + "type": "string" + }, + "filterName": { + "type": "string" + }, + "responsibilityUuid": { + "type": "string" + } + } + }, "oscal.rule": { "type": "object", "properties": { @@ -40922,20 +41179,6 @@ const docTemplate = `{ } } }, - "oscal.subscribeLeveragedAuthorizationRequest": { - "type": "object", - "properties": { - "dateAuthorized": { - "type": "string" - }, - "partyUuid": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, "oscal.subscribeRequest": { "type": "object", "properties": { @@ -40947,9 +41190,6 @@ const docTemplate = `{ "items": { "$ref": "#/definitions/oscal.subscribeItemRequest" } - }, - "leveragedAuthorization": { - "$ref": "#/definitions/oscal.subscribeLeveragedAuthorizationRequest" } } }, @@ -46547,6 +46787,29 @@ const docTemplate = `{ } } }, + "relational.FilterResponsibility": { + "type": "object", + "properties": { + "controlId": { + "description": "Provenance of the filter→control association this attachment created. Attaching a\nfilter to a responsibility also links it to the responsibility's control (so the\nexisting control-level compliance surfaces include it); detaching must undo ONLY a\nlink the responsibility machinery itself created, never one a user made\nindependently via POST/PUT /api/filters. Semantics:\n - attach, link absent → append filter_controls, ControlLinkCreated=true\n - attach, link present and some existing row on (filter_id, control_id) has\n ControlLinkCreated=true → the link is responsibility-owned; the new row\n co-owns it (true), so the last detacher removes it\n - attach, link present otherwise → independently created, ControlLinkCreated=false\n - detach → remove the filter_controls row iff this row had\n ControlLinkCreated=true AND no other filter_responsibilities row on the same\n (filter_id, control_id) remains\nControl's primary key is composite (catalog_id, id) — both parts are recorded so\ndetach can delete the exact association row.\n\nRows are create/delete-only. Never Save()/Updates() this model: with a composite\nprimary key GORM's upsert semantics on partial keys are a footgun.", + "type": "string" + }, + "controlLinkCreated": { + "description": "ControlLinkCreated reports whether this attachment created (or co-owns) the\nfilter→control link — see the provenance comment above.", + "type": "boolean" + }, + "filterId": { + "type": "string" + }, + "responsibilityUuid": { + "description": "ResponsibilityUUID and SSPID also carry idx_filter_responsibilities_ssp_lookup, a\ncomposite index leading with ssp_id — the (filter_id, responsibility_uuid, ssp_id)\nprimary key leads with filter_id, which doesn't serve ResponsibilityPosture's\n\"WHERE ssp_id = ? AND responsibility_uuid IN ?\" lookup (profile_compliance.go).", + "type": "string" + }, + "sspId": { + "type": "string" + } + } + }, "relational.Hash": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 28234382..c05c2a8b 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -4383,6 +4383,126 @@ } } }, + "/filters/{id}/responsibilities": { + "post": { + "description": "Associates this filter with an upstream responsibility the given downstream\nSSP inherits (BCH-1339's filter_responsibilities), so the responsibility's\nposture is computed live from the filter's evidence. When controlId is given,\nthe filter is also linked to that control (so control-level compliance\nsurfaces include it) with provenance recorded: detaching removes the control\nlink only if this attach created it.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Filters" + ], + "summary": "Attach a filter to an inherited responsibility", + "parameters": [ + { + "type": "string", + "description": "Filter ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Responsibility to attach", + "name": "attachment", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.attachFilterResponsibilityRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-relational_FilterResponsibility" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + } + } + }, + "/filters/{id}/responsibilities/{responsibilityUuid}": { + "delete": { + "description": "Removes the filter↔responsibility association for the given downstream SSP\n(sspId query param — the association's key is the full triple). The filter's\ncontrol link is removed only if it was created by a responsibility attachment\nand no other attachment on this filter still claims that control.", + "tags": [ + "Filters" + ], + "summary": "Detach a filter from an inherited responsibility", + "parameters": [ + { + "type": "string", + "description": "Filter ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Responsibility UUID", + "name": "responsibilityUuid", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Downstream SSP ID", + "name": "sspId", + "in": "query", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "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" + } + } + } + } + }, "/lineage/nodes/{key}/children": { "get": { "description": "Returns one level of children for a node. Key is a composite like catalog:\u003cuuid\u003e, group:\u003ccatalogId\u003e/\u003cgroupId\u003e, control:\u003ccatalogId\u003e/\u003ccontrolId\u003e, linkcat:\u003cchildCatalogId\u003e/\u003crelationship\u003e/\u003cparentCatalogId\u003e/\u003cparentControlId\u003e, risk:\u003criskId\u003e, evidence:\u003cstreamUuid\u003e. A control expands to synthetic linkcat catalog nodes (its implementing/documenting controls grouped by their catalog) plus its directly-linked risks; a linkcat node expands to that group's controls; a risk expands to its latest evidence per linked stream; evidence is a leaf.", @@ -16317,7 +16437,7 @@ }, "/oscal/ssp-export-offerings/{id}/subscribe": { "post": { - "description": "Records, on the downstream SSP named in the request body, an OSCAL\ninherited-control-implementation and (optionally) satisfied-responsibility\nentries per chosen offering item, plus one leveraged-authorization for the\nwhole request — all in a single atomic write. Never checks ssp:read on the\nupstream SSP: the trust boundary is that subscribing to a published offering\nonly requires ssp-export-offering:subscribe on the offering and ssp:update on\nthe downstream SSP.\n\nEvery subscribed item must be statement-anchored: a legacy offering item with\nno statement-id is rejected with 422. The materialized downstream tree is\nalways requirement -\u003e statement -\u003e by-component -\u003e inherited + satisfied, and is\nreported back in meta.created (each row flagged created:true when inserted,\nfalse when an existing row was reused) so the caller can render newly-created\nrequirements without re-walking the SSP. The data payload is unchanged.", + "description": "Records, on the downstream SSP named in the request body, an OSCAL\ninherited-control-implementation and (optionally) satisfied-responsibility\nentries per chosen offering item — all in a single atomic write. Never checks\nssp:read on the upstream SSP: the trust boundary is that subscribing to a\npublished offering only requires ssp-export-offering:subscribe on the offering\nand ssp:update on the downstream SSP.\n\nNo leveraged-authorization is created: sharing is decoupled from an Authority\nto Operate. A Leveraged Authorization is an independent, human-authored record\nof the downstream's real ATO and never gates importing.\n\nEvery subscribed item must be statement-anchored: a legacy offering item with\nno statement-id is rejected with 422. The materialized downstream tree is\nalways requirement -\u003e statement -\u003e by-component -\u003e inherited + satisfied, and is\nreported back in meta.created (each row flagged created:true when inserted,\nfalse when an existing row was reused) so the caller can render newly-created\nrequirements without re-walking the SSP. The data payload is unchanged.", "consumes": [ "application/json" ], @@ -23115,6 +23235,58 @@ ] } }, + "/oscal/system-security-plans/{id}/responsibility-filters": { + "get": { + "description": "Every filter attached to an upstream responsibility this SSP inherits\n(BCH-1339's filter_responsibilities), with filter names resolved — one call\nfor the whole SSP so per-responsibility evidence bars need no N+1. Writes\nare on the filters API (POST/DELETE /filters/:id/responsibilities).", + "produces": [ + "application/json" + ], + "tags": [ + "SSP Export Offerings" + ], + "summary": "List a downstream SSP's filter↔responsibility attachments", + "parameters": [ + { + "type": "string", + "description": "Downstream SSP ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-oscal_responsibilityFilterResponse" + } + }, + "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": [] + } + ] + } + }, "/oscal/system-security-plans/{id}/shared-responsibility": { "get": { "description": "A flat, statement-keyed projection of this SSP's shared-responsibility posture:\nwhat it exports (with the provided capabilities and the responsibilities it\npushes onto consumers, and whether each is offered on a *published* offering —\na draft/deprecated/revoked one does not count, since no downstream can reach\nit), what it inherits\nfrom upstreams (with live-recomputed satisfaction and link status), how it\ndischarges upstream responsibilities, and any legacy requirement-anchored\nby-components still to be migrated.\n\nThe inherits arm reuses the same batched projection GET /leveraged-controls\nserves, so the two can never disagree about satisfaction. Optionally filter the\nwhole rollup to one control with controlId.", @@ -35335,6 +35507,19 @@ "meta": {} } }, + "handler.GenericDataListResponse-oscal_responsibilityFilterResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/oscal.responsibilityFilterResponse" + } + }, + "meta": {} + } + }, "handler.GenericDataListResponse-poam_PoamItemControlLink": { "type": "object", "properties": { @@ -36839,6 +37024,19 @@ } } }, + "handler.GenericDataResponse-relational_FilterResponsibility": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/relational.FilterResponsibility" + } + ] + } + } + }, "handler.GenericDataResponse-relational_SSOGroupMapping": { "type": "object", "properties": { @@ -37485,6 +37683,29 @@ } } }, + "handler.attachFilterResponsibilityRequest": { + "type": "object", + "required": [ + "responsibilityUuid", + "sspId" + ], + "properties": { + "controlId": { + "description": "Optional control to also link the filter to (so control-level compliance\nsurfaces include it). The link's provenance is recorded: detaching the\nresponsibility removes the control link only if this attach created it.", + "type": "string" + }, + "responsibilityUuid": { + "description": "The upstream ControlImplementationResponsibility this filter should evidence.", + "type": "string", + "format": "uuid" + }, + "sspId": { + "description": "The DOWNSTREAM SSP that inherits the responsibility (matched against\nssp_leverage_links.downstream_ssp_id).", + "type": "string", + "format": "uuid" + } + } + }, "handler.availableNotificationProviderResponse": { "type": "object", "properties": { @@ -40819,6 +41040,10 @@ "oscal.leveragedControlResponse": { "type": "object", "properties": { + "byComponentId": { + "description": "ByComponentId is the downstream by-component the link's inherited entry hangs off —\nthe anchor for authoring satisfied entries against this link's responsibilities.\nNil only if the inherited row was deleted out from under the link.", + "type": "string" + }, "controlId": { "type": "string" }, @@ -40838,6 +41063,17 @@ "$ref": "#/definitions/oscal.upstreamResponsibility" } }, + "providedUuid": { + "description": "ProvidedUuid is the upstream provided capability this link consumes — the key the\ndownstream's inherited entries reference.", + "type": "string" + }, + "responsibilities": { + "description": "Responsibilities is the FULL upstream responsibility set under this link (uuid +\ndescription). Downstream surfaces label every responsibility from this — including ones\nalready satisfied — so the responsibility's own text is never replaced by a satisfied\nentry's \"how we handle this\" wording. OutstandingResponsibilities is the not-yet-covered\nsubset (unchanged).", + "type": "array", + "items": { + "$ref": "#/definitions/oscal.upstreamResponsibility" + } + }, "responsibilityPosture": { "description": "ResponsibilityPosture is the live, evidence-backed posture (satisfied /\nnot-satisfied / unknown) per upstream responsibility uuid under this link's\nprovided-uuid — computed via filter_responsibilities (BCH-1339), independent of\nSatisfaction/OutstandingResponsibilities above (which reflect what was attested at\nsubscribe time, not current evidence).", "type": "object", @@ -40884,6 +41120,27 @@ } } }, + "oscal.responsibilityFilterResponse": { + "type": "object", + "properties": { + "controlId": { + "type": "string" + }, + "controlLinkCreated": { + "description": "ControlLinkCreated reports whether the attachment created (or co-owns) the\nfilter→control link — detaching such an attachment may also unlink the control.", + "type": "boolean" + }, + "filterId": { + "type": "string" + }, + "filterName": { + "type": "string" + }, + "responsibilityUuid": { + "type": "string" + } + } + }, "oscal.rule": { "type": "object", "properties": { @@ -40916,20 +41173,6 @@ } } }, - "oscal.subscribeLeveragedAuthorizationRequest": { - "type": "object", - "properties": { - "dateAuthorized": { - "type": "string" - }, - "partyUuid": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, "oscal.subscribeRequest": { "type": "object", "properties": { @@ -40941,9 +41184,6 @@ "items": { "$ref": "#/definitions/oscal.subscribeItemRequest" } - }, - "leveragedAuthorization": { - "$ref": "#/definitions/oscal.subscribeLeveragedAuthorizationRequest" } } }, @@ -46541,6 +46781,29 @@ } } }, + "relational.FilterResponsibility": { + "type": "object", + "properties": { + "controlId": { + "description": "Provenance of the filter→control association this attachment created. Attaching a\nfilter to a responsibility also links it to the responsibility's control (so the\nexisting control-level compliance surfaces include it); detaching must undo ONLY a\nlink the responsibility machinery itself created, never one a user made\nindependently via POST/PUT /api/filters. Semantics:\n - attach, link absent → append filter_controls, ControlLinkCreated=true\n - attach, link present and some existing row on (filter_id, control_id) has\n ControlLinkCreated=true → the link is responsibility-owned; the new row\n co-owns it (true), so the last detacher removes it\n - attach, link present otherwise → independently created, ControlLinkCreated=false\n - detach → remove the filter_controls row iff this row had\n ControlLinkCreated=true AND no other filter_responsibilities row on the same\n (filter_id, control_id) remains\nControl's primary key is composite (catalog_id, id) — both parts are recorded so\ndetach can delete the exact association row.\n\nRows are create/delete-only. Never Save()/Updates() this model: with a composite\nprimary key GORM's upsert semantics on partial keys are a footgun.", + "type": "string" + }, + "controlLinkCreated": { + "description": "ControlLinkCreated reports whether this attachment created (or co-owns) the\nfilter→control link — see the provenance comment above.", + "type": "boolean" + }, + "filterId": { + "type": "string" + }, + "responsibilityUuid": { + "description": "ResponsibilityUUID and SSPID also carry idx_filter_responsibilities_ssp_lookup, a\ncomposite index leading with ssp_id — the (filter_id, responsibility_uuid, ssp_id)\nprimary key leads with filter_id, which doesn't serve ResponsibilityPosture's\n\"WHERE ssp_id = ? AND responsibility_uuid IN ?\" lookup (profile_compliance.go).", + "type": "string" + }, + "sspId": { + "type": "string" + } + } + }, "relational.Hash": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 55114828..e9a635d0 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -790,6 +790,15 @@ definitions: type: array meta: {} type: object + handler.GenericDataListResponse-oscal_responsibilityFilterResponse: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/oscal.responsibilityFilterResponse' + type: array + meta: {} + type: object handler.GenericDataListResponse-oscalTypes_1_1_3_AssessmentPlan: properties: data: @@ -1958,6 +1967,13 @@ definitions: - $ref: '#/definitions/relational.Filter' description: Wrapped response data type: object + handler.GenericDataResponse-relational_FilterResponsibility: + properties: + data: + allOf: + - $ref: '#/definitions/relational.FilterResponsibility' + description: Wrapped response data + type: object handler.GenericDataResponse-relational_SSOGroupMapping: properties: data: @@ -2382,6 +2398,29 @@ definitions: subject-id: type: string type: object + handler.attachFilterResponsibilityRequest: + properties: + controlId: + description: |- + Optional control to also link the filter to (so control-level compliance + surfaces include it). The link's provenance is recorded: detaching the + responsibility removes the control link only if this attach created it. + type: string + responsibilityUuid: + description: The upstream ControlImplementationResponsibility this filter + should evidence. + format: uuid + type: string + sspId: + description: |- + The DOWNSTREAM SSP that inherits the responsibility (matched against + ssp_leverage_links.downstream_ssp_id). + format: uuid + type: string + required: + - responsibilityUuid + - sspId + type: object handler.availableNotificationProviderResponse: properties: description: @@ -4631,6 +4670,12 @@ definitions: type: object oscal.leveragedControlResponse: properties: + byComponentId: + description: |- + ByComponentId is the downstream by-component the link's inherited entry hangs off — + the anchor for authoring satisfied entries against this link's responsibilities. + Nil only if the inherited row was deleted out from under the link. + type: string controlId: type: string driftRiskId: @@ -4648,6 +4693,21 @@ definitions: items: $ref: '#/definitions/oscal.upstreamResponsibility' type: array + providedUuid: + description: |- + ProvidedUuid is the upstream provided capability this link consumes — the key the + downstream's inherited entries reference. + type: string + responsibilities: + description: |- + Responsibilities is the FULL upstream responsibility set under this link (uuid + + description). Downstream surfaces label every responsibility from this — including ones + already satisfied — so the responsibility's own text is never replaced by a satisfied + entry's "how we handle this" wording. OutstandingResponsibilities is the not-yet-covered + subset (unchanged). + items: + $ref: '#/definitions/oscal.upstreamResponsibility' + type: array responsibilityPosture: additionalProperties: type: string @@ -4683,6 +4743,22 @@ definitions: title: type: string type: object + oscal.responsibilityFilterResponse: + properties: + controlId: + type: string + controlLinkCreated: + description: |- + ControlLinkCreated reports whether the attachment created (or co-owns) the + filter→control link — detaching such an attachment may also unlink the control. + type: boolean + filterId: + type: string + filterName: + type: string + responsibilityUuid: + type: string + type: object oscal.rule: properties: name: @@ -4704,15 +4780,6 @@ definitions: type: string type: array type: object - oscal.subscribeLeveragedAuthorizationRequest: - properties: - dateAuthorized: - type: string - partyUuid: - type: string - title: - type: string - type: object oscal.subscribeRequest: properties: downstreamSspId: @@ -4721,8 +4788,6 @@ definitions: items: $ref: '#/definitions/oscal.subscribeItemRequest' type: array - leveragedAuthorization: - $ref: '#/definitions/oscal.subscribeLeveragedAuthorizationRequest' type: object oscal.suggestionEventActor: properties: @@ -8427,6 +8492,46 @@ definitions: sspId: type: string type: object + relational.FilterResponsibility: + properties: + controlId: + description: |- + Provenance of the filter→control association this attachment created. Attaching a + filter to a responsibility also links it to the responsibility's control (so the + existing control-level compliance surfaces include it); detaching must undo ONLY a + link the responsibility machinery itself created, never one a user made + independently via POST/PUT /api/filters. Semantics: + - attach, link absent → append filter_controls, ControlLinkCreated=true + - attach, link present and some existing row on (filter_id, control_id) has + ControlLinkCreated=true → the link is responsibility-owned; the new row + co-owns it (true), so the last detacher removes it + - attach, link present otherwise → independently created, ControlLinkCreated=false + - detach → remove the filter_controls row iff this row had + ControlLinkCreated=true AND no other filter_responsibilities row on the same + (filter_id, control_id) remains + Control's primary key is composite (catalog_id, id) — both parts are recorded so + detach can delete the exact association row. + + Rows are create/delete-only. Never Save()/Updates() this model: with a composite + primary key GORM's upsert semantics on partial keys are a footgun. + type: string + controlLinkCreated: + description: |- + ControlLinkCreated reports whether this attachment created (or co-owns) the + filter→control link — see the provenance comment above. + type: boolean + filterId: + type: string + responsibilityUuid: + description: |- + ResponsibilityUUID and SSPID also carry idx_filter_responsibilities_ssp_lookup, a + composite index leading with ssp_id — the (filter_id, responsibility_uuid, ssp_id) + primary key leads with filter_id, which doesn't serve ResponsibilityPosture's + "WHERE ssp_id = ? AND responsibility_uuid IN ?" lookup (profile_compliance.go). + type: string + sspId: + type: string + type: object relational.Hash: properties: algorithm: @@ -14564,6 +14669,96 @@ paths: summary: Update a filter tags: - Filters + /filters/{id}/responsibilities: + post: + consumes: + - application/json + description: |- + Associates this filter with an upstream responsibility the given downstream + SSP inherits (BCH-1339's filter_responsibilities), so the responsibility's + posture is computed live from the filter's evidence. When controlId is given, + the filter is also linked to that control (so control-level compliance + surfaces include it) with provenance recorded: detaching removes the control + link only if this attach created it. + parameters: + - description: Filter ID + in: path + name: id + required: true + type: string + - description: Responsibility to attach + in: body + name: attachment + required: true + schema: + $ref: '#/definitions/handler.attachFilterResponsibilityRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handler.GenericDataResponse-relational_FilterResponsibility' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "409": + description: Conflict + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + summary: Attach a filter to an inherited responsibility + tags: + - Filters + /filters/{id}/responsibilities/{responsibilityUuid}: + delete: + description: |- + Removes the filter↔responsibility association for the given downstream SSP + (sspId query param — the association's key is the full triple). The filter's + control link is removed only if it was created by a responsibility attachment + and no other attachment on this filter still claims that control. + parameters: + - description: Filter ID + in: path + name: id + required: true + type: string + - description: Responsibility UUID + in: path + name: responsibilityUuid + required: true + type: string + - description: Downstream SSP ID + in: query + name: sspId + required: true + type: string + responses: + "204": + description: No Content + "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' + summary: Detach a filter from an inherited responsibility + tags: + - Filters /filters/import: post: consumes: @@ -22353,11 +22548,14 @@ paths: description: |- Records, on the downstream SSP named in the request body, an OSCAL inherited-control-implementation and (optionally) satisfied-responsibility - entries per chosen offering item, plus one leveraged-authorization for the - whole request — all in a single atomic write. Never checks ssp:read on the - upstream SSP: the trust boundary is that subscribing to a published offering - only requires ssp-export-offering:subscribe on the offering and ssp:update on - the downstream SSP. + entries per chosen offering item — all in a single atomic write. Never checks + ssp:read on the upstream SSP: the trust boundary is that subscribing to a + published offering only requires ssp-export-offering:subscribe on the offering + and ssp:update on the downstream SSP. + + No leveraged-authorization is created: sharing is decoupled from an Authority + to Operate. A Leveraged Authorization is an independent, human-authored record + of the downstream's real ATO and never gates importing. Every subscribed item must be statement-anchored: a legacy offering item with no statement-id is rejected with 422. The materialized downstream tree is @@ -27083,6 +27281,43 @@ paths: summary: Remove a Profile binding from an SSP tags: - System Security Plans + /oscal/system-security-plans/{id}/responsibility-filters: + get: + description: |- + Every filter attached to an upstream responsibility this SSP inherits + (BCH-1339's filter_responsibilities), with filter names resolved — one call + for the whole SSP so per-responsibility evidence bars need no N+1. Writes + are on the filters API (POST/DELETE /filters/:id/responsibilities). + parameters: + - description: Downstream SSP ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-oscal_responsibilityFilterResponse' + "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: List a downstream SSP's filter↔responsibility attachments + tags: + - SSP Export Offerings /oscal/system-security-plans/{id}/shared-responsibility: get: description: |- diff --git a/internal/api/handler/filter.go b/internal/api/handler/filter.go index 01b9d826..7cc00a45 100644 --- a/internal/api/handler/filter.go +++ b/internal/api/handler/filter.go @@ -39,6 +39,10 @@ func (h *FilterHandler) Register(api *echo.Group, guard middleware.ResourceGuard api.DELETE("/:id", h.Delete, guard.Delete()) // Bulk import creates filters → create. api.POST("/import", h.ImportFilters, guard.Create()) + // Responsibility attachments mutate the filter's associations, exactly like the + // control links PUT /:id manages — same guard. + api.POST("/:id/responsibilities", h.AttachResponsibility, guard.Update()) + api.DELETE("/:id/responsibilities/:responsibilityUuid", h.DetachResponsibility, guard.Update()) } type FilterWithAssociations struct { @@ -422,3 +426,219 @@ func (h *FilterHandler) Delete(ctx echo.Context) error { return ctx.NoContent(http.StatusNoContent) } + +// AttachResponsibility godoc +// +// @Summary Attach a filter to an inherited responsibility +// @Description Associates this filter with an upstream responsibility the given downstream +// @Description SSP inherits (BCH-1339's filter_responsibilities), so the responsibility's +// @Description posture is computed live from the filter's evidence. When controlId is given, +// @Description the filter is also linked to that control (so control-level compliance +// @Description surfaces include it) with provenance recorded: detaching removes the control +// @Description link only if this attach created it. +// @Tags Filters +// @Accept json +// @Produce json +// @Param id path string true "Filter ID" +// @Param attachment body attachFilterResponsibilityRequest true "Responsibility to attach" +// @Success 201 {object} GenericDataResponse[relational.FilterResponsibility] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 409 {object} api.Error +// @Failure 500 {object} api.Error +// @Router /filters/{id}/responsibilities [post] +func (h *FilterHandler) AttachResponsibility(ctx echo.Context) error { + idParam := ctx.Param("id") + filterID, err := uuid.Parse(idParam) + if err != nil { + h.sugar.Warnw("Invalid filter id", "id", idParam, "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + var req attachFilterResponsibilityRequest + if err := ctx.Bind(&req); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := ctx.Validate(req); err != nil { + return ctx.JSON(http.StatusBadRequest, api.Validator(err)) + } + + var filter relational.Filter + if err := h.db.Preload("Controls").First(&filter, "id = ?", filterID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(err)) + } + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + if err := h.db.First(&relational.SystemSecurityPlan{}, "id = ?", req.SSPID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("system security plan %s not found", req.SSPID))) + } + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + // The responsibility must be one the downstream SSP actually inherits: posture reads + // tolerate junk rows, but writes are validated. This replicates the + // bulkResolveUpstreamResponsibilities join (handler/oscal cannot be imported from + // here — it imports this package for the response envelopes). + var inherits int64 + if err := h.db.Table("ssp_leverage_links AS l"). + Joins("JOIN provided_control_implementations p ON p.id = l.provided_uuid"). + Joins("JOIN control_implementation_responsibilities r ON r.provided_uuid = p.id AND r.export_id = p.export_id"). + Where("l.downstream_ssp_id = ? AND r.id = ?", req.SSPID, req.ResponsibilityUUID). + Count(&inherits).Error; err != nil { + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + if inherits == 0 { + return ctx.JSON(http.StatusBadRequest, api.NewError( + fmt.Errorf("responsibilityUuid %s is not a responsibility this SSP inherits", req.ResponsibilityUUID))) + } + + var existing relational.FilterResponsibility + err = h.db.First(&existing, + "filter_id = ? AND responsibility_uuid = ? AND ssp_id = ?", + filterID, req.ResponsibilityUUID, req.SSPID, + ).Error + if err == nil { + return ctx.JSON(http.StatusConflict, api.NewError( + fmt.Errorf("filter is already attached to this responsibility for this SSP"))) + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + row := relational.FilterResponsibility{ + FilterID: filterID, + ResponsibilityUUID: req.ResponsibilityUUID, + SSPID: req.SSPID, + } + + var controlToLink *relational.Control + if req.ControlID != nil && strings.TrimSpace(*req.ControlID) != "" { + // The caller passes the SSP's casing of the control id, which is not reliably + // the catalog's — match case-insensitively, like the leverage joins do. + var control relational.Control + if err := h.db.First(&control, "LOWER(id) = LOWER(?)", strings.TrimSpace(*req.ControlID)).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("control %q not found", *req.ControlID))) + } + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + row.ControlID = &control.ID + row.ControlCatalogID = &control.CatalogID + + linkExists := false + for _, linked := range filter.Controls { + if linked.CatalogID == control.CatalogID && linked.ID == control.ID { + linkExists = true + break + } + } + if !linkExists { + controlToLink = &control + row.ControlLinkCreated = true + } else { + // Co-ownership: if the existing link was itself created by a responsibility + // attachment, this row claims it too, so the LAST detacher removes it. An + // independently created link (POST/PUT /filters) is never owned. + var owners int64 + if err := h.db.Model(&relational.FilterResponsibility{}). + Where("filter_id = ? AND control_id = ? AND control_link_created = true", filterID, control.ID). + Count(&owners).Error; err != nil { + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + row.ControlLinkCreated = owners > 0 + } + } + + if err := h.db.Transaction(func(tx *gorm.DB) error { + if controlToLink != nil { + if err := tx.Model(&filter).Association("Controls").Append(controlToLink); err != nil { + return err + } + } + return tx.Create(&row).Error + }); err != nil { + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.JSON(http.StatusCreated, GenericDataResponse[relational.FilterResponsibility]{Data: row}) +} + +// DetachResponsibility godoc +// +// @Summary Detach a filter from an inherited responsibility +// @Description Removes the filter↔responsibility association for the given downstream SSP +// @Description (sspId query param — the association's key is the full triple). The filter's +// @Description control link is removed only if it was created by a responsibility attachment +// @Description and no other attachment on this filter still claims that control. +// @Tags Filters +// @Param id path string true "Filter ID" +// @Param responsibilityUuid path string true "Responsibility UUID" +// @Param sspId query string true "Downstream SSP ID" +// @Success 204 "No Content" +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Router /filters/{id}/responsibilities/{responsibilityUuid} [delete] +func (h *FilterHandler) DetachResponsibility(ctx echo.Context) error { + filterID, err := uuid.Parse(ctx.Param("id")) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + responsibilityUUID, err := uuid.Parse(ctx.Param("responsibilityUuid")) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + sspID, err := uuid.Parse(strings.TrimSpace(ctx.QueryParam("sspId"))) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError( + fmt.Errorf("sspId query parameter is required (the association is keyed per downstream SSP): %w", err))) + } + + var row relational.FilterResponsibility + if err := h.db.First(&row, + "filter_id = ? AND responsibility_uuid = ? AND ssp_id = ?", + filterID, responsibilityUUID, sspID, + ).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(err)) + } + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + if err := h.db.Transaction(func(tx *gorm.DB) error { + if err := tx. + Where("filter_id = ? AND responsibility_uuid = ? AND ssp_id = ?", filterID, responsibilityUUID, sspID). + Delete(&relational.FilterResponsibility{}).Error; err != nil { + return err + } + + // Unwind the control link only when this row owned it and nobody else claims it — + // see the provenance comment on relational.FilterResponsibility. + if row.ControlLinkCreated && row.ControlID != nil && row.ControlCatalogID != nil { + var claims int64 + if err := tx.Model(&relational.FilterResponsibility{}). + Where("filter_id = ? AND control_id = ?", filterID, *row.ControlID). + Count(&claims).Error; err != nil { + return err + } + if claims == 0 { + var filter relational.Filter + if err := tx.First(&filter, "id = ?", filterID).Error; err != nil { + return err + } + control := relational.Control{CatalogID: *row.ControlCatalogID, ID: *row.ControlID} + if err := tx.Model(&filter).Association("Controls").Delete(&control); err != nil { + return err + } + } + } + return nil + }); err != nil { + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.NoContent(http.StatusNoContent) +} diff --git a/internal/api/handler/oscal/filter_responsibilities_test.go b/internal/api/handler/oscal/filter_responsibilities_test.go new file mode 100644 index 00000000..7525c8a4 --- /dev/null +++ b/internal/api/handler/oscal/filter_responsibilities_test.go @@ -0,0 +1,349 @@ +//go:build integration + +package oscal + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/suite" + "go.uber.org/zap" + "gorm.io/datatypes" + + "github.com/compliance-framework/api/internal/api" + "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/converters/labelfilter" + "github.com/compliance-framework/api/internal/service/relational" + evidencesvc "github.com/compliance-framework/api/internal/service/relational/evidence" + "github.com/compliance-framework/api/internal/tests" +) + +// FilterResponsibilityIntegrationSuite exercises the filter↔responsibility attach/detach +// endpoints (the write side of BCH-1339's filter_responsibilities), their control-link +// ownership semantics, and the responsibility-filters projection — end to end through the +// real subscribe pipeline, so the "responsibility this SSP inherits" validation runs +// against genuinely materialized leverage links. +type FilterResponsibilityIntegrationSuite struct { + tests.IntegrationTestSuite +} + +func TestFilterResponsibilityIntegrationSuite(t *testing.T) { + suite.Run(t, new(FilterResponsibilityIntegrationSuite)) +} + +// newCedarServerWithFilters is newCedarServer plus the /api/filters mount — the attach and +// detach routes live on the filters handler (package handler), which the oscal-only test +// server doesn't register. +func newCedarServerWithFilters(suite *tests.IntegrationTestSuite) *api.Server { + logConf := zap.NewDevelopmentConfig() + logConf.Level = zap.NewAtomicLevelAt(zap.ErrorLevel) + logger, _ := logConf.Build() + + pdp, err := authz.Open(authz.Options{Driver: authz.DriverCedar}, authz.Deps{ + DB: suite.DB, + Config: suite.Config, + Logger: logger.Sugar(), + }) + suite.Require().NoError(err) + pep := middleware.NewPEP(pdp, authz.FailClosed, logger.Sugar()) + + metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) + server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) + evidenceSvc := evidencesvc.NewEvidenceService(suite.DB, logger.Sugar(), suite.Config, nil) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, evidenceSvc, nil, pep) + + filterHandler := handler.NewFilterHandler(logger.Sugar(), suite.DB) + filterGroup := server.API().Group("/filters") + filterGroup.Use(middleware.JWTMiddleware(suite.Config.JWTPublicKey)) + filterHandler.Register(filterGroup, pep.For(authz.ResourceFilter)) + + return server +} + +type filterResponsibilityFixture struct { + server *api.Server + contributorToken string + + downstreamSSPID string + providedUUID string + respAUUID string + respBUUID string +} + +// setupFilterResponsibilityFixture materializes a real leverage link: upstream SSP with a +// leverageable AC-1 capability (two responsibilities), published offering + item, downstream +// SSP, subscribe with nothing satisfied — plus a catalog control "ac-1" for the control-link +// side of attach. +func (suite *FilterResponsibilityIntegrationSuite) setupFilterResponsibilityFixture() filterResponsibilityFixture { + suite.Require().NoError(suite.Migrator.Refresh()) + + contributorToken := createRoledUser(&suite.IntegrationTestSuite, "contributor-fr@example.com", "contributor") + server := newCedarServerWithFilters(&suite.IntegrationTestSuite) + + upstreamComponentUUID := uuid.New().String() + providedUUID := uuid.New().String() + respAUUID := uuid.New().String() + respBUUID := uuid.New().String() + upstreamSSP := sspWithLeverageableCapability(upstreamComponentUUID, providedUUID, respAUUID, respBUUID) + rec := authedRequest(&suite.IntegrationTestSuite, server, "POST", "/api/oscal/system-security-plans", contributorToken, upstreamSSP) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + + rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", + fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings", upstreamSSP.UUID), + contributorToken, map[string]string{"title": "AC-1 capability"}) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + var createdOffering handler.GenericDataResponse[relational.SSPExportOffering] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &createdOffering)) + offeringID := createdOffering.Data.ID.String() + + rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", + fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings/%s/items", upstreamSSP.UUID, offeringID), + contributorToken, leverageOfferingItemBody(upstreamComponentUUID, providedUUID)) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + var createdItem handler.GenericDataResponse[relational.SSPExportOfferingItem] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &createdItem)) + + rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", + fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings/%s/publish", upstreamSSP.UUID, offeringID), + contributorToken, nil) + suite.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + + downstreamSSP := minimalSSP(uuid.New().String()) + rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", "/api/oscal/system-security-plans", contributorToken, downstreamSSP) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + + rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", + "/api/oscal/ssp-export-offerings/"+offeringID+"/subscribe", contributorToken, + map[string]any{ + "downstreamSspId": downstreamSSP.UUID, + "items": []map[string]any{{"itemId": createdItem.Data.ID.String()}}, + }) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + + // A catalog control for the control-link side. Deliberately lowercase — attach is + // called with the SSP's casing ("AC-1") and must match case-insensitively. + catalog := relational.Catalog{} + suite.Require().NoError(suite.DB.Create(&catalog).Error) + suite.Require().NoError(suite.DB.Create(&relational.Control{CatalogID: *catalog.ID, ID: "ac-1", Title: "Access Control Policy"}).Error) + + return filterResponsibilityFixture{ + server: server, + contributorToken: contributorToken, + downstreamSSPID: downstreamSSP.UUID, + providedUUID: providedUUID, + respAUUID: respAUUID, + respBUUID: respBUUID, + } +} + +func (suite *FilterResponsibilityIntegrationSuite) makeFilter(name string, sspID string) relational.Filter { + parsed := uuid.MustParse(sspID) + f := relational.Filter{ + Name: name, + SSPID: &parsed, + Filter: datatypes.NewJSONType(labelfilter.Filter{ + Scope: &labelfilter.Scope{Condition: &labelfilter.Condition{Label: "env", Operator: "=", Value: "prod"}}, + }), + } + suite.Require().NoError(suite.DB.Create(&f).Error) + return f +} + +func (suite *FilterResponsibilityIntegrationSuite) controlLinkCount(filterID uuid.UUID, controlID string) int64 { + var n int64 + suite.Require().NoError(suite.DB.Table("filter_controls"). + Where("filter_id = ? AND control_id = ?", filterID, controlID). + Count(&n).Error) + return n +} + +func (suite *FilterResponsibilityIntegrationSuite) attach(fx filterResponsibilityFixture, filterID uuid.UUID, respUUID string, controlID *string) *api.Server { + body := map[string]any{"responsibilityUuid": respUUID, "sspId": fx.downstreamSSPID} + if controlID != nil { + body["controlId"] = *controlID + } + rec := authedRequest(&suite.IntegrationTestSuite, fx.server, "POST", + fmt.Sprintf("/api/filters/%s/responsibilities", filterID), fx.contributorToken, body) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + return fx.server +} + +func (suite *FilterResponsibilityIntegrationSuite) TestAttachDetachOwnsControlLink() { + fx := suite.setupFilterResponsibilityFixture() + filter := suite.makeFilter("resp filter", fx.downstreamSSPID) + controlID := "AC-1" // SSP casing; catalog has "ac-1" + + // Attach with the control: creates the link and records ownership. + rec := authedRequest(&suite.IntegrationTestSuite, fx.server, "POST", + fmt.Sprintf("/api/filters/%s/responsibilities", filter.ID), fx.contributorToken, + map[string]any{"responsibilityUuid": fx.respAUUID, "sspId": fx.downstreamSSPID, "controlId": controlID}) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + var created handler.GenericDataResponse[relational.FilterResponsibility] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &created)) + suite.True(created.Data.ControlLinkCreated) + suite.Require().NotNil(created.Data.ControlID) + suite.Equal("ac-1", *created.Data.ControlID, "the catalog's casing is what gets recorded") + suite.Equal(int64(1), suite.controlLinkCount(*filter.ID, "ac-1")) + + // Duplicate triple → 409. + rec = authedRequest(&suite.IntegrationTestSuite, fx.server, "POST", + fmt.Sprintf("/api/filters/%s/responsibilities", filter.ID), fx.contributorToken, + map[string]any{"responsibilityUuid": fx.respAUUID, "sspId": fx.downstreamSSPID}) + suite.Require().Equal(http.StatusConflict, rec.Code, rec.Body.String()) + + // The projection surfaces the attachment with the filter name and provenance. + rec = authedRequest(&suite.IntegrationTestSuite, fx.server, "GET", + fmt.Sprintf("/api/oscal/system-security-plans/%s/responsibility-filters", fx.downstreamSSPID), + fx.contributorToken, nil) + suite.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + var projection handler.GenericDataListResponse[responsibilityFilterResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &projection)) + suite.Require().Len(projection.Data, 1) + suite.Equal(fx.respAUUID, projection.Data[0].ResponsibilityUUID.String()) + suite.Equal("resp filter", projection.Data[0].FilterName) + suite.True(projection.Data[0].ControlLinkCreated) + + // Detach: the association goes, and so does the control link it created. + rec = authedRequest(&suite.IntegrationTestSuite, fx.server, "DELETE", + fmt.Sprintf("/api/filters/%s/responsibilities/%s?sspId=%s", filter.ID, fx.respAUUID, fx.downstreamSSPID), + fx.contributorToken, nil) + suite.Require().Equal(http.StatusNoContent, rec.Code, rec.Body.String()) + suite.Equal(int64(0), suite.controlLinkCount(*filter.ID, "ac-1")) + + var remaining int64 + suite.Require().NoError(suite.DB.Model(&relational.FilterResponsibility{}).Count(&remaining).Error) + suite.Equal(int64(0), remaining) +} + +func (suite *FilterResponsibilityIntegrationSuite) TestDetachKeepsIndependentControlLink() { + fx := suite.setupFilterResponsibilityFixture() + + // The filter is linked to the control independently (as POST/PUT /filters would do). + var control relational.Control + suite.Require().NoError(suite.DB.First(&control, "id = ?", "ac-1").Error) + filter := suite.makeFilter("pre-linked filter", fx.downstreamSSPID) + suite.Require().NoError(suite.DB.Model(&filter).Association("Controls").Append(&control)) + + controlID := "ac-1" + rec := authedRequest(&suite.IntegrationTestSuite, fx.server, "POST", + fmt.Sprintf("/api/filters/%s/responsibilities", filter.ID), fx.contributorToken, + map[string]any{"responsibilityUuid": fx.respAUUID, "sspId": fx.downstreamSSPID, "controlId": controlID}) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + var created handler.GenericDataResponse[relational.FilterResponsibility] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &created)) + suite.False(created.Data.ControlLinkCreated, "a pre-existing, independently created link is never owned") + suite.Equal(int64(1), suite.controlLinkCount(*filter.ID, "ac-1"), "no duplicate link") + + rec = authedRequest(&suite.IntegrationTestSuite, fx.server, "DELETE", + fmt.Sprintf("/api/filters/%s/responsibilities/%s?sspId=%s", filter.ID, fx.respAUUID, fx.downstreamSSPID), + fx.contributorToken, nil) + suite.Require().Equal(http.StatusNoContent, rec.Code, rec.Body.String()) + suite.Equal(int64(1), suite.controlLinkCount(*filter.ID, "ac-1"), "the independent link survives detach") +} + +func (suite *FilterResponsibilityIntegrationSuite) TestCoOwnedControlLinkRemovedByLastDetacher() { + fx := suite.setupFilterResponsibilityFixture() + filter := suite.makeFilter("co-owned filter", fx.downstreamSSPID) + controlID := "ac-1" + + // respA creates the link; respB attaches while it exists and co-owns it. + suite.attach(fx, *filter.ID, fx.respAUUID, &controlID) + rec := authedRequest(&suite.IntegrationTestSuite, fx.server, "POST", + fmt.Sprintf("/api/filters/%s/responsibilities", filter.ID), fx.contributorToken, + map[string]any{"responsibilityUuid": fx.respBUUID, "sspId": fx.downstreamSSPID, "controlId": controlID}) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + var second handler.GenericDataResponse[relational.FilterResponsibility] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &second)) + suite.True(second.Data.ControlLinkCreated, "a responsibility-owned link is co-owned by later attachments") + + // First detach: the other claim keeps the link alive. + rec = authedRequest(&suite.IntegrationTestSuite, fx.server, "DELETE", + fmt.Sprintf("/api/filters/%s/responsibilities/%s?sspId=%s", filter.ID, fx.respAUUID, fx.downstreamSSPID), + fx.contributorToken, nil) + suite.Require().Equal(http.StatusNoContent, rec.Code) + suite.Equal(int64(1), suite.controlLinkCount(*filter.ID, "ac-1")) + + // Last detach: nobody claims the link any more — it goes. + rec = authedRequest(&suite.IntegrationTestSuite, fx.server, "DELETE", + fmt.Sprintf("/api/filters/%s/responsibilities/%s?sspId=%s", filter.ID, fx.respBUUID, fx.downstreamSSPID), + fx.contributorToken, nil) + suite.Require().Equal(http.StatusNoContent, rec.Code) + suite.Equal(int64(0), suite.controlLinkCount(*filter.ID, "ac-1")) +} + +func (suite *FilterResponsibilityIntegrationSuite) TestAttachValidation() { + fx := suite.setupFilterResponsibilityFixture() + filter := suite.makeFilter("validation filter", fx.downstreamSSPID) + + // A responsibility the SSP does not inherit → 400. + rec := authedRequest(&suite.IntegrationTestSuite, fx.server, "POST", + fmt.Sprintf("/api/filters/%s/responsibilities", filter.ID), fx.contributorToken, + map[string]any{"responsibilityUuid": uuid.New().String(), "sspId": fx.downstreamSSPID}) + suite.Require().Equal(http.StatusBadRequest, rec.Code, rec.Body.String()) + + // An unknown downstream SSP → 404. + rec = authedRequest(&suite.IntegrationTestSuite, fx.server, "POST", + fmt.Sprintf("/api/filters/%s/responsibilities", filter.ID), fx.contributorToken, + map[string]any{"responsibilityUuid": fx.respAUUID, "sspId": uuid.New().String()}) + suite.Require().Equal(http.StatusNotFound, rec.Code, rec.Body.String()) + + // Detach without the sspId query param → 400 (the association is keyed per SSP). + rec = authedRequest(&suite.IntegrationTestSuite, fx.server, "DELETE", + fmt.Sprintf("/api/filters/%s/responsibilities/%s", filter.ID, fx.respAUUID), + fx.contributorToken, nil) + suite.Require().Equal(http.StatusBadRequest, rec.Code, rec.Body.String()) + + // Detach an association that doesn't exist → 404. + rec = authedRequest(&suite.IntegrationTestSuite, fx.server, "DELETE", + fmt.Sprintf("/api/filters/%s/responsibilities/%s?sspId=%s", filter.ID, fx.respAUUID, fx.downstreamSSPID), + fx.contributorToken, nil) + suite.Require().Equal(http.StatusNotFound, rec.Code, rec.Body.String()) +} + +func (suite *FilterResponsibilityIntegrationSuite) TestLeveragedControlsExposesAnchorAndPostureKeys() { + fx := suite.setupFilterResponsibilityFixture() + + rec := authedRequest(&suite.IntegrationTestSuite, fx.server, "GET", + fmt.Sprintf("/api/oscal/system-security-plans/%s/leveraged-controls", fx.downstreamSSPID), + fx.contributorToken, nil) + suite.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + var projection handler.GenericDataListResponse[leveragedControlResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &projection)) + suite.Require().Len(projection.Data, 1) + + link := projection.Data[0] + suite.Equal(fx.providedUUID, link.ProvidedUuid.String()) + + // The anchor is the downstream by-component the subscribe materialized — the row the + // UI authors satisfied entries against. + suite.Require().NotNil(link.ByComponentId) + var anchor relational.ByComponent + suite.Require().NoError(suite.DB.First(&anchor, "id = ?", link.ByComponentId).Error) + suite.Require().NotNil(anchor.ParentType) + suite.Equal("statements", *anchor.ParentType) + + // The anchor's component REPRESENTS THE UPSTREAM SYSTEM: named after the upstream SSP, + // typed `system`, identified by the leveraged-system-uuid prop — "Platform exports + // control 1 → the importer gains a Platform component on that implementation". + var anchorComponent relational.SystemComponent + suite.Require().NoError(suite.DB.First(&anchorComponent, "id = ?", anchor.ComponentUUID).Error) + suite.Equal("system", anchorComponent.Type) + suite.Equal("Export Offering AuthZ Test SSP", anchorComponent.Title) + propValues := map[string]string{} + for _, prop := range anchorComponent.Props { + propValues[prop.Name] = prop.Value + } + suite.NotEmpty(propValues[leveragedSystemUUIDProp]) + suite.Equal("external", propValues["implementation-point"]) + + // Posture is keyed by the FULL upstream responsibility set — unknown until a filter + // with matching evidence is attached. + suite.Equal("unknown", link.ResponsibilityPosture[uuid.MustParse(fx.respAUUID)]) + suite.Equal("unknown", link.ResponsibilityPosture[uuid.MustParse(fx.respBUUID)]) +} diff --git a/internal/api/handler/oscal/ssp_export_offering_status_test.go b/internal/api/handler/oscal/ssp_export_offering_status_test.go index 42b66d4d..4757e275 100644 --- a/internal/api/handler/oscal/ssp_export_offering_status_test.go +++ b/internal/api/handler/oscal/ssp_export_offering_status_test.go @@ -48,7 +48,7 @@ func seedPublishedOfferingWithActiveLink(t *testing.T, db *gorm.DB) (*relational link := relational.SSPLeverageLink{ DownstreamSSPID: *downstreamSSP.ID, UpstreamSSPID: *upstreamSSP.ID, OfferingID: *offering.ID, OfferingVersion: 1, - ControlID: "ac-1", ProvidedUUID: *provided.ID, InheritedUUID: uuid.New(), LeveragedAuthUUID: uuid.New(), + ControlID: "ac-1", ProvidedUUID: *provided.ID, InheritedUUID: uuid.New(), Satisfaction: relational.SSPLeverageSatisfactionFull, Status: relational.SSPLeverageStatusActive, } require.NoError(t, db.Create(&link).Error) diff --git a/internal/api/handler/oscal/ssp_export_offerings_unit_test.go b/internal/api/handler/oscal/ssp_export_offerings_unit_test.go index 093b356b..966bce7e 100644 --- a/internal/api/handler/oscal/ssp_export_offerings_unit_test.go +++ b/internal/api/handler/oscal/ssp_export_offerings_unit_test.go @@ -183,7 +183,7 @@ func TestSyncExportOfferingDriftsLeverageLinksOnVersionBump(t *testing.T) { link := relational.SSPLeverageLink{ DownstreamSSPID: uuid.New(), UpstreamSSPID: offering.SSPID, OfferingID: *offering.ID, OfferingVersion: 1, - ControlID: "ac-1", ProvidedUUID: *provided.ID, InheritedUUID: uuid.New(), LeveragedAuthUUID: uuid.New(), + ControlID: "ac-1", ProvidedUUID: *provided.ID, InheritedUUID: uuid.New(), Satisfaction: relational.SSPLeverageSatisfactionFull, Status: relational.SSPLeverageStatusActive, } require.NoError(t, db.Create(&link).Error) diff --git a/internal/api/handler/oscal/ssp_leverage.go b/internal/api/handler/oscal/ssp_leverage.go index f197757a..09e38acb 100644 --- a/internal/api/handler/oscal/ssp_leverage.go +++ b/internal/api/handler/oscal/ssp_leverage.go @@ -11,6 +11,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "github.com/labstack/echo/v4" "go.uber.org/zap" + "gorm.io/datatypes" "gorm.io/gorm" "github.com/compliance-framework/api/internal/api" @@ -21,12 +22,6 @@ import ( "github.com/compliance-framework/api/internal/service/relational/risks" ) -// thisSystemComponentType is the OSCAL convention for a placeholder component -// representing the system itself, used to anchor by-components that aren't tied to any -// specific local component (e.g. purely-inherited capabilities). There's no existing Go -// constant for this — it only appears in JSON test fixtures — so it's declared here. -const thisSystemComponentType = "this-system" - // errDuplicateLeverageLink signals a UNIQUE(downstream_ssp_id, provided_uuid) violation // caught inside the subscribe transaction — a concurrent request racing the same insert // past the pre-check earlier in Subscribe. Mapped to 409, same as the pre-check's own @@ -193,6 +188,7 @@ func (h *SSPLeverageHandler) RegisterReAttest(g *echo.Group, guard middleware.Re // route group, gated by the standard ssp:read. func (h *SSPLeverageHandler) RegisterProjection(g *echo.Group, guard middleware.ResourceGuard) { g.GET("/:id/leveraged-controls", h.LeveragedControls, guard.Read()) + g.GET("/:id/responsibility-filters", h.ResponsibilityFilters, guard.Read()) } // authorizeDownstreamUpdate enforces ssp:update on the downstream SSP identified by @@ -284,24 +280,46 @@ func bulkAllowedOfferings(db *gorm.DB, offeringIDs []uuid.UUID, downstreamSSPID return allowed, nil } -// findOrCreateThisSystemComponent finds the downstream's placeholder "this-system" -// component, creating one if none exists — not every SSP has one, and there's no -// guarantee the subscribing downstream does either. -func findOrCreateThisSystemComponent(tx *gorm.DB, systemImplementationID uuid.UUID) (*relational.SystemComponent, error) { - var existing relational.SystemComponent - err := tx.Where("system_implementation_id = ? AND type = ?", systemImplementationID, thisSystemComponentType). - First(&existing).Error - if err == nil { - return &existing, nil - } - if !errors.Is(err, gorm.ErrRecordNotFound) { +// leveragedSystemUUIDProp is the namespacing-free identity prop stamped on the downstream +// component that represents an upstream system: its value is the upstream SSP's uuid, so +// repeat imports from the same provider reuse one component even if the provider renames +// itself. (Props are queried in Go rather than with a jsonb operator so the helper works +// on the sqlite unit-test driver too.) +const leveragedSystemUUIDProp = "leveraged-system-uuid" + +// findOrCreateLeveragedSystemComponent finds (or creates) the downstream component that +// REPRESENTS THE UPSTREAM SYSTEM an import consumes — "Platform exports control 1 → App +// imports it and gains a Platform component on that implementation". The component is an +// OSCAL `system` component (this-system is reserved for the SSP's own system) named after +// the upstream SSP and flagged implementation-point=external, identified across imports by +// the leveraged-system-uuid prop. +func findOrCreateLeveragedSystemComponent(tx *gorm.DB, systemImplementationID uuid.UUID, upstreamSSPID uuid.UUID, upstreamTitle string) (*relational.SystemComponent, error) { + var candidates []relational.SystemComponent + if err := tx.Where("system_implementation_id = ? AND type = ?", systemImplementationID, "system"). + Find(&candidates).Error; err != nil { return nil, err } + for i := range candidates { + for _, prop := range candidates[i].Props { + if prop.Name == leveragedSystemUUIDProp && prop.Value == upstreamSSPID.String() { + return &candidates[i], nil + } + } + } + title := strings.TrimSpace(upstreamTitle) + if title == "" { + title = "Leveraged system " + upstreamSSPID.String() + } created := relational.SystemComponent{ - Type: thisSystemComponentType, - Title: "This System", - Description: "Placeholder component representing the system itself, used to anchor leveraged/inherited capabilities not tied to a specific local component.", + Type: "system", + Title: title, + Description: fmt.Sprintf("The %s system this plan leverages — capabilities inherited from it anchor here.", title), + Status: datatypes.NewJSONType(relational.SystemComponentStatus{State: "operational"}), + Props: datatypes.NewJSONSlice([]relational.Prop{ + {Name: "implementation-point", Value: "external"}, + {Name: leveragedSystemUUIDProp, Value: upstreamSSPID.String()}, + }), SystemImplementationId: systemImplementationID, } if err := tx.Create(&created).Error; err != nil { @@ -488,33 +506,23 @@ func (t *subscribeCreationTracker) addByComponent(bc *relational.ByComponent, st }) } -type subscribeLeveragedAuthorizationRequest struct { - Title string `json:"title"` - PartyUUID string `json:"partyUuid"` - DateAuthorized string `json:"dateAuthorized,omitempty"` -} - type subscribeItemRequest struct { ItemID string `json:"itemId"` SatisfiedResponsibilityUUIDs []string `json:"satisfiedResponsibilityUuids,omitempty"` } +// subscribeRequest carries no leveraged-authorization: sharing is decoupled from an +// Authority to Operate. An old client may still send a `leveragedAuthorization` object — +// Echo's binder ignores unknown JSON fields, so it is silently dropped rather than an error. type subscribeRequest struct { - DownstreamSSPID string `json:"downstreamSspId"` - LeveragedAuthorization subscribeLeveragedAuthorizationRequest `json:"leveragedAuthorization"` - Items []subscribeItemRequest `json:"items"` + DownstreamSSPID string `json:"downstreamSspId"` + Items []subscribeItemRequest `json:"items"` } func (r subscribeRequest) validate() error { if _, err := uuid.Parse(r.DownstreamSSPID); err != nil { return fmt.Errorf("downstreamSspId must be a valid UUID") } - if r.LeveragedAuthorization.Title == "" { - return fmt.Errorf("leveragedAuthorization.title is required") - } - if _, err := uuid.Parse(r.LeveragedAuthorization.PartyUUID); err != nil { - return fmt.Errorf("leveragedAuthorization.partyUuid must be a valid UUID") - } if len(r.Items) == 0 { return fmt.Errorf("items must not be empty") } @@ -536,11 +544,14 @@ func (r subscribeRequest) validate() error { // @Summary Subscribe to a published export offering // @Description Records, on the downstream SSP named in the request body, an OSCAL // @Description inherited-control-implementation and (optionally) satisfied-responsibility -// @Description entries per chosen offering item, plus one leveraged-authorization for the -// @Description whole request — all in a single atomic write. Never checks ssp:read on the -// @Description upstream SSP: the trust boundary is that subscribing to a published offering -// @Description only requires ssp-export-offering:subscribe on the offering and ssp:update on -// @Description the downstream SSP. +// @Description entries per chosen offering item — all in a single atomic write. Never checks +// @Description ssp:read on the upstream SSP: the trust boundary is that subscribing to a +// @Description published offering only requires ssp-export-offering:subscribe on the offering +// @Description and ssp:update on the downstream SSP. +// @Description +// @Description No leveraged-authorization is created: sharing is decoupled from an Authority +// @Description to Operate. A Leveraged Authorization is an independent, human-authored record +// @Description of the downstream's real ATO and never gates importing. // @Description // @Description Every subscribed item must be statement-anchored: a legacy offering item with // @Description no statement-id is rejected with 422. The materialized downstream tree is @@ -693,13 +704,15 @@ func (h *SSPLeverageHandler) Subscribe(ctx echo.Context) error { } } - dateAuthorized := time.Now() - if req.LeveragedAuthorization.DateAuthorized != "" { - parsed, err := time.Parse(time.RFC3339, req.LeveragedAuthorization.DateAuthorized) - if err != nil { - return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("leveragedAuthorization.dateAuthorized must be RFC3339"))) - } - dateAuthorized = parsed + // The downstream materializes a component NAMED AFTER THE UPSTREAM SYSTEM to anchor + // what it inherits ("Platform exports control 1 → the importer gains a Platform + // component on that implementation"). Only the title is read — a server-side DB read + // of upstream metadata, not an authorized read of the upstream SSP resource, so the + // subscribe trust boundary is unchanged. + var upstream relational.SystemSecurityPlan + if err := h.db.Preload("Metadata").First(&upstream, "id = ?", offering.SSPID).Error; err != nil { + h.sugar.Errorf("Failed to load upstream SSP metadata: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } attestedBy := actorUserID(ctx) now := time.Now() @@ -719,21 +732,12 @@ func (h *SSPLeverageHandler) Subscribe(ctx echo.Context) error { return errDownstreamNotAllowed } - thisSystemComponent, err := findOrCreateThisSystemComponent(tx, *downstream.SystemImplementation.ID) + leveragedComponent, err := findOrCreateLeveragedSystemComponent( + tx, *downstream.SystemImplementation.ID, offering.SSPID, upstream.Metadata.Title) if err != nil { return err } - leveragedAuth := relational.LeveragedAuthorization{ - Title: req.LeveragedAuthorization.Title, - PartyUUID: uuid.MustParse(req.LeveragedAuthorization.PartyUUID), - DateAuthorized: dateAuthorized, - SystemImplementationId: *downstream.SystemImplementation.ID, - } - if err := tx.Create(&leveragedAuth).Error; err != nil { - return err - } - for _, reqItem := range req.Items { item := itemsByID[uuid.MustParse(reqItem.ItemID)] @@ -751,7 +755,7 @@ func (h *SSPLeverageHandler) Subscribe(ctx echo.Context) error { } tracker.addStatement(stmt, stmtCreated) - byComponent, bcCreated, err := findOrCreateByComponent(tx, *stmt.ID, "statements", *thisSystemComponent.ID) + byComponent, bcCreated, err := findOrCreateByComponent(tx, *stmt.ID, "statements", *leveragedComponent.ID) if err != nil { return err } @@ -815,20 +819,20 @@ func (h *SSPLeverageHandler) Subscribe(ctx echo.Context) error { satisfaction, _ := deriveSatisfaction(fullSet, satisfiedSet) + // No LeveragedAuthUUID: sharing is decoupled from an Authority to Operate. link := relational.SSPLeverageLink{ - DownstreamSSPID: downstreamSSPID, - UpstreamSSPID: offering.SSPID, - OfferingID: *offering.ID, - OfferingVersion: offering.Version, - ControlID: item.ControlID, - StatementID: item.StatementID, - ProvidedUUID: item.ProvidedUUID, - InheritedUUID: *inherited.ID, - LeveragedAuthUUID: *leveragedAuth.ID, - Satisfaction: satisfaction, - Status: relational.SSPLeverageStatusActive, - AttestedAt: &now, - AttestedByID: attestedBy, + DownstreamSSPID: downstreamSSPID, + UpstreamSSPID: offering.SSPID, + OfferingID: *offering.ID, + OfferingVersion: offering.Version, + ControlID: item.ControlID, + StatementID: item.StatementID, + ProvidedUUID: item.ProvidedUUID, + InheritedUUID: *inherited.ID, + Satisfaction: satisfaction, + Status: relational.SSPLeverageStatusActive, + AttestedAt: &now, + AttestedByID: attestedBy, } if err := tx.Create(&link).Error; err != nil { if isUniqueViolation(err) { @@ -885,13 +889,26 @@ type leveragedControlInheritedFrom struct { } type leveragedControlResponse struct { - ID uuid.UUID `json:"id"` - ControlID string `json:"controlId"` - StatementID *string `json:"statementId,omitempty"` - InheritedFrom leveragedControlInheritedFrom `json:"inheritedFrom"` - Satisfaction relational.SSPLeverageSatisfaction `json:"satisfaction"` - Status relational.SSPLeverageStatus `json:"status"` - OutstandingResponsibilities []upstreamResponsibility `json:"outstandingResponsibilities"` + ID uuid.UUID `json:"id"` + ControlID string `json:"controlId"` + StatementID *string `json:"statementId,omitempty"` + InheritedFrom leveragedControlInheritedFrom `json:"inheritedFrom"` + // ProvidedUuid is the upstream provided capability this link consumes — the key the + // downstream's inherited entries reference. + ProvidedUuid uuid.UUID `json:"providedUuid"` + // ByComponentId is the downstream by-component the link's inherited entry hangs off — + // the anchor for authoring satisfied entries against this link's responsibilities. + // Nil only if the inherited row was deleted out from under the link. + ByComponentId *uuid.UUID `json:"byComponentId,omitempty"` + Satisfaction relational.SSPLeverageSatisfaction `json:"satisfaction"` + Status relational.SSPLeverageStatus `json:"status"` + // Responsibilities is the FULL upstream responsibility set under this link (uuid + + // description). Downstream surfaces label every responsibility from this — including ones + // already satisfied — so the responsibility's own text is never replaced by a satisfied + // entry's "how we handle this" wording. OutstandingResponsibilities is the not-yet-covered + // subset (unchanged). + Responsibilities []upstreamResponsibility `json:"responsibilities"` + OutstandingResponsibilities []upstreamResponsibility `json:"outstandingResponsibilities"` // ResponsibilityPosture is the live, evidence-backed posture (satisfied / // not-satisfied / unknown) per upstream responsibility uuid under this link's // provided-uuid — computed via filter_responsibilities (BCH-1339), independent of @@ -905,6 +922,87 @@ type leveragedControlResponse struct { DriftRiskID *uuid.UUID `json:"driftRiskId,omitempty"` } +// responsibilityFilterResponse is one filter↔responsibility attachment for a downstream +// SSP (a filter_responsibilities row with the filter's name resolved), keyed the way the +// consumer reads it: by responsibility uuid. +type responsibilityFilterResponse struct { + ResponsibilityUUID uuid.UUID `json:"responsibilityUuid"` + FilterID uuid.UUID `json:"filterId"` + FilterName string `json:"filterName"` + ControlID *string `json:"controlId,omitempty"` + // ControlLinkCreated reports whether the attachment created (or co-owns) the + // filter→control link — detaching such an attachment may also unlink the control. + ControlLinkCreated bool `json:"controlLinkCreated"` +} + +// ResponsibilityFilters godoc +// +// @Summary List a downstream SSP's filter↔responsibility attachments +// @Description Every filter attached to an upstream responsibility this SSP inherits +// @Description (BCH-1339's filter_responsibilities), with filter names resolved — one call +// @Description for the whole SSP so per-responsibility evidence bars need no N+1. Writes +// @Description are on the filters API (POST/DELETE /filters/:id/responsibilities). +// @Tags SSP Export Offerings +// @Produce json +// @Param id path string true "Downstream SSP ID" +// @Success 200 {object} handler.GenericDataListResponse[responsibilityFilterResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/responsibility-filters [get] +func (h *SSPLeverageHandler) ResponsibilityFilters(ctx echo.Context) error { + sspIdParam := ctx.Param("id") + sspID, err := uuid.Parse(sspIdParam) + if err != nil { + h.sugar.Warnw("Invalid SSP id", "id", sspIdParam, "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + if err := h.db.Select("id").First(&relational.SystemSecurityPlan{}, "id = ?", sspID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("SSP not found"))) + } + h.sugar.Errorf("Failed to load SSP: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + var rows []relational.FilterResponsibility + if err := h.db.Where("ssp_id = ?", sspID).Find(&rows).Error; err != nil { + h.sugar.Errorf("Failed to list filter responsibilities: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + nameByFilter := map[uuid.UUID]string{} + if len(rows) > 0 { + filterIDs := make([]uuid.UUID, 0, len(rows)) + for _, row := range rows { + filterIDs = append(filterIDs, row.FilterID) + } + var filters []relational.Filter + if err := h.db.Select("id, name").Where("id IN ?", filterIDs).Find(&filters).Error; err != nil { + h.sugar.Errorf("Failed to resolve filter names: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + for _, filter := range filters { + nameByFilter[*filter.ID] = filter.Name + } + } + + result := make([]responsibilityFilterResponse, 0, len(rows)) + for _, row := range rows { + result = append(result, responsibilityFilterResponse{ + ResponsibilityUUID: row.ResponsibilityUUID, + FilterID: row.FilterID, + FilterName: nameByFilter[row.FilterID], + ControlID: row.ControlID, + ControlLinkCreated: row.ControlLinkCreated, + }) + } + + return ctx.JSON(http.StatusOK, handler.GenericDataListResponse[responsibilityFilterResponse]{Data: result}) +} + // LeveragedControls godoc // // @Summary Project a downstream SSP's leveraged controls @@ -949,6 +1047,11 @@ func (h *SSPLeverageHandler) LeveragedControls(ctx echo.Context) error { result := make([]leveragedControlResponse, 0, len(projection)) for _, p := range projection { + var byComponentID *uuid.UUID + if p.ByComponentID != uuid.Nil { + id := p.ByComponentID + byComponentID = &id + } result = append(result, leveragedControlResponse{ ID: *p.Link.ID, ControlID: p.Link.ControlID, @@ -959,8 +1062,11 @@ func (h *SSPLeverageHandler) LeveragedControls(ctx echo.Context) error { 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, @@ -986,8 +1092,13 @@ type leveragedControlProjection struct { Inherited *relational.InheritedControlImplementation Satisfaction relational.SSPLeverageSatisfaction Outstanding []upstreamResponsibility - Posture map[uuid.UUID]string - DriftRiskID *uuid.UUID + // 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 } // projectLeveragedControls builds the projection for every leverage link on one downstream @@ -1108,14 +1219,15 @@ func projectLeveragedControls(db *gorm.DB, sspID uuid.UUID) ([]leveragedControlP } result = append(result, leveragedControlProjection{ - Link: link, - OfferingTitle: offeringTitleByID[link.OfferingID], - ByComponentID: byComponentID, - Inherited: inherited, - Satisfaction: satisfaction, - Outstanding: outstanding, - Posture: linkPosture, - DriftRiskID: driftRiskID, + Link: link, + OfferingTitle: offeringTitleByID[link.OfferingID], + ByComponentID: byComponentID, + Inherited: inherited, + Satisfaction: satisfaction, + Outstanding: outstanding, + Responsibilities: full, + Posture: linkPosture, + DriftRiskID: driftRiskID, }) } diff --git a/internal/api/handler/oscal/ssp_leverage_allowlist_integration_test.go b/internal/api/handler/oscal/ssp_leverage_allowlist_integration_test.go index 2fb463f2..f2bf8d07 100644 --- a/internal/api/handler/oscal/ssp_leverage_allowlist_integration_test.go +++ b/internal/api/handler/oscal/ssp_leverage_allowlist_integration_test.go @@ -84,10 +84,6 @@ func (suite *SubscribeAllowlistIntegrationSuite) createDownstreamSSP(contributor func (suite *SubscribeAllowlistIntegrationSuite) subscribeRequestBody(downstreamSSPID, itemID string) map[string]any { return map[string]any{ "downstreamSspId": downstreamSSPID, - "leveragedAuthorization": map[string]string{ - "title": "Trust", - "partyUuid": uuid.New().String(), - }, "items": []map[string]any{ {"itemId": itemID, "satisfiedResponsibilityUuids": []string{}}, }, diff --git a/internal/api/handler/oscal/ssp_leverage_drift_enqueue_test.go b/internal/api/handler/oscal/ssp_leverage_drift_enqueue_test.go index 1a6c2686..0ced7c62 100644 --- a/internal/api/handler/oscal/ssp_leverage_drift_enqueue_test.go +++ b/internal/api/handler/oscal/ssp_leverage_drift_enqueue_test.go @@ -77,7 +77,7 @@ func TestPublishEnqueuesLeverageDriftNotificationOnVersionBump(t *testing.T) { require.NoError(t, db.Create(&downstreamSSP).Error) link := relational.SSPLeverageLink{ DownstreamSSPID: *downstreamSSP.ID, UpstreamSSPID: *upstreamSSP.ID, OfferingID: *offering.ID, OfferingVersion: 1, - ControlID: "ac-1", ProvidedUUID: *provided.ID, InheritedUUID: uuid.New(), LeveragedAuthUUID: uuid.New(), + ControlID: "ac-1", ProvidedUUID: *provided.ID, InheritedUUID: uuid.New(), Satisfaction: relational.SSPLeverageSatisfactionFull, Status: relational.SSPLeverageStatusActive, } require.NoError(t, db.Create(&link).Error) @@ -132,7 +132,9 @@ func TestDeleteLeveragedAuthorizationEnqueuesLeverageDriftNotification(t *testin require.NoError(t, db.Create(&offering).Error) link := relational.SSPLeverageLink{ DownstreamSSPID: *downstreamSSP.ID, UpstreamSSPID: *upstreamSSP.ID, OfferingID: *offering.ID, OfferingVersion: 1, - ControlID: "ac-1", ProvidedUUID: *provided.ID, InheritedUUID: uuid.New(), LeveragedAuthUUID: *auth.ID, + // A legacy link that DOES reference a leveraged authorization — the lapse-drift + // path this test exercises keys on it. + ControlID: "ac-1", ProvidedUUID: *provided.ID, InheritedUUID: uuid.New(), LeveragedAuthUUID: auth.ID, Satisfaction: relational.SSPLeverageSatisfactionFull, Status: relational.SSPLeverageStatusActive, } require.NoError(t, db.Create(&link).Error) diff --git a/internal/api/handler/oscal/ssp_leverage_drift_integration_test.go b/internal/api/handler/oscal/ssp_leverage_drift_integration_test.go index 592fdf0c..53a6a019 100644 --- a/internal/api/handler/oscal/ssp_leverage_drift_integration_test.go +++ b/internal/api/handler/oscal/ssp_leverage_drift_integration_test.go @@ -230,14 +230,28 @@ func (suite *LeverageDriftIntegrationSuite) TestDeprecateOfferingDriftsAndNotifi } // TestLeveragedAuthorizationDeleteDriftsAndNotifies covers the "leveraged authorization -// lapsed" trigger. +// lapsed" trigger. Sharing no longer creates an LA, so this exercises a LEGACY link: one +// that references a hand-authored leveraged authorization (as pre-decoupling links did). +// Deleting that LA must still lapse the link. func (suite *LeverageDriftIntegrationSuite) TestLeveragedAuthorizationDeleteDriftsAndNotifies() { fx := seedDriftFixture(suite) link := suite.subscribe(fx) + // Attach a leveraged authorization to the link, simulating a legacy subscription. + var downstreamSysImpl relational.SystemImplementation + suite.Require().NoError(suite.DB.First(&downstreamSysImpl, "system_security_plan_id = ?", fx.downstreamSSPID).Error) + auth := relational.LeveragedAuthorization{ + Title: "Legacy authorization", + PartyUUID: uuid.New(), + SystemImplementationId: *downstreamSysImpl.ID, + } + suite.Require().NoError(suite.DB.Create(&auth).Error) + suite.Require().NoError(suite.DB.Model(&relational.SSPLeverageLink{}). + Where("id = ?", link.ID).Update("leveraged_auth_uuid", auth.ID).Error) + spy := &spyJobEnqueuer{} sspHandler := NewSystemSecurityPlanHandler(zap.NewNop().Sugar(), suite.DB, nil, spy) - ctx, rec := newDeleteLeveragedAuthRequestContext(fx.downstreamSSPID, link.LeveragedAuthUUID) + ctx, rec := newDeleteLeveragedAuthRequestContext(fx.downstreamSSPID, *auth.ID) suite.Require().NoError(sspHandler.DeleteSystemImplementationLeveragedAuthorization(ctx)) suite.Equal(http.StatusNoContent, rec.Code) diff --git a/internal/api/handler/oscal/ssp_leverage_drift_test.go b/internal/api/handler/oscal/ssp_leverage_drift_test.go index bfc45f4e..20715842 100644 --- a/internal/api/handler/oscal/ssp_leverage_drift_test.go +++ b/internal/api/handler/oscal/ssp_leverage_drift_test.go @@ -42,12 +42,11 @@ func seedLeverageLinkForDrift(t *testing.T, db *gorm.DB, offeringVersion int) (* UpstreamSSPID: *upstreamSSP.ID, OfferingID: *offering.ID, OfferingVersion: 1, - ControlID: "ac-1", - ProvidedUUID: *provided.ID, - InheritedUUID: uuid.New(), - LeveragedAuthUUID: uuid.New(), - Satisfaction: relational.SSPLeverageSatisfactionFull, - Status: relational.SSPLeverageStatusActive, + ControlID: "ac-1", + ProvidedUUID: *provided.ID, + InheritedUUID: uuid.New(), + Satisfaction: relational.SSPLeverageSatisfactionFull, + Status: relational.SSPLeverageStatusActive, } require.NoError(t, db.Create(&link).Error) diff --git a/internal/api/handler/oscal/ssp_leverage_test.go b/internal/api/handler/oscal/ssp_leverage_test.go index e9291204..896bbab7 100644 --- a/internal/api/handler/oscal/ssp_leverage_test.go +++ b/internal/api/handler/oscal/ssp_leverage_test.go @@ -152,10 +152,6 @@ func (suite *SSPLeverageIntegrationSuite) TestSubscribePartialThenProjection() { // Subscribe as the ssp-subscriber user, satisfying only 1 of the 2 responsibilities. subscribeReqBody := map[string]any{ "downstreamSspId": downstreamSSP.UUID, - "leveragedAuthorization": map[string]string{ - "title": "Trust in upstream provider", - "partyUuid": uuid.New().String(), - }, "items": []map[string]any{ {"itemId": itemID, "satisfiedResponsibilityUuids": []string{respAUUID}}, }, @@ -192,8 +188,8 @@ func (suite *SSPLeverageIntegrationSuite) TestSubscribePartialThenProjection() { suite.Equal(respBUUID, projection.Data[0].OutstandingResponsibilities[0].ResponsibilityUUID.String()) // AC #4: the downstream's OSCAL subtree, marshaled with zero new export code, contains the - // inherited/satisfied/leveraged-authorization entries subscribe wrote — and the tree it - // materialized is requirement -> statement -> by-component, never requirement-anchored. + // inherited/satisfied entries subscribe wrote — and the tree it materialized is + // requirement -> statement -> by-component, never requirement-anchored. var downstream relational.SystemSecurityPlan suite.Require().NoError(suite.DB. Preload("SystemImplementation.LeveragedAuthorizations"). @@ -203,9 +199,11 @@ func (suite *SSPLeverageIntegrationSuite) TestSubscribePartialThenProjection() { First(&downstream, "id = ?", downstreamSSP.UUID).Error) marshaled := downstream.MarshalOscal() - suite.Require().NotNil(marshaled.SystemImplementation.LeveragedAuthorizations) - suite.Require().Len(*marshaled.SystemImplementation.LeveragedAuthorizations, 1) - suite.Equal("Trust in upstream provider", (*marshaled.SystemImplementation.LeveragedAuthorizations)[0].Title) + // Sharing is decoupled from authorizations: subscribe creates NO leveraged-authorization, + // and the leverage link references none. + suite.Nil(marshaled.SystemImplementation.LeveragedAuthorizations, + "subscribe must not create a leveraged authorization") + suite.Nil(links.Data[0].LeveragedAuthUUID, "the leverage link carries no leveraged-auth reference") var foundInherited, foundSatisfied bool for _, req := range marshaled.ControlImplementation.ImplementedRequirements { @@ -290,9 +288,8 @@ func (suite *SSPLeverageIntegrationSuite) TestSubscribeRequiresSSPUpdateOnDownst // the handler's downstream ssp:update check. rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", "/api/oscal/ssp-export-offerings/"+offeringID+"/subscribe", viewerToken, map[string]any{ - "downstreamSspId": downstreamSSP.UUID, - "leveragedAuthorization": map[string]string{"title": "Trust", "partyUuid": uuid.New().String()}, - "items": []map[string]any{{"itemId": createdItem.Data.ID.String(), "satisfiedResponsibilityUuids": []string{respUUID}}}, + "downstreamSspId": downstreamSSP.UUID, + "items": []map[string]any{{"itemId": createdItem.Data.ID.String(), "satisfiedResponsibilityUuids": []string{respUUID}}}, }) suite.Require().Equal(http.StatusForbidden, rec.Code, rec.Body.String()) } @@ -350,8 +347,7 @@ func (suite *SSPLeverageIntegrationSuite) TestResponsibilityFilterFlipsPosture() rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", "/api/oscal/ssp-export-offerings/"+offeringID+"/subscribe", subscriberToken, map[string]any{ - "downstreamSspId": downstreamSSP.UUID, - "leveragedAuthorization": map[string]string{"title": "Trust in upstream provider", "partyUuid": uuid.New().String()}, + "downstreamSspId": downstreamSSP.UUID, "items": []map[string]any{ {"itemId": itemID, "satisfiedResponsibilityUuids": []string{respAUUID}}, }, diff --git a/internal/api/handler/oscal/ssp_leverage_unit_test.go b/internal/api/handler/oscal/ssp_leverage_unit_test.go index 8b092830..ea0c4fbb 100644 --- a/internal/api/handler/oscal/ssp_leverage_unit_test.go +++ b/internal/api/handler/oscal/ssp_leverage_unit_test.go @@ -163,8 +163,8 @@ func subscribeBody(downstreamSSPID uuid.UUID, itemID uuid.UUID, satisfiedRespIDs quoted = append(quoted, fmt.Sprintf("%q", id.String())) } return fmt.Sprintf( - `{"downstreamSspId":%q,"leveragedAuthorization":{"title":"Trust","partyUuid":%q},"items":[{"itemId":%q,"satisfiedResponsibilityUuids":[%s]}]}`, - downstreamSSPID.String(), uuid.New().String(), itemID.String(), strings.Join(quoted, ","), + `{"downstreamSspId":%q,"items":[{"itemId":%q,"satisfiedResponsibilityUuids":[%s]}]}`, + downstreamSSPID.String(), itemID.String(), strings.Join(quoted, ","), ) } @@ -272,24 +272,41 @@ func TestDeriveSatisfaction(t *testing.T) { }) } -// TestFindOrCreateThisSystemComponent: creates a placeholder component when none exists, -// and returns the same row (not a duplicate) on a second call. -func TestFindOrCreateThisSystemComponent(t *testing.T) { +// TestFindOrCreateLeveragedSystemComponent: the downstream component representing an +// upstream system is named after the upstream, typed `system` (not this-system), and +// identified by the leveraged-system-uuid prop — so repeat imports from the same upstream +// reuse one row (even if the upstream was renamed in the meantime), while a different +// upstream gets its own component. +func TestFindOrCreateLeveragedSystemComponent(t *testing.T) { db := newSSPLeverageTestDB(t) si := relational.SystemImplementation{} require.NoError(t, db.Create(&si).Error) - first, err := findOrCreateThisSystemComponent(db, *si.ID) + upstreamID := uuid.New() + first, err := findOrCreateLeveragedSystemComponent(db, *si.ID, upstreamID, "Platform") require.NoError(t, err) - require.Equal(t, thisSystemComponentType, first.Type) + require.Equal(t, "system", first.Type) + require.Equal(t, "Platform", first.Title) + propValues := map[string]string{} + for _, prop := range first.Props { + propValues[prop.Name] = prop.Value + } + require.Equal(t, upstreamID.String(), propValues[leveragedSystemUUIDProp]) + require.Equal(t, "external", propValues["implementation-point"]) - second, err := findOrCreateThisSystemComponent(db, *si.ID) + // Identity is the upstream id, not the title: a rename does not spawn a duplicate. + second, err := findOrCreateLeveragedSystemComponent(db, *si.ID, upstreamID, "Platform (renamed)") require.NoError(t, err) require.Equal(t, *first.ID, *second.ID) + // A different upstream gets its own component. + other, err := findOrCreateLeveragedSystemComponent(db, *si.ID, uuid.New(), "Other Provider") + require.NoError(t, err) + require.NotEqual(t, *first.ID, *other.ID) + var count int64 require.NoError(t, db.Model(&relational.SystemComponent{}).Where("system_implementation_id = ?", si.ID).Count(&count).Error) - require.Equal(t, int64(1), count) + require.Equal(t, int64(2), count) } // TestFindOrCreateImplementedRequirement: creates a requirement for a control_id when @@ -394,7 +411,9 @@ func TestSubscribePartialSatisfactionWritesAtomically(t *testing.T) { require.NoError(t, db.Model(&relational.LeveragedAuthorization{}).Count(&authCount).Error) require.Equal(t, int64(1), inheritedCount) require.Equal(t, int64(1), satisfiedCount) - require.Equal(t, int64(1), authCount) + // Sharing is decoupled from authorizations: subscribe creates no leveraged authorization. + require.Equal(t, int64(0), authCount) + require.Nil(t, links[0].LeveragedAuthUUID) require.Len(t, pdp.calls, 1, "subscribe must check exactly one authz resource") require.Equal(t, authz.ResourceSSP, pdp.calls[0].Type) @@ -498,8 +517,8 @@ func TestSubscribeDuplicateProvidedUUIDWithinRequestRejected(t *testing.T) { h := NewSSPLeverageHandler(zap.NewNop().Sugar(), db, pdp, authz.FailClosed) body := fmt.Sprintf( - `{"downstreamSspId":%q,"leveragedAuthorization":{"title":"Trust","partyUuid":%q},"items":[{"itemId":%q,"satisfiedResponsibilityUuids":[]},{"itemId":%q,"satisfiedResponsibilityUuids":[]}]}`, - fx.downstreamSSPID.String(), uuid.New().String(), fx.itemID.String(), fx.itemID.String(), + `{"downstreamSspId":%q,"items":[{"itemId":%q,"satisfiedResponsibilityUuids":[]},{"itemId":%q,"satisfiedResponsibilityUuids":[]}]}`, + fx.downstreamSSPID.String(), fx.itemID.String(), fx.itemID.String(), ) ctx, _, rec := newSubscribeRequestContext(fx.offeringID, body) require.NoError(t, h.Subscribe(ctx)) @@ -584,6 +603,18 @@ func TestLeveragedControlsProjectionShowsPartialAndOutstanding(t *testing.T) { // that part of the response is unchanged by BCH-1339. require.Len(t, parsed.Data[0].OutstandingResponsibilities, 1) require.Equal(t, fx.respBID, parsed.Data[0].OutstandingResponsibilities[0].ResponsibilityUUID) + // responsibilities, by contrast, carries the FULL set with descriptions — including + // respA, which is already satisfied and thus absent from outstanding. Downstream labels + // every responsibility from this, so a satisfied entry's text never masquerades as the + // responsibility's own. + responsibilityDescByID := make(map[uuid.UUID]string) + for _, r := range parsed.Data[0].Responsibilities { + responsibilityDescByID[r.ResponsibilityUUID] = r.Description + } + require.Len(t, responsibilityDescByID, 2) + require.Contains(t, responsibilityDescByID, fx.respAID) + require.Contains(t, responsibilityDescByID, fx.respBID) + require.NotEmpty(t, responsibilityDescByID[fx.respAID]) // responsibilityPosture, by contrast, always covers every upstream responsibility // under the provided-uuid — both respA (satisfied at subscribe time) and respB — since // it's independent, evidence-backed posture, not a subset of what's outstanding. diff --git a/internal/api/handler/oscal/ssp_shared_responsibility_test.go b/internal/api/handler/oscal/ssp_shared_responsibility_test.go index 32149f5c..cffe6d7a 100644 --- a/internal/api/handler/oscal/ssp_shared_responsibility_test.go +++ b/internal/api/handler/oscal/ssp_shared_responsibility_test.go @@ -12,6 +12,7 @@ import ( "github.com/labstack/echo/v4" "github.com/stretchr/testify/require" "go.uber.org/zap" + "gorm.io/datatypes" "gorm.io/gorm" "github.com/compliance-framework/api/internal/api/handler" @@ -344,8 +345,8 @@ func TestSubscribeReportsReusedRowsAsNotCreated(t *testing.T) { pdp := &stubPDP{allow: true} h := NewSSPLeverageHandler(zap.NewNop().Sugar(), db, pdp, authz.FailClosed) body := fmt.Sprintf( - `{"downstreamSspId":%q,"leveragedAuthorization":{"title":"Trust","partyUuid":%q},"items":[{"itemId":%q},{"itemId":%q}]}`, - fx.downstreamSSPID.String(), uuid.New().String(), fx.itemID.String(), item2.ID.String()) + `{"downstreamSspId":%q,"items":[{"itemId":%q},{"itemId":%q}]}`, + fx.downstreamSSPID.String(), fx.itemID.String(), item2.ID.String()) ctx, _, rec := newSubscribeRequestContext(fx.offeringID, body) require.NoError(t, h.Subscribe(ctx)) require.Equal(t, http.StatusCreated, rec.Code) @@ -1641,18 +1642,24 @@ func TestSubscribeStoresSatisfactionAgreeingWithTheProjection(t *testing.T) { db := newSSPLeverageTestDB(t) fx := newSharedResponsibilityFixture(t, db) - // The downstream already hand-authored the whole tree for this statement, satisfying BOTH of - // the upstream's responsibilities — no leverage link, so the duplicate-link pre-check is silent. + // The downstream already carries the whole tree for this statement — anchored, as a + // PREVIOUS subscribe would have left it, on the component representing the upstream + // system (identified by the leveraged-system-uuid prop, which is what Subscribe's + // find-or-create keys on) — with satisfied rows covering BOTH of the upstream's + // responsibilities. No leverage link, so the duplicate-link pre-check is silent. var downstreamImpl relational.ControlImplementation require.NoError(t, db.First(&downstreamImpl, "system_security_plan_id = ?", fx.downstreamSSPID).Error) var downstreamSysImpl relational.SystemImplementation require.NoError(t, db.First(&downstreamSysImpl, "system_security_plan_id = ?", fx.downstreamSSPID).Error) - thisSystem := relational.SystemComponent{ - Type: thisSystemComponentType, Title: "This System", + upstreamComponent := relational.SystemComponent{ + Type: "system", Title: "Upstream Provider", + Props: datatypes.NewJSONSlice([]relational.Prop{ + {Name: leveragedSystemUUIDProp, Value: fx.upstreamSSPID.String()}, + }), SystemImplementationId: *downstreamSysImpl.ID, } - require.NoError(t, db.Create(&thisSystem).Error) + require.NoError(t, db.Create(&upstreamComponent).Error) req := relational.ImplementedRequirement{ControlImplementationId: *downstreamImpl.ID, ControlId: "ac-2"} require.NoError(t, db.Create(&req).Error) @@ -1660,7 +1667,7 @@ func TestSubscribeStoresSatisfactionAgreeingWithTheProjection(t *testing.T) { require.NoError(t, db.Create(&stmt).Error) statementsType := "statements" - bc := relational.ByComponent{ParentID: stmt.ID, ParentType: &statementsType, ComponentUUID: *thisSystem.ID} + bc := relational.ByComponent{ParentID: stmt.ID, ParentType: &statementsType, ComponentUUID: *upstreamComponent.ID} require.NoError(t, db.Create(&bc).Error) for _, respID := range []uuid.UUID{fx.respAID, fx.respBID} { diff --git a/internal/api/handler/oscal/system_security_plans_leveraged_auth_drift_test.go b/internal/api/handler/oscal/system_security_plans_leveraged_auth_drift_test.go index 72a3d63f..9d8d8320 100644 --- a/internal/api/handler/oscal/system_security_plans_leveraged_auth_drift_test.go +++ b/internal/api/handler/oscal/system_security_plans_leveraged_auth_drift_test.go @@ -51,7 +51,9 @@ func TestDeleteLeveragedAuthorizationDriftsReferencingActiveLinks(t *testing.T) link := relational.SSPLeverageLink{ DownstreamSSPID: *downstreamSSP.ID, UpstreamSSPID: *upstreamSSP.ID, OfferingID: *offering.ID, OfferingVersion: 1, - ControlID: "ac-1", ProvidedUUID: *provided.ID, InheritedUUID: uuid.New(), LeveragedAuthUUID: *auth.ID, + // A legacy link referencing a leveraged authorization — deleting that LA lapses this + // link (the behavior under test). + ControlID: "ac-1", ProvidedUUID: *provided.ID, InheritedUUID: uuid.New(), LeveragedAuthUUID: auth.ID, Satisfaction: relational.SSPLeverageSatisfactionFull, Status: relational.SSPLeverageStatusActive, } require.NoError(t, db.Create(&link).Error) diff --git a/internal/api/handler/request.go b/internal/api/handler/request.go index 50981d45..88247673 100644 --- a/internal/api/handler/request.go +++ b/internal/api/handler/request.go @@ -36,6 +36,20 @@ type createFilterRequest struct { Components *[]string `json:"components" yaml:"components"` } +// attachFilterResponsibilityRequest is the body for POST /filters/:id/responsibilities. +// camelCase JSON, the filters-API convention — clients must NOT kebab-case this body. +type attachFilterResponsibilityRequest struct { + // The upstream ControlImplementationResponsibility this filter should evidence. + ResponsibilityUUID uuid.UUID `json:"responsibilityUuid" validate:"required" swaggertype:"string" format:"uuid"` + // The DOWNSTREAM SSP that inherits the responsibility (matched against + // ssp_leverage_links.downstream_ssp_id). + SSPID uuid.UUID `json:"sspId" validate:"required" swaggertype:"string" format:"uuid"` + // Optional control to also link the filter to (so control-level compliance + // surfaces include it). The link's provenance is recorded: detaching the + // responsibility removes the control link only if this attach created it. + ControlID *string `json:"controlId"` +} + // SetCatalogActiveRequest is the body for toggling a catalog's active state. // Active is a pointer so a missing field is rejected rather than defaulting to // false and silently deactivating the catalog. diff --git a/internal/service/migrator.go b/internal/service/migrator.go index 3a9a7a6e..71bcbfb5 100644 --- a/internal/service/migrator.go +++ b/internal/service/migrator.go @@ -477,6 +477,15 @@ func MigrateUpWithConfig(db *gorm.DB, cfg *config.Config) error { `).Error; err != nil { return err } + + // Sharing is decoupled from Leveraged Authorizations: subscribe no longer creates + // an LA, so ssp_leverage_links.leveraged_auth_uuid must be nullable. AutoMigrate + // never relaxes an existing NOT NULL, so drop it explicitly (idempotent). + if err := db.Exec(` + ALTER TABLE ssp_leverage_links ALTER COLUMN leveraged_auth_uuid DROP NOT NULL + `).Error; err != nil { + return err + } } // For SQLite and other databases we do not create functional/unique indexes here. // They will rely on their default query plans; a plain index on control_id diff --git a/internal/service/relational/filters.go b/internal/service/relational/filters.go index 81a67f53..54560878 100644 --- a/internal/service/relational/filters.go +++ b/internal/service/relational/filters.go @@ -35,6 +35,30 @@ type FilterResponsibility struct { ResponsibilityUUID uuid.UUID `json:"responsibilityUuid" gorm:"type:uuid;primaryKey;index:idx_filter_responsibilities_ssp_lookup,priority:2"` SSPID uuid.UUID `json:"sspId" gorm:"type:uuid;primaryKey;index:idx_filter_responsibilities_ssp_lookup,priority:1"` + // Provenance of the filter→control association this attachment created. Attaching a + // filter to a responsibility also links it to the responsibility's control (so the + // existing control-level compliance surfaces include it); detaching must undo ONLY a + // link the responsibility machinery itself created, never one a user made + // independently via POST/PUT /api/filters. Semantics: + // - attach, link absent → append filter_controls, ControlLinkCreated=true + // - attach, link present and some existing row on (filter_id, control_id) has + // ControlLinkCreated=true → the link is responsibility-owned; the new row + // co-owns it (true), so the last detacher removes it + // - attach, link present otherwise → independently created, ControlLinkCreated=false + // - detach → remove the filter_controls row iff this row had + // ControlLinkCreated=true AND no other filter_responsibilities row on the same + // (filter_id, control_id) remains + // Control's primary key is composite (catalog_id, id) — both parts are recorded so + // detach can delete the exact association row. + // + // Rows are create/delete-only. Never Save()/Updates() this model: with a composite + // primary key GORM's upsert semantics on partial keys are a footgun. + ControlID *string `json:"controlId,omitempty" gorm:"column:control_id;index:idx_filter_responsibilities_control"` + ControlCatalogID *uuid.UUID `json:"-" gorm:"column:control_catalog_id;type:uuid"` + // ControlLinkCreated reports whether this attachment created (or co-owns) the + // filter→control link — see the provenance comment above. + ControlLinkCreated bool `json:"controlLinkCreated" gorm:"not null;default:false"` + Filter *Filter `json:"-" gorm:"foreignKey:FilterID;references:ID;constraint:OnDelete:CASCADE"` SystemSecurityPlan *SystemSecurityPlan `json:"-" gorm:"foreignKey:SSPID;references:ID;constraint:OnDelete:CASCADE"` } diff --git a/internal/service/relational/ssp_leverage.go b/internal/service/relational/ssp_leverage.go index a57b2acf..7a17a846 100644 --- a/internal/service/relational/ssp_leverage.go +++ b/internal/service/relational/ssp_leverage.go @@ -29,9 +29,16 @@ const ( // SSPExportOfferingItem (BCH-1338 Phase 2). ProvidedUUID is copied by value from the // offering item rather than a real FK into the upstream's Export subtree, keeping the // link portable and tolerant of the upstream being on a different system entirely. -// InheritedUUID/LeveragedAuthUUID are bare-value references (not GORM associations) to -// the downstream's own InheritedControlImplementation/LeveragedAuthorization rows -// created by the same subscribe call; nothing needs to preload through them. +// InheritedUUID is a bare-value reference (not a GORM association) to the downstream's own +// InheritedControlImplementation row created by the same subscribe call; nothing needs to +// preload through it. +// +// LeveragedAuthUUID is an OPTIONAL reference to a downstream LeveragedAuthorization. Sharing +// is deliberately decoupled from authorizations: subscribe never creates one, so new links +// carry nil here. A Leveraged Authorization models the downstream's real Authority to +// Operate (granted by an external auditor) — an independent, human-authored registry, not a +// gate on importing. The pointer is retained (and non-nil) only for legacy links created +// before this decoupling, which the LA-lapse drift path still keys on. type SSPLeverageLink struct { UUIDModel @@ -45,8 +52,8 @@ type SSPLeverageLink struct { StatementID *string `json:"statementId,omitempty"` ProvidedUUID uuid.UUID `json:"providedUuid" gorm:"type:uuid;not null;uniqueIndex:idx_ssp_leverage_links_unique,priority:2"` - InheritedUUID uuid.UUID `json:"inheritedUuid" gorm:"type:uuid;not null"` - LeveragedAuthUUID uuid.UUID `json:"leveragedAuthUuid" gorm:"type:uuid;not null"` + InheritedUUID uuid.UUID `json:"inheritedUuid" gorm:"type:uuid;not null"` + LeveragedAuthUUID *uuid.UUID `json:"leveragedAuthUuid,omitempty" gorm:"type:uuid"` Satisfaction SSPLeverageSatisfaction `json:"satisfaction" gorm:"not null"` Status SSPLeverageStatus `json:"status" gorm:"not null;default:active"` From 4f43822fddff51312904d9f6107bc13f002d4e28 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:18:24 -0300 Subject: [PATCH 10/13] fix: address review feedback --- docs/docs.go | 79 ++---- docs/swagger.json | 79 ++---- docs/swagger.yaml | 57 ++-- internal/api/handler/filter.go | 103 ++++++- .../oscal/filter_responsibilities_test.go | 78 ++++++ .../api/handler/oscal/ssp_by_components.go | 198 ++++++++++--- .../api/handler/oscal/ssp_export_offerings.go | 41 ++- internal/api/handler/oscal/ssp_leverage.go | 7 +- .../handler/oscal/ssp_leverage_drift_test.go | 8 +- .../oscal/ssp_shared_responsibility.go | 72 +++-- .../oscal/ssp_shared_responsibility_test.go | 94 ++++++- ...btree_lock_concurrency_integration_test.go | 228 +++++++++++++++ .../oscal/subtree_lock_invariant_test.go | 194 ++++++++++--- .../handler/oscal/system_security_plans.go | 114 ++++++-- internal/service/migrator.go | 262 ++++++++++++------ .../risk_evidence_worker_test_helpers_test.go | 17 +- 16 files changed, 1208 insertions(+), 423 deletions(-) create mode 100644 internal/api/handler/oscal/subtree_lock_concurrency_integration_test.go diff --git a/docs/docs.go b/docs/docs.go index 39c81e63..82738b39 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -4454,61 +4454,6 @@ const docTemplate = `{ } } }, - "/filters/{id}/responsibilities/{responsibilityUuid}": { - "delete": { - "description": "Removes the filter↔responsibility association for the given downstream SSP\n(sspId query param — the association's key is the full triple). The filter's\ncontrol link is removed only if it was created by a responsibility attachment\nand no other attachment on this filter still claims that control.", - "tags": [ - "Filters" - ], - "summary": "Detach a filter from an inherited responsibility", - "parameters": [ - { - "type": "string", - "description": "Filter ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Responsibility UUID", - "name": "responsibilityUuid", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Downstream SSP ID", - "name": "sspId", - "in": "query", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content" - }, - "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" - } - } - } - } - }, "/lineage/nodes/{key}/children": { "get": { "description": "Returns one level of children for a node. Key is a composite like catalog:\u003cuuid\u003e, group:\u003ccatalogId\u003e/\u003cgroupId\u003e, control:\u003ccatalogId\u003e/\u003ccontrolId\u003e, linkcat:\u003cchildCatalogId\u003e/\u003crelationship\u003e/\u003cparentCatalogId\u003e/\u003cparentControlId\u003e, risk:\u003criskId\u003e, evidence:\u003cstreamUuid\u003e. A control expands to synthetic linkcat catalog nodes (its implementing/documenting controls grouped by their catalog) plus its directly-linked risks; a linkcat node expands to that group's controls; a risk expands to its latest evidence per linked stream; evidence is a leaf.", @@ -16360,6 +16305,18 @@ const docTemplate = `{ "description": "Only return offerings this downstream SSP may subscribe to", "name": "downstreamSspId", "in": "query" + }, + { + "type": "integer", + "description": "Max items to return (default 100, max 1000)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Items to skip", + "name": "offset", + "in": "query" } ], "responses": { @@ -20367,6 +20324,12 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -20693,6 +20656,12 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { diff --git a/docs/swagger.json b/docs/swagger.json index c05c2a8b..62e77d4d 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -4448,61 +4448,6 @@ } } }, - "/filters/{id}/responsibilities/{responsibilityUuid}": { - "delete": { - "description": "Removes the filter↔responsibility association for the given downstream SSP\n(sspId query param — the association's key is the full triple). The filter's\ncontrol link is removed only if it was created by a responsibility attachment\nand no other attachment on this filter still claims that control.", - "tags": [ - "Filters" - ], - "summary": "Detach a filter from an inherited responsibility", - "parameters": [ - { - "type": "string", - "description": "Filter ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Responsibility UUID", - "name": "responsibilityUuid", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Downstream SSP ID", - "name": "sspId", - "in": "query", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content" - }, - "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" - } - } - } - } - }, "/lineage/nodes/{key}/children": { "get": { "description": "Returns one level of children for a node. Key is a composite like catalog:\u003cuuid\u003e, group:\u003ccatalogId\u003e/\u003cgroupId\u003e, control:\u003ccatalogId\u003e/\u003ccontrolId\u003e, linkcat:\u003cchildCatalogId\u003e/\u003crelationship\u003e/\u003cparentCatalogId\u003e/\u003cparentControlId\u003e, risk:\u003criskId\u003e, evidence:\u003cstreamUuid\u003e. A control expands to synthetic linkcat catalog nodes (its implementing/documenting controls grouped by their catalog) plus its directly-linked risks; a linkcat node expands to that group's controls; a risk expands to its latest evidence per linked stream; evidence is a leaf.", @@ -16354,6 +16299,18 @@ "description": "Only return offerings this downstream SSP may subscribe to", "name": "downstreamSspId", "in": "query" + }, + { + "type": "integer", + "description": "Max items to return (default 100, max 1000)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Items to skip", + "name": "offset", + "in": "query" } ], "responses": { @@ -20361,6 +20318,12 @@ "$ref": "#/definitions/api.Error" } }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -20687,6 +20650,12 @@ "$ref": "#/definitions/api.Error" } }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e9a635d0..f760b49c 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -14718,47 +14718,6 @@ paths: summary: Attach a filter to an inherited responsibility tags: - Filters - /filters/{id}/responsibilities/{responsibilityUuid}: - delete: - description: |- - Removes the filter↔responsibility association for the given downstream SSP - (sspId query param — the association's key is the full triple). The filter's - control link is removed only if it was created by a responsibility attachment - and no other attachment on this filter still claims that control. - parameters: - - description: Filter ID - in: path - name: id - required: true - type: string - - description: Responsibility UUID - in: path - name: responsibilityUuid - required: true - type: string - - description: Downstream SSP ID - in: query - name: sspId - required: true - type: string - responses: - "204": - description: No Content - "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' - summary: Detach a filter from an inherited responsibility - tags: - - Filters /filters/import: post: consumes: @@ -22643,6 +22602,14 @@ paths: in: query name: downstreamSspId type: string + - description: Max items to return (default 100, max 1000) + in: query + name: limit + type: integer + - description: Items to skip + in: query + name: offset + type: integer produces: - application/json responses: @@ -25347,6 +25314,10 @@ paths: description: Not Found schema: $ref: '#/definitions/api.Error' + "409": + description: Conflict + schema: + $ref: '#/definitions/api.Error' "500": description: Internal Server Error schema: @@ -25576,6 +25547,10 @@ paths: description: Not Found schema: $ref: '#/definitions/api.Error' + "409": + description: Conflict + schema: + $ref: '#/definitions/api.Error' "500": description: Internal Server Error schema: diff --git a/internal/api/handler/filter.go b/internal/api/handler/filter.go index 7cc00a45..bdf530d8 100644 --- a/internal/api/handler/filter.go +++ b/internal/api/handler/filter.go @@ -482,11 +482,17 @@ func (h *FilterHandler) AttachResponsibility(ctx echo.Context) error { // tolerate junk rows, but writes are validated. This replicates the // bulkResolveUpstreamResponsibilities join (handler/oscal cannot be imported from // here — it imports this package for the response envelopes). + // + // Terminal link states are excluded deliberately, not by omission: a revoked or superseded + // link means the SSP no longer inherits the responsibility, so attaching a filter to it would + // target something that isn't there. A drifted link still qualifies — the subscription exists + // and is being reconciled, so filtering on it remains meaningful. var inherits int64 if err := h.db.Table("ssp_leverage_links AS l"). Joins("JOIN provided_control_implementations p ON p.id = l.provided_uuid"). Joins("JOIN control_implementation_responsibilities r ON r.provided_uuid = p.id AND r.export_id = p.export_id"). - Where("l.downstream_ssp_id = ? AND r.id = ?", req.SSPID, req.ResponsibilityUUID). + Where("l.downstream_ssp_id = ? AND r.id = ? AND l.status NOT IN ?", req.SSPID, req.ResponsibilityUUID, + []relational.SSPLeverageStatus{relational.SSPLeverageStatusRevoked, relational.SSPLeverageStatusSuperseded}). Count(&inherits).Error; err != nil { return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } @@ -516,10 +522,28 @@ func (h *FilterHandler) AttachResponsibility(ctx echo.Context) error { var controlToLink *relational.Control if req.ControlID != nil && strings.TrimSpace(*req.ControlID) != "" { + // Control's PK is composite (catalog_id, id), so a control id alone does not identify a + // control — NIST 800-53 rev4 and rev5 both define AC-2. Resolve the catalog through the + // SSP's own profiles first; matching on id alone would pick an arbitrary catalog's row and + // pin this filter (and every control-level compliance surface reading ControlCatalogID) to + // a catalog the SSP may not even use. + controlID := strings.TrimSpace(*req.ControlID) + catalogID, err := h.resolveCatalogIDForSSPControl(req.SSPID, controlID) + if errors.Is(err, errAmbiguousControlCatalog) { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf( + "control %q is defined in multiple catalogs and this SSP's profiles do not disambiguate it", controlID))) + } + if err != nil { + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + if catalogID == nil { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("control %q not found", controlID))) + } + // The caller passes the SSP's casing of the control id, which is not reliably // the catalog's — match case-insensitively, like the leverage joins do. var control relational.Control - if err := h.db.First(&control, "LOWER(id) = LOWER(?)", strings.TrimSpace(*req.ControlID)).Error; err != nil { + if err := h.db.First(&control, "catalog_id = ? AND LOWER(id) = LOWER(?)", *catalogID, controlID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("control %q not found", *req.ControlID))) } @@ -542,9 +566,12 @@ func (h *FilterHandler) AttachResponsibility(ctx echo.Context) error { // Co-ownership: if the existing link was itself created by a responsibility // attachment, this row claims it too, so the LAST detacher removes it. An // independently created link (POST/PUT /filters) is never owned. + // Scoped by catalog as well as id: control_id alone spans catalogs, so a claim on + // another catalog's AC-2 would otherwise read as a claim on this one. var owners int64 if err := h.db.Model(&relational.FilterResponsibility{}). - Where("filter_id = ? AND control_id = ? AND control_link_created = true", filterID, control.ID). + Where("filter_id = ? AND control_id = ? AND control_catalog_id = ? AND control_link_created = true", + filterID, control.ID, control.CatalogID). Count(&owners).Error; err != nil { return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } @@ -582,6 +609,68 @@ func (h *FilterHandler) AttachResponsibility(ctx echo.Context) error { // @Failure 404 {object} api.Error // @Failure 500 {object} api.Error // @Router /filters/{id}/responsibilities/{responsibilityUuid} [delete] +// +// errAmbiguousControlCatalog is returned when a control id matches controls in more than one +// catalog and nothing narrows the choice. Mapped to 400: picking one arbitrarily is the bug this +// resolution exists to prevent, so the caller has to say which catalog it means. +var errAmbiguousControlCatalog = errors.New("control id matches controls in multiple catalogs") + +// resolveCatalogIDForSSPControl resolves controlID to exactly one catalog. Control's PK is +// composite (catalog_id, id), so a bare control id does not identify a control — NIST 800-53 rev4 +// and rev5 both define AC-2. +// +// Two steps, in order: +// 1. The SSP's own profiles, which are authoritative about which catalog it means. This mirrors +// handler/oscal's resolveCatalogIDForControl; it is replicated rather than shared because +// handler/oscal imports this package for the response envelopes, so it cannot be imported back. +// 2. If the profiles don't import the control — which is legitimate and common, since a downstream +// may rely on a leveraged capability without implementing the control itself — fall back to the +// catalogs that define it. Exactly one match resolves; more than one is ambiguous and errors. +// +// The point is that no arbitrary choice is ever made: the previous `First(id = ?)` returned +// whichever row the planner yielded, silently pinning the filter (and every control-level +// compliance surface reading ControlCatalogID) to a catalog the SSP may not even use. +// +// The join deliberately carries no CAST: ssp_profiles.profile_id and profile_controls.profile_id +// compare directly, matching the precedent in relational/system_component_suggestions.go, and +// casting to uuid would break the sqlite-backed unit suites. control_id stays free text, hence the +// UPPER() fold. A nil return with a nil error means no catalog defines the control at all — a 404. +func (h *FilterHandler) resolveCatalogIDForSSPControl(sspID uuid.UUID, controlID string) (*uuid.UUID, error) { + var profileCatalogIDs []uuid.UUID + if err := h.db. + Table("profile_controls"). + Joins("JOIN ssp_profiles ON ssp_profiles.profile_id = profile_controls.profile_id"). + Where("ssp_profiles.system_security_plan_id = ? AND UPPER(profile_controls.control_id) = UPPER(?)", sspID, controlID). + Distinct(). + Pluck("profile_controls.control_catalog_id", &profileCatalogIDs).Error; err != nil { + return nil, err + } + switch len(profileCatalogIDs) { + case 1: + return &profileCatalogIDs[0], nil + case 0: + // Fall through to the catalog-wide lookup below. + default: + return nil, errAmbiguousControlCatalog + } + + var catalogIDs []uuid.UUID + if err := h.db.Model(&relational.Control{}). + Where("UPPER(id) = UPPER(?)", controlID). + Distinct(). + Pluck("catalog_id", &catalogIDs).Error; err != nil { + return nil, err + } + switch len(catalogIDs) { + case 1: + return &catalogIDs[0], nil + case 0: + return nil, nil + default: + return nil, errAmbiguousControlCatalog + } +} + func (h *FilterHandler) DetachResponsibility(ctx echo.Context) error { filterID, err := uuid.Parse(ctx.Param("id")) if err != nil { @@ -617,10 +706,16 @@ func (h *FilterHandler) DetachResponsibility(ctx echo.Context) error { // Unwind the control link only when this row owned it and nobody else claims it — // see the provenance comment on relational.FilterResponsibility. + // + // A claim is a row that CREATED the link (control_link_created), on this exact control + // (catalog_id, id). Counting rows with control_link_created = false would count rows that + // never claimed the link, blocking the unwind and leaking the link permanently; counting + // across catalogs would do the same via an unrelated catalog's same-named control. if row.ControlLinkCreated && row.ControlID != nil && row.ControlCatalogID != nil { var claims int64 if err := tx.Model(&relational.FilterResponsibility{}). - Where("filter_id = ? AND control_id = ?", filterID, *row.ControlID). + Where("filter_id = ? AND control_id = ? AND control_catalog_id = ? AND control_link_created = true", + filterID, *row.ControlID, *row.ControlCatalogID). Count(&claims).Error; err != nil { return err } diff --git a/internal/api/handler/oscal/filter_responsibilities_test.go b/internal/api/handler/oscal/filter_responsibilities_test.go index 7525c8a4..560ae1b6 100644 --- a/internal/api/handler/oscal/filter_responsibilities_test.go +++ b/internal/api/handler/oscal/filter_responsibilities_test.go @@ -277,6 +277,84 @@ func (suite *FilterResponsibilityIntegrationSuite) TestCoOwnedControlLinkRemoved suite.Equal(int64(0), suite.controlLinkCount(*filter.ID, "ac-1")) } +// TestDetachUnwindsLinkDespiteUnowningSibling: a filter_responsibilities row with +// ControlLinkCreated=false is not a claim on the link, so it must not block the unwind. +// +// The sequence is the reachable one: an independent link makes R1 unowned; the independent link is +// then removed; R2 attaches and creates the link itself. Detaching R2 must unwind the link R2 +// created — counting R1 (same filter+control, but never a claimant) would strand it forever. +func (suite *FilterResponsibilityIntegrationSuite) TestDetachUnwindsLinkDespiteUnowningSibling() { + fx := suite.setupFilterResponsibilityFixture() + filter := suite.makeFilter("unwind filter", fx.downstreamSSPID) + controlID := "ac-1" + + // An independently created link (as POST/PUT /filters would make). + var control relational.Control + suite.Require().NoError(suite.DB.First(&control, "id = ?", "ac-1").Error) + suite.Require().NoError(suite.DB.Model(&filter).Association("Controls").Append(&control)) + + // R1 attaches while that link exists, so it never owns it. + rec := authedRequest(&suite.IntegrationTestSuite, fx.server, "POST", + fmt.Sprintf("/api/filters/%s/responsibilities", filter.ID), fx.contributorToken, + map[string]any{"responsibilityUuid": fx.respAUUID, "sspId": fx.downstreamSSPID, "controlId": controlID}) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + var r1 handler.GenericDataResponse[relational.FilterResponsibility] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &r1)) + suite.Require().False(r1.Data.ControlLinkCreated) + + // The independent link goes away. + suite.Require().NoError(suite.DB.Model(&filter).Association("Controls").Delete(&control)) + suite.Require().Equal(int64(0), suite.controlLinkCount(*filter.ID, "ac-1")) + + // R2 attaches with no link present, so R2 creates and owns it. + rec = authedRequest(&suite.IntegrationTestSuite, fx.server, "POST", + fmt.Sprintf("/api/filters/%s/responsibilities", filter.ID), fx.contributorToken, + map[string]any{"responsibilityUuid": fx.respBUUID, "sspId": fx.downstreamSSPID, "controlId": controlID}) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + var r2 handler.GenericDataResponse[relational.FilterResponsibility] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &r2)) + suite.Require().True(r2.Data.ControlLinkCreated, "no link existed, so R2 created it") + suite.Require().Equal(int64(1), suite.controlLinkCount(*filter.ID, "ac-1")) + + // Detaching R2 must unwind the link it created: R1 is not a claimant. + rec = authedRequest(&suite.IntegrationTestSuite, fx.server, "DELETE", + fmt.Sprintf("/api/filters/%s/responsibilities/%s?sspId=%s", filter.ID, fx.respBUUID, fx.downstreamSSPID), + fx.contributorToken, nil) + suite.Require().Equal(http.StatusNoContent, rec.Code, rec.Body.String()) + suite.Equal(int64(0), suite.controlLinkCount(*filter.ID, "ac-1"), + "a ControlLinkCreated=false row is not a claim, so it must not strand the link R2 created") +} + +// TestAttachRejectsAmbiguousControlAcrossCatalogs: Control's PK is composite (catalog_id, id), and +// two catalogs routinely define the same id (NIST 800-53 rev4 and rev5 both have AC-2). With +// nothing to disambiguate, attach must refuse rather than resolve to an arbitrary catalog's +// control — the old `First(LOWER(id) = ...)` returned whichever row the planner happened to yield +// and recorded that catalog on the row. +func (suite *FilterResponsibilityIntegrationSuite) TestAttachRejectsAmbiguousControlAcrossCatalogs() { + fx := suite.setupFilterResponsibilityFixture() + + // A second catalog defining the very same control id as the fixture's. + secondCatalog := relational.Catalog{} + suite.Require().NoError(suite.DB.Create(&secondCatalog).Error) + suite.Require().NoError(suite.DB.Create(&relational.Control{ + CatalogID: *secondCatalog.ID, ID: "ac-1", Title: "Access Control Policy (rev 2)", + }).Error) + + filter := suite.makeFilter("ambiguous filter", fx.downstreamSSPID) + rec := authedRequest(&suite.IntegrationTestSuite, fx.server, "POST", + fmt.Sprintf("/api/filters/%s/responsibilities", filter.ID), fx.contributorToken, + map[string]any{"responsibilityUuid": fx.respAUUID, "sspId": fx.downstreamSSPID, "controlId": "AC-1"}) + suite.Require().Equal(http.StatusBadRequest, rec.Code, rec.Body.String()) + suite.Contains(rec.Body.String(), "multiple catalogs") + + // And nothing was half-written. + var rows int64 + suite.Require().NoError(suite.DB.Model(&relational.FilterResponsibility{}). + Where("filter_id = ?", filter.ID).Count(&rows).Error) + suite.Zero(rows, "a rejected attach must not leave a filter_responsibilities row") + suite.Equal(int64(0), suite.controlLinkCount(*filter.ID, "ac-1")) +} + func (suite *FilterResponsibilityIntegrationSuite) TestAttachValidation() { fx := suite.setupFilterResponsibilityFixture() filter := suite.makeFilter("validation filter", fx.downstreamSSPID) diff --git a/internal/api/handler/oscal/ssp_by_components.go b/internal/api/handler/oscal/ssp_by_components.go index df694c02..f2766ed2 100644 --- a/internal/api/handler/oscal/ssp_by_components.go +++ b/internal/api/handler/oscal/ssp_by_components.go @@ -32,6 +32,78 @@ import ( // unsubscribe path as the way out. Hand-authored inherited entries (no link row) delete freely. var errInheritedOwnedBySubscription = errors.New("inherited entry is owned by a leverage subscription; unsubscribe instead of deleting it") +// errResponsibilityNotInheritable is returned from inside the satisfied-create transaction when +// the responsibility-uuid isn't on any export this by-component inherits. It exists so the check +// can run under the subtree lock and still surface as a 400 rather than a 500. +var errResponsibilityNotInheritable = errors.New("responsibility is not inheritable by this by-component") + +// errProvidedAlreadyInherited is returned when a provided-uuid is already inherited by another +// by-component in the same SSP. See the check in the inherited create for why that is ambiguous +// rather than merely redundant. Mapped to 409. +var errProvidedAlreadyInherited = errors.New("provided-uuid is already inherited in this system security plan") + +// errDuplicateInheritedUUID / errDuplicateSatisfiedUUID turn a client-supplied uuid that collides +// with an existing row into a 409 instead of leaking a raw driver error as a 500. ensureBodyUUID +// accepts any well-formed uuid, so replaying a create — or copying a uuid off another +// by-component — is trivially reachable. +var errDuplicateInheritedUUID = errors.New("an inherited entry with this uuid already exists") +var errDuplicateSatisfiedUUID = errors.New("a satisfied entry with this uuid already exists") + +// byComponentIDsForSSP lists every by-component id under an SSP, across both anchoring levels +// (statement-anchored, and the legacy requirement-anchored shape). The chain is +// control_implementations -> implemented_requirements -> statements -> by_components; nothing +// carries an SSP id further down, so it has to be walked. +func byComponentIDsForSSP(db *gorm.DB, sspID uuid.UUID) ([]uuid.UUID, error) { + var requirementIDs []uuid.UUID + if err := db.Model(&relational.ImplementedRequirement{}). + Joins("JOIN control_implementations ON control_implementations.id = implemented_requirements.control_implementation_id"). + Where("control_implementations.system_security_plan_id = ?", sspID). + Pluck("implemented_requirements.id", &requirementIDs).Error; err != nil { + return nil, err + } + if len(requirementIDs) == 0 { + return nil, nil + } + + var statementIDs []uuid.UUID + if err := db.Model(&relational.Statement{}). + Where("implemented_requirement_id IN ?", requirementIDs). + Pluck("id", &statementIDs).Error; err != nil { + return nil, err + } + + query := db.Model(&relational.ByComponent{}). + Where("parent_id IN ? AND parent_type = ?", requirementIDs, "implemented_requirements") + if len(statementIDs) > 0 { + query = query.Or("parent_id IN ? AND parent_type = ?", statementIDs, "statements") + } + var byComponentIDs []uuid.UUID + if err := query.Pluck("id", &byComponentIDs).Error; err != nil { + return nil, err + } + return byComponentIDs, nil +} + +// assertInheritedNotSubscribed enforces errInheritedOwnedBySubscription over a set of inherited +// ids. Both delete paths — the sub-resource route and the by-component cascade — must apply the +// same rule; guarding only one left the invariant bypassable by deleting the parent, and the two +// copies had already drifted into different queries. +func assertInheritedNotSubscribed(tx *gorm.DB, inheritedIDs []uuid.UUID) error { + if len(inheritedIDs) == 0 { + return nil + } + var linkCount int64 + if err := tx.Model(&relational.SSPLeverageLink{}). + Where("inherited_uuid IN ?", inheritedIDs). + Count(&linkCount).Error; err != nil { + return err + } + if linkCount > 0 { + return errInheritedOwnedBySubscription + } + return nil +} + // GetImplementedRequirementByComponent godoc // // @Summary Get a by-component within an implemented requirement @@ -436,10 +508,16 @@ func (h *SystemSecurityPlanHandler) GetImplementedRequirementStatementByComponen // @Success 201 {object} handler.GenericDataResponse[oscalTypes_1_1_3.InheritedControlImplementation] // @Failure 400 {object} api.Error // @Failure 404 {object} api.Error +// @Failure 409 {object} api.Error // @Failure 500 {object} api.Error // @Security OAuth2Password // @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/inherited [post] func (h *SystemSecurityPlanHandler) CreateImplementedRequirementStatementByComponentInherited(ctx echo.Context) error { + sspID, _, _, err := parseSSPReqStmtIDs(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + bc, ok := h.resolveByComponentForStatement(ctx) if !ok { return nil @@ -480,13 +558,40 @@ func (h *SystemSecurityPlanHandler) CreateImplementedRequirementStatementByCompo if err := h.db.Transaction(func(tx *gorm.DB) error { // Same advisory-lock guard the Export create uses: nothing in the schema stops two - // concurrent creates from racing on the same by-component, and the satisfaction - // re-derivation below reads the rows this write produces. + // concurrent creates from racing on the same by-component. No satisfaction re-derivation + // belongs here — creating an inherited row changes what is inheritable, not what is + // satisfied — so the lock is purely to serialize concurrent creates against the + // already-inherited check below. if err := lockByComponentSubtreeWrite(tx, *bc.ID); err != nil { return err } + // One provided-uuid may be inherited only once per SSP. A subscription's link is reached + // through its inherited row, and satisfaction is derived from the owning by-component's + // satisfied set — so a second by-component inheriting the same provided-uuid would make + // "which satisfied set governs this capability" ambiguous. Nothing in the schema forbids + // it, so it is enforced here, across both anchoring levels. + inSSP, err := byComponentIDsForSSP(tx, sspID) + if err != nil { + return err + } + var dupe int64 + if err := tx.Model(&relational.InheritedControlImplementation{}). + Where("provided_uuid = ? AND by_component_id IN ?", providedUUID, inSSP). + Count(&dupe).Error; err != nil { + return err + } + if dupe > 0 { + return errProvidedAlreadyInherited + } return tx.Create(relInherited).Error }); err != nil { + if errors.Is(err, errProvidedAlreadyInherited) { + return ctx.JSON(http.StatusConflict, api.NewError(fmt.Errorf( + "provided-uuid %q is already inherited elsewhere in this system security plan", providedUUID))) + } + if isUniqueViolation(err) { + return ctx.JSON(http.StatusConflict, api.NewError(errDuplicateInheritedUUID)) + } h.sugar.Errorf("Failed to create inherited control implementation: %v", err) return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } @@ -622,14 +727,14 @@ func (h *SystemSecurityPlanHandler) DeleteImplementedRequirementStatementByCompo } if err := h.db.Transaction(func(tx *gorm.DB) error { - var linkCount int64 - if err := tx.Model(&relational.SSPLeverageLink{}). - Where("inherited_uuid = ?", existing.ID). - Count(&linkCount).Error; err != nil { + // Without the lock the count below is a pre-check-then-delete: a concurrent Subscribe can + // create a link for this row between the two, leaving link.InheritedUUID dangling. This + // path never calls resyncLeverageSatisfaction, so nothing takes the lock on its behalf. + if err := lockByComponentSubtreeWrite(tx, existing.ByComponentId); err != nil { return err } - if linkCount > 0 { - return errInheritedOwnedBySubscription + if err := assertInheritedNotSubscribed(tx, []uuid.UUID{*existing.ID}); err != nil { + return err } if err := deleteResponsibleRoles(tx, existing.ResponsibleRoles); err != nil { @@ -704,6 +809,7 @@ func (h *SystemSecurityPlanHandler) GetImplementedRequirementStatementByComponen // @Success 201 {object} handler.GenericDataResponse[oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility] // @Failure 400 {object} api.Error // @Failure 404 {object} api.Error +// @Failure 409 {object} api.Error // @Failure 500 {object} api.Error // @Security OAuth2Password // @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components/{byComponentId}/satisfied [post] @@ -730,16 +836,6 @@ func (h *SystemSecurityPlanHandler) CreateImplementedRequirementStatementByCompo return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("responsibility-uuid must be a valid UUID"))) } - inheritable, err := h.inheritableResponsibilities(*bc.ID) - if err != nil { - h.sugar.Errorf("Failed to resolve inheritable responsibilities: %v", err) - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) - } - if !inheritable[responsibilityUUID] { - return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf( - "responsibility-uuid %q is not a responsibility on any export this by-component inherits from", responsibilityUUID))) - } - relSatisfied := &relational.SatisfiedControlImplementationResponsibility{} relSatisfied.UnmarshalOscal(oscalSatisfied) relSatisfied.ByComponentId = *bc.ID @@ -748,11 +844,28 @@ func (h *SystemSecurityPlanHandler) CreateImplementedRequirementStatementByCompo if err := lockByComponentSubtreeWrite(tx, *bc.ID); err != nil { return err } + // Inside the tx and after the lock: read on h.db before either, and a concurrent + // inherited DELETE could retire the responsibility between the check and the insert, + // leaving a satisfied row that is no longer inheritable and can never be re-derived away. + inheritable, err := inheritableResponsibilities(tx, *bc.ID) + if err != nil { + return err + } + if !inheritable[responsibilityUUID] { + return errResponsibilityNotInheritable + } if err := tx.Create(relSatisfied).Error; err != nil { return err } return resyncLeverageSatisfaction(tx, sspID, *bc.ID) }); err != nil { + if errors.Is(err, errResponsibilityNotInheritable) { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf( + "responsibility-uuid %q is not a responsibility on any export this by-component inherits from", responsibilityUUID))) + } + if isUniqueViolation(err) { + return ctx.JSON(http.StatusConflict, api.NewError(errDuplicateSatisfiedUUID)) + } h.sugar.Errorf("Failed to create satisfied responsibility: %v", err) return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } @@ -923,14 +1036,18 @@ func (h *SystemSecurityPlanHandler) DeleteImplementedRequirementStatementByCompo // bulkResolveUpstreamResponsibilities, because provided-uuid values are only unique within one // upstream's Export — a provided_uuid-only match could pick up a same-valued responsibility // from an unrelated Export. -func (h *SystemSecurityPlanHandler) inheritableResponsibilities(byComponentID uuid.UUID) (map[uuid.UUID]bool, error) { +// +// Takes the caller's db handle so a writer can run it inside its transaction, after taking +// lockByComponentSubtreeWrite: the answer is only trustworthy for a write if the inherited set +// it reads cannot change between the check and the insert. +func inheritableResponsibilities(db *gorm.DB, byComponentID uuid.UUID) (map[uuid.UUID]bool, error) { var inherited []relational.InheritedControlImplementation - if err := h.db.Where("by_component_id = ?", byComponentID).Find(&inherited).Error; err != nil { + if err := db.Where("by_component_id = ?", byComponentID).Find(&inherited).Error; err != nil { return nil, err } providedUUIDs := uniqueUUIDs(inherited, func(i relational.InheritedControlImplementation) uuid.UUID { return i.ProvidedUuid }) - byProvided, err := bulkResolveUpstreamResponsibilities(h.db, providedUUIDs) + byProvided, err := bulkResolveUpstreamResponsibilities(db, providedUUIDs) if err != nil { return nil, err } @@ -948,10 +1065,14 @@ func (h *SystemSecurityPlanHandler) inheritableResponsibilities(byComponentID uu // owns an Inherited row on this by-component, after its satisfied set changed. // // LeveragedControls recomputes satisfaction live, so the stored value is bookkeeping — but the -// drift detector and the notification path read it, so it must not rot. The link is found the -// only way it can be: through the by-component's inherited rows, keyed on -// (downstream_ssp_id, provided_uuid) — the pair the unique index is built on. A hand-authored -// inherited entry has no link and is skipped silently. +// drift detector and the notification path read it, so it must not rot. Links are found by +// inherited_uuid, NOT by (downstream_ssp_id, provided_uuid). That pair carries a unique index on +// ssp_leverage_links, so it identifies at most one link — but it does not identify which +// by-component's satisfied set governs that link. Nothing constrains InheritedControlImplementation, +// so two by-components in one SSP can each hold an inherited row for the same provided-uuid while +// only one link exists; keying on the pair would let this by-component's satisfied set overwrite a +// sibling's link. inherited_uuid is unambiguous: it names the exact row the link was created for. +// A hand-authored inherited entry has no link and is skipped silently. // // Runs inside the caller's transaction so the satisfied write and the satisfaction it implies // commit together. @@ -993,17 +1114,26 @@ func resyncLeverageSatisfaction(tx *gorm.DB, downstreamSSPID, byComponentID uuid return err } - for _, providedUUID := range providedUUIDs { - var link relational.SSPLeverageLink - err := tx.Where("downstream_ssp_id = ? AND provided_uuid = ?", downstreamSSPID, providedUUID). - First(&link).Error - if errors.Is(err, gorm.ErrRecordNotFound) { + // The inherited row names the provided-impl its link is derived from; the link is reached + // through the row's id, so a sibling by-component holding the same provided-uuid is untouched. + providedByInherited := make(map[uuid.UUID]uuid.UUID, len(inherited)) + inheritedIDs := make([]uuid.UUID, 0, len(inherited)) + for _, i := range inherited { + providedByInherited[*i.ID] = i.ProvidedUuid + inheritedIDs = append(inheritedIDs, *i.ID) + } + + var links []relational.SSPLeverageLink + if err := tx.Where("downstream_ssp_id = ? AND inherited_uuid IN ?", downstreamSSPID, inheritedIDs). + Find(&links).Error; err != nil { + return err + } + + for _, link := range links { + providedUUID, ok := providedByInherited[link.InheritedUUID] + if !ok { continue } - if err != nil { - return err - } - satisfaction, _ := deriveSatisfaction(fullSetByProvided[providedUUID], satisfiedUUIDs) if satisfaction == link.Satisfaction { continue diff --git a/internal/api/handler/oscal/ssp_export_offerings.go b/internal/api/handler/oscal/ssp_export_offerings.go index 32a40a3e..1b02ed3d 100644 --- a/internal/api/handler/oscal/ssp_export_offerings.go +++ b/internal/api/handler/oscal/ssp_export_offerings.go @@ -1151,12 +1151,16 @@ func (h *SSPExportOfferingHandler) withResolvedResponsibilities(offerings []rela // @Failure 500 {object} api.Error // @Security OAuth2Password // @Router /oscal/ssp-export-offerings [get] -func (h *SSPExportOfferingHandler) ListAll(ctx echo.Context) error { - limit := defaultExportOfferingCatalogLimit +// +// parseExportOfferingPagination reads the shared limit/offset params for the export-offering +// catalog reads, capping limit at maxExportOfferingCatalogLimit. Shared by ListAll and ByControl +// so the two cannot drift into different bounds — or, as ByControl did, into none at all. +func parseExportOfferingPagination(ctx echo.Context) (limit int, offset int, err error) { + limit = defaultExportOfferingCatalogLimit if raw := strings.TrimSpace(ctx.QueryParam("limit")); raw != "" { - parsed, err := strconv.Atoi(raw) - if err != nil || parsed < 1 { - return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("invalid limit parameter"))) + parsed, convErr := strconv.Atoi(raw) + if convErr != nil || parsed < 1 { + return 0, 0, fmt.Errorf("invalid limit parameter") } if parsed > maxExportOfferingCatalogLimit { parsed = maxExportOfferingCatalogLimit @@ -1164,14 +1168,21 @@ func (h *SSPExportOfferingHandler) ListAll(ctx echo.Context) error { limit = parsed } - offset := 0 if raw := strings.TrimSpace(ctx.QueryParam("offset")); raw != "" { - parsed, err := strconv.Atoi(raw) - if err != nil || parsed < 0 { - return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("invalid offset parameter"))) + parsed, convErr := strconv.Atoi(raw) + if convErr != nil || parsed < 0 { + return 0, 0, fmt.Errorf("invalid offset parameter") } offset = parsed } + return limit, offset, nil +} + +func (h *SSPExportOfferingHandler) ListAll(ctx echo.Context) error { + limit, offset, err := parseExportOfferingPagination(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } var offerings []relational.SSPExportOffering if err := h.db.Preload("Items"). @@ -1298,6 +1309,8 @@ type ControlExportOffer struct { // @Produce json // @Param controlId path string true "Control ID (e.g. AC-2)" // @Param downstreamSspId query string false "Only return offerings this downstream SSP may subscribe to" +// @Param limit query int false "Max items to return (default 100, max 1000)" +// @Param offset query int false "Items to skip" // @Success 200 {object} handler.GenericDataListResponse[ControlExportOffer] // @Failure 400 {object} api.Error // @Failure 500 {object} api.Error @@ -1309,6 +1322,15 @@ func (h *SSPExportOfferingHandler) ByControl(ctx echo.Context) error { return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("controlId is required"))) } + // Same bounds as ListAll. A widely-offered control (AC-2 across a large tenant) would + // otherwise return every published item plus the batched resolution queries behind it in one + // unpaginated response; downstreamSspId does not bound it, being optional and applied after + // the fetch. + limit, offset, err := parseExportOfferingPagination(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + var downstreamSSPID *uuid.UUID if raw := strings.TrimSpace(ctx.QueryParam("downstreamSspId")); raw != "" { parsed, err := uuid.Parse(raw) @@ -1327,6 +1349,7 @@ func (h *SSPExportOfferingHandler) ByControl(ctx echo.Context) error { Where("ssp_export_offerings.status = ? AND UPPER(ssp_export_offering_items.control_id) = UPPER(?)", relational.SSPExportOfferingStatusPublished, controlID). Order("ssp_export_offering_items.id ASC"). + Limit(limit).Offset(offset). Find(&items).Error; err != nil { h.sugar.Errorf("Failed to list export offering items by control: %v", err) return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) diff --git a/internal/api/handler/oscal/ssp_leverage.go b/internal/api/handler/oscal/ssp_leverage.go index 09e38acb..d92ef82b 100644 --- a/internal/api/handler/oscal/ssp_leverage.go +++ b/internal/api/handler/oscal/ssp_leverage.go @@ -156,9 +156,10 @@ func deriveSatisfaction(full []upstreamResponsibility, satisfiedUUIDs map[uuid.U } // SSPLeverageHandler serves the downstream side of BCH-1338 Phase 2: subscribing to a -// published SSPExportOffering (recording OSCAL inherited + satisfied + a -// leveraged-authorization on the downstream SSP) and the read-only projection over what -// a downstream SSP has subscribed to. +// published SSPExportOffering (recording OSCAL inherited + satisfied rows on the +// downstream SSP) and the read-only projection over what a downstream SSP has subscribed +// to. Subscribe deliberately does NOT record a leveraged-authorization — sharing is +// decoupled from authorizations; see LeveragedAuthUUID on relational.SSPLeverageLink. type SSPLeverageHandler struct { sugar *zap.SugaredLogger db *gorm.DB diff --git a/internal/api/handler/oscal/ssp_leverage_drift_test.go b/internal/api/handler/oscal/ssp_leverage_drift_test.go index 20715842..0a86c72c 100644 --- a/internal/api/handler/oscal/ssp_leverage_drift_test.go +++ b/internal/api/handler/oscal/ssp_leverage_drift_test.go @@ -38,10 +38,10 @@ func seedLeverageLinkForDrift(t *testing.T, db *gorm.DB, offeringVersion int) (* require.NoError(t, db.Create(&offering).Error) link := relational.SSPLeverageLink{ - DownstreamSSPID: *downstreamSSP.ID, - UpstreamSSPID: *upstreamSSP.ID, - OfferingID: *offering.ID, - OfferingVersion: 1, + DownstreamSSPID: *downstreamSSP.ID, + UpstreamSSPID: *upstreamSSP.ID, + OfferingID: *offering.ID, + OfferingVersion: 1, ControlID: "ac-1", ProvidedUUID: *provided.ID, InheritedUUID: uuid.New(), diff --git a/internal/api/handler/oscal/ssp_shared_responsibility.go b/internal/api/handler/oscal/ssp_shared_responsibility.go index 52ed49ac..fdbd82aa 100644 --- a/internal/api/handler/oscal/ssp_shared_responsibility.go +++ b/internal/api/handler/oscal/ssp_shared_responsibility.go @@ -227,9 +227,16 @@ func (h *SystemSecurityPlanHandler) collectOwnedSharedResponsibility(rollup *Sha Find(&exports).Error; err != nil { return err } - exportByComponent := make(map[uuid.UUID]relational.Export, len(exports)) + // Keyed to a slice, not a single export: exports.by_component_id carries no unique + // constraint (that absence is why lockByComponentSubtreeWrite exists), and the lock is a + // no-op off Postgres and cannot retroactively fix rows written before it landed — so + // duplicates are possible in existing data. Last-write-wins would silently drop one, making a + // genuinely published capability render as not offered. The unique index is the eventual + // answer, but it cannot be added until existing data is cleaned: AutoMigrate would fail on + // boot against violating rows. + exportsByComponent := make(map[uuid.UUID][]relational.Export, len(exports)) for _, e := range exports { - exportByComponent[e.ByComponentId] = e + exportsByComponent[e.ByComponentId] = append(exportsByComponent[e.ByComponentId], e) } var satisfiedRows []relational.SatisfiedControlImplementationResponsibility @@ -330,7 +337,7 @@ func (h *SystemSecurityPlanHandler) collectOwnedSharedResponsibility(rollup *Sha // normal SSP created or imported through POST /api/oscal/import is full of them. // Reporting those would hand the UI an SSP's entire control implementation as debt and // invite the user to wind down their own work. - _, hasExport := exportByComponent[*bc.ID] + hasExport := len(exportsByComponent[*bc.ID]) > 0 hasInherited := inheritedByComponent[*bc.ID] hasSatisfied := satisfiedByComponent[*bc.ID] if !hasExport && !hasInherited && !hasSatisfied { @@ -349,40 +356,45 @@ func (h *SystemSecurityPlanHandler) collectOwnedSharedResponsibility(rollup *Sha continue } - export, hasExport := exportByComponent[*bc.ID] - if !hasExport { + bcExports := exportsByComponent[*bc.ID] + if len(bcExports) == 0 { continue } - provided := make([]controlExportProvided, 0, len(export.Provided)) - offered := false - for _, p := range export.Provided { - provided = append(provided, controlExportProvided{UUID: *p.ID, Description: p.Description}) - if offeredProvidedUUIDs[*p.ID] { - offered = true + // One entry per export. Duplicates on a by-component are malformed data rather than a + // supported shape, but reporting each is what makes them visible instead of silently + // halving the rollup. + for _, export := range bcExports { + provided := make([]controlExportProvided, 0, len(export.Provided)) + offered := false + for _, p := range export.Provided { + provided = append(provided, controlExportProvided{UUID: *p.ID, Description: p.Description}) + if offeredProvidedUUIDs[*p.ID] { + offered = true + } + } + + responsibilities := make([]controlExportResponsibility, 0, len(export.Responsibilities)) + for _, r := range export.Responsibilities { + responsibilities = append(responsibilities, controlExportResponsibility{ + UUID: *r.ID, + Description: r.Description, + ProvidedUUID: r.ProvidedUuid, + }) } - } - responsibilities := make([]controlExportResponsibility, 0, len(export.Responsibilities)) - for _, r := range export.Responsibilities { - responsibilities = append(responsibilities, controlExportResponsibility{ - UUID: *r.ID, - Description: r.Description, - ProvidedUUID: r.ProvidedUuid, + rollup.Provides = append(rollup.Provides, SharedResponsibilityProvides{ + ControlID: a.controlID, + StatementID: a.statementID, + ByComponentUUID: *bc.ID, + ComponentUUID: bc.ComponentUUID, + ComponentTitle: componentTitleByID[bc.ComponentUUID], + ExportUUID: *export.ID, + Provided: provided, + Responsibilities: responsibilities, + Offered: offered, }) } - - rollup.Provides = append(rollup.Provides, SharedResponsibilityProvides{ - ControlID: a.controlID, - StatementID: a.statementID, - ByComponentUUID: *bc.ID, - ComponentUUID: bc.ComponentUUID, - ComponentTitle: componentTitleByID[bc.ComponentUUID], - ExportUUID: *export.ID, - Provided: provided, - Responsibilities: responsibilities, - Offered: offered, - }) } for i := range satisfiedRows { diff --git a/internal/api/handler/oscal/ssp_shared_responsibility_test.go b/internal/api/handler/oscal/ssp_shared_responsibility_test.go index cffe6d7a..acfde612 100644 --- a/internal/api/handler/oscal/ssp_shared_responsibility_test.go +++ b/internal/api/handler/oscal/ssp_shared_responsibility_test.go @@ -71,11 +71,16 @@ func newSharedResponsibilityFixture(t *testing.T, db *gorm.DB) sharedResponsibil require.NoError(t, db.Create(&statement).Error) statementsType := "statements" + // Every scalar the PUT owns is populated, so a PUT that omits them has something to wipe: + // leaving props/links/set-parameters empty made the omitted-field regression invisible. byComponent := relational.ByComponent{ ParentID: statement.ID, ParentType: &statementsType, ComponentUUID: *component.ID, Description: "original description", Remarks: "original remarks", + Props: datatypes.NewJSONSlice([]relational.Prop{{Name: "original-prop", Value: "original-value"}}), + Links: datatypes.NewJSONSlice([]relational.Link{{Href: "https://example.com/original", Rel: "reference"}}), + SetParameters: datatypes.NewJSONSlice([]relational.SetParameter{{ParamId: "original-param", Values: []string{"original"}}}), } require.NoError(t, db.Create(&byComponent).Error) @@ -326,21 +331,15 @@ func TestSubscribeReportsCreatedTree(t *testing.T) { require.Equal(t, relational.SSPLeverageSatisfactionPartial, link.Satisfaction) } -// TestSubscribeReportsReusedRowsAsNotCreated: a second item on the same statement reuses the -// requirement, statement and by-component already materialized, and reports created:false. -func TestSubscribeReportsReusedRowsAsNotCreated(t *testing.T) { +// TestSubscribeDedupesReportedRowsWithinOneCall: two items landing on the same +// requirement/statement/by-component report each row once — as created, since the first item +// inserted it — rather than once per item. +func TestSubscribeDedupesReportedRowsWithinOneCall(t *testing.T) { db := newSSPLeverageTestDB(t) fx := newSharedResponsibilityFixture(t, db) - // A second provided capability on the same statement-anchored by-component, offered as a - // second item in the same offering. - provided2 := relational.ProvidedControlImplementation{ExportId: fx.exportID, Description: "second capability"} - require.NoError(t, db.Create(&provided2).Error) - item2 := relational.SSPExportOfferingItem{ - OfferingID: fx.offeringID, ControlID: "ac-2", StatementID: statementID("ac-2_smt.a"), - ComponentUUID: fx.componentID, ProvidedUUID: *provided2.ID, - } - require.NoError(t, db.Create(&item2).Error) + provided2, item2 := secondOfferingItemOnSameStatement(t, db, fx) + _ = provided2 pdp := &stubPDP{allow: true} h := NewSSPLeverageHandler(zap.NewNop().Sugar(), db, pdp, authz.FailClosed) @@ -358,14 +357,70 @@ func TestSubscribeReportsReusedRowsAsNotCreated(t *testing.T) { require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) require.Len(t, resp.Data, 2, "two items subscribed, two links") - // Both items land on the same requirement/statement/by-component: each is reported once, - // as created (the first item inserted it), not duplicated per item. require.Len(t, resp.Meta.Created.ImplementedRequirements, 1) require.Len(t, resp.Meta.Created.Statements, 1) require.Len(t, resp.Meta.Created.ByComponents, 1) require.True(t, resp.Meta.Created.ByComponents[0].Created) } +// TestSubscribeReportsReusedRowsAsNotCreated: a SECOND subscribe call reuses the requirement, +// statement and by-component the first call materialized, and reports created:false for each. +// +// It has to be a second call: within one call the rows genuinely are created, so created:true is +// correct there and cannot pin the negative. +func TestSubscribeReportsReusedRowsAsNotCreated(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + + _, item2 := secondOfferingItemOnSameStatement(t, db, fx) + + pdp := &stubPDP{allow: true} + h := NewSSPLeverageHandler(zap.NewNop().Sugar(), db, pdp, authz.FailClosed) + + // First call materializes the requirement, statement and by-component. + firstBody := fmt.Sprintf(`{"downstreamSspId":%q,"items":[{"itemId":%q}]}`, + fx.downstreamSSPID.String(), fx.itemID.String()) + firstCtx, _, firstRec := newSubscribeRequestContext(fx.offeringID, firstBody) + require.NoError(t, h.Subscribe(firstCtx)) + require.Equal(t, http.StatusCreated, firstRec.Code) + + // Second call subscribes a different item that lands on the same statement — every reported + // row is a reuse. + secondBody := fmt.Sprintf(`{"downstreamSspId":%q,"items":[{"itemId":%q}]}`, + fx.downstreamSSPID.String(), item2.ID.String()) + secondCtx, _, secondRec := newSubscribeRequestContext(fx.offeringID, secondBody) + require.NoError(t, h.Subscribe(secondCtx)) + require.Equal(t, http.StatusCreated, secondRec.Code) + + var resp struct { + Data []relational.SSPLeverageLink `json:"data"` + Meta subscribeMeta `json:"meta"` + } + require.NoError(t, json.Unmarshal(secondRec.Body.Bytes(), &resp)) + require.Len(t, resp.Data, 1) + + require.Len(t, resp.Meta.Created.ImplementedRequirements, 1) + require.False(t, resp.Meta.Created.ImplementedRequirements[0].Created, "the requirement was created by the first call") + require.Len(t, resp.Meta.Created.Statements, 1) + require.False(t, resp.Meta.Created.Statements[0].Created, "the statement was created by the first call") + require.Len(t, resp.Meta.Created.ByComponents, 1) + require.False(t, resp.Meta.Created.ByComponents[0].Created, "the by-component was created by the first call") +} + +// secondOfferingItemOnSameStatement adds a second provided capability on the same +// statement-anchored by-component, offered as a second item in the same offering. +func secondOfferingItemOnSameStatement(t *testing.T, db *gorm.DB, fx sharedResponsibilityFixture) (relational.ProvidedControlImplementation, relational.SSPExportOfferingItem) { + t.Helper() + provided2 := relational.ProvidedControlImplementation{ExportId: fx.exportID, Description: "second capability"} + require.NoError(t, db.Create(&provided2).Error) + item2 := relational.SSPExportOfferingItem{ + OfferingID: fx.offeringID, ControlID: "ac-2", StatementID: statementID("ac-2_smt.a"), + ComponentUUID: fx.componentID, ProvidedUUID: *provided2.ID, + } + require.NoError(t, db.Create(&item2).Error) + return provided2, item2 +} + // TestSubscribeReportsExistingRequirementAsNotCreated: when the downstream already implements // the control, the requirement is matched rather than inserted — created:false. func TestSubscribeReportsExistingRequirementAsNotCreated(t *testing.T) { @@ -516,6 +571,17 @@ func TestUpdateByComponentDoesNotClobberSubtreesOrOmittedFields(t *testing.T) { require.NoError(t, db.First(&stored, "id = ?", fx.byComponentID).Error) require.Equal(t, "updated description", stored.Description) + // Omitted SCALARS survive. This is the half the test's name promised but never checked: the + // PUT above sends only a description, so every one of these would be zeroed by a blind Save — + // or by a map-form Updates listing them unconditionally. + require.Equal(t, "original remarks", stored.Remarks, "an omitted scalar must not be wiped") + require.Len(t, stored.Props, 1, "omitted props must not be wiped") + require.Equal(t, "original-prop", stored.Props[0].Name) + require.Len(t, stored.Links, 1, "omitted links must not be wiped") + require.Equal(t, "https://example.com/original", stored.Links[0].Href) + require.Len(t, stored.SetParameters, 1, "omitted set-parameters must not be wiped") + require.Equal(t, "original-param", stored.SetParameters[0].ParamId) + // implementation-status is optional: a by-component that never had one must not gain an // empty {"state": ""} from a PUT that doesn't mention it. var updatedBC handler.GenericDataResponse[oscalTypes_1_1_3.ByComponent] diff --git a/internal/api/handler/oscal/subtree_lock_concurrency_integration_test.go b/internal/api/handler/oscal/subtree_lock_concurrency_integration_test.go new file mode 100644 index 00000000..18e4717f --- /dev/null +++ b/internal/api/handler/oscal/subtree_lock_concurrency_integration_test.go @@ -0,0 +1,228 @@ +//go:build integration + +package oscal + +import ( + "sync" + "testing" + + "github.com/compliance-framework/api/internal/service/relational" + "github.com/compliance-framework/api/internal/tests" + "github.com/google/uuid" + "github.com/stretchr/testify/suite" + "gorm.io/gorm" +) + +// SubtreeLockConcurrencySuite drives the race lockByComponentSubtreeWrite exists to prevent, +// against real Postgres. +// +// TestEveryLeverageSatisfactionWriterTakesTheSubtreeLock pins the invariant structurally, which is +// necessary but not sufficient: the lock is a Postgres advisory lock and a no-op under the sqlite +// unit driver, so every unit test in this package would stay green if the lock's body were replaced +// with `return nil`. Nothing else exercises two concurrent writers against one by-component. This +// suite closes that gap — it is the test that would actually go red. +type SubtreeLockConcurrencySuite struct { + tests.IntegrationTestSuite +} + +func TestSubtreeLockConcurrencySuite(t *testing.T) { + suite.Run(t, new(SubtreeLockConcurrencySuite)) +} + +func (suite *SubtreeLockConcurrencySuite) SetupTest() { + suite.Require().NoError(suite.Migrator.Refresh()) +} + +// TestConcurrentSatisfiedWritesConvergeOnProjection: two goroutines each add a satisfied row +// covering a different responsibility of the same inherited capability, concurrently. Whoever +// commits second must observe the first's row, so the stored Satisfaction must end up `full` — +// matching what the live projection derives. +// +// Without the lock this is a lost update: both transactions read the satisfied set before either +// inserts, both derive `partial` from their own single row, and the second write clobbers the +// first's — leaving `partial` stored while both responsibilities are in fact satisfied. The drift +// detector and the notification path consume that stored value. +func (suite *SubtreeLockConcurrencySuite) TestConcurrentSatisfiedWritesConvergeOnProjection() { + fx := suite.seedTwoResponsibilityFixture() + + // Both writers target the same by-component, each satisfying one of the two responsibilities. + var wg sync.WaitGroup + errs := make([]error, 2) + responsibilities := []uuid.UUID{fx.respAID, fx.respBID} + + wg.Add(len(responsibilities)) + for i, respID := range responsibilities { + go func(i int, respID uuid.UUID) { + defer wg.Done() + errs[i] = suite.DB.Transaction(func(tx *gorm.DB) error { + if err := lockByComponentSubtreeWrite(tx, fx.byComponentID); err != nil { + return err + } + satisfied := relational.SatisfiedControlImplementationResponsibility{ + ByComponentId: fx.byComponentID, + ResponsibilityUuid: respID, + Description: "satisfied concurrently", + } + if err := tx.Create(&satisfied).Error; err != nil { + return err + } + return resyncLeverageSatisfaction(tx, fx.downstreamSSPID, fx.byComponentID) + }) + }(i, respID) + } + wg.Wait() + + for i, err := range errs { + suite.Require().NoErrorf(err, "concurrent satisfied write %d failed", i) + } + + var satisfiedCount int64 + suite.Require().NoError(suite.DB.Model(&relational.SatisfiedControlImplementationResponsibility{}). + Where("by_component_id = ?", fx.byComponentID).Count(&satisfiedCount).Error) + suite.Require().Equal(int64(2), satisfiedCount, "both satisfied rows must persist") + + var link relational.SSPLeverageLink + suite.Require().NoError(suite.DB.First(&link, "id = ?", fx.linkID).Error) + suite.Equal(relational.SSPLeverageSatisfactionFull, link.Satisfaction, + "both responsibilities are satisfied, so the stored satisfaction must be full — "+ + "`partial` means one writer's derivation overwrote the other's with a stale value") +} + +// TestConcurrentSatisfiedWriteAndInheritedDeleteDoNotDangle: a satisfied create and a delete of the +// inherited row it depends on, racing. Whichever order they serialize in, the result must be +// coherent — the delete is either refused (the link owns the row) or the satisfied row is rejected +// as no longer inheritable. What must never happen is a surviving link whose inherited row is gone. +func (suite *SubtreeLockConcurrencySuite) TestConcurrentSatisfiedWriteAndInheritedDeleteDoNotDangle() { + fx := suite.seedTwoResponsibilityFixture() + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + _ = suite.DB.Transaction(func(tx *gorm.DB) error { + if err := lockByComponentSubtreeWrite(tx, fx.byComponentID); err != nil { + return err + } + satisfied := relational.SatisfiedControlImplementationResponsibility{ + ByComponentId: fx.byComponentID, + ResponsibilityUuid: fx.respAID, + Description: "satisfied during delete", + } + if err := tx.Create(&satisfied).Error; err != nil { + return err + } + return resyncLeverageSatisfaction(tx, fx.downstreamSSPID, fx.byComponentID) + }) + }() + + go func() { + defer wg.Done() + _ = suite.DB.Transaction(func(tx *gorm.DB) error { + if err := lockByComponentSubtreeWrite(tx, fx.byComponentID); err != nil { + return err + } + if err := assertInheritedNotSubscribed(tx, []uuid.UUID{fx.inheritedID}); err != nil { + return err + } + return tx.Delete(&relational.InheritedControlImplementation{}, "id = ?", fx.inheritedID).Error + }) + }() + + wg.Wait() + + // The link is owned by a subscription, so the delete must have been refused outright: the + // inherited row survives and nothing dangles. + var inheritedCount int64 + suite.Require().NoError(suite.DB.Model(&relational.InheritedControlImplementation{}). + Where("id = ?", fx.inheritedID).Count(&inheritedCount).Error) + suite.Equal(int64(1), inheritedCount, + "the inherited row is referenced by a leverage link, so it must never be deleted") + + var link relational.SSPLeverageLink + suite.Require().NoError(suite.DB.First(&link, "id = ?", fx.linkID).Error) + suite.Equal(fx.inheritedID, link.InheritedUUID, "link.InheritedUUID must still resolve") +} + +type subtreeLockFixture struct { + downstreamSSPID uuid.UUID + byComponentID uuid.UUID + inheritedID uuid.UUID + linkID uuid.UUID + respAID uuid.UUID + respBID uuid.UUID +} + +// seedTwoResponsibilityFixture builds an upstream capability carrying TWO responsibilities, a +// downstream by-component inheriting it, and the leverage link — the minimum shape where +// satisfaction is genuinely derived (partial vs full) rather than trivially constant. +func (suite *SubtreeLockConcurrencySuite) seedTwoResponsibilityFixture() subtreeLockFixture { + db := suite.DB + + upstreamSSP := relational.SystemSecurityPlan{} + suite.Require().NoError(db.Create(&upstreamSSP).Error) + upstreamImpl := relational.ControlImplementation{SystemSecurityPlanId: *upstreamSSP.ID} + suite.Require().NoError(db.Create(&upstreamImpl).Error) + upstreamReq := relational.ImplementedRequirement{ControlImplementationId: *upstreamImpl.ID, ControlId: "ac-1"} + suite.Require().NoError(db.Create(&upstreamReq).Error) + upstreamStmt := relational.Statement{ImplementedRequirementId: *upstreamReq.ID, StatementId: "ac-1_smt.a"} + suite.Require().NoError(db.Create(&upstreamStmt).Error) + + statementsType := "statements" + upstreamBC := relational.ByComponent{ParentID: upstreamStmt.ID, ParentType: &statementsType} + suite.Require().NoError(db.Create(&upstreamBC).Error) + + export := relational.Export{ByComponentId: *upstreamBC.ID} + suite.Require().NoError(db.Create(&export).Error) + provided := relational.ProvidedControlImplementation{ExportId: *export.ID, Description: "provided"} + suite.Require().NoError(db.Create(&provided).Error) + + respA := relational.ControlImplementationResponsibility{ + ExportId: *export.ID, ProvidedUuid: *provided.ID, Description: "responsibility A", + } + suite.Require().NoError(db.Create(&respA).Error) + respB := relational.ControlImplementationResponsibility{ + ExportId: *export.ID, ProvidedUuid: *provided.ID, Description: "responsibility B", + } + suite.Require().NoError(db.Create(&respB).Error) + + // Downstream side. + downstreamSSP := relational.SystemSecurityPlan{} + suite.Require().NoError(db.Create(&downstreamSSP).Error) + downstreamImpl := relational.ControlImplementation{SystemSecurityPlanId: *downstreamSSP.ID} + suite.Require().NoError(db.Create(&downstreamImpl).Error) + downstreamReq := relational.ImplementedRequirement{ControlImplementationId: *downstreamImpl.ID, ControlId: "ac-1"} + suite.Require().NoError(db.Create(&downstreamReq).Error) + downstreamStmt := relational.Statement{ImplementedRequirementId: *downstreamReq.ID, StatementId: "ac-1_smt.a"} + suite.Require().NoError(db.Create(&downstreamStmt).Error) + downstreamBC := relational.ByComponent{ParentID: downstreamStmt.ID, ParentType: &statementsType} + suite.Require().NoError(db.Create(&downstreamBC).Error) + + inherited := relational.InheritedControlImplementation{ + ByComponentId: *downstreamBC.ID, ProvidedUuid: *provided.ID, Description: "inherited", + } + suite.Require().NoError(db.Create(&inherited).Error) + + // Starts `partial`: nothing is satisfied yet, so a stale value is distinguishable from a + // freshly derived one. + link := relational.SSPLeverageLink{ + DownstreamSSPID: *downstreamSSP.ID, + UpstreamSSPID: *upstreamSSP.ID, + OfferingID: uuid.New(), + ControlID: "ac-1", + ProvidedUUID: *provided.ID, + InheritedUUID: *inherited.ID, + Satisfaction: relational.SSPLeverageSatisfactionPartial, + Status: relational.SSPLeverageStatusActive, + } + suite.Require().NoError(db.Create(&link).Error) + + return subtreeLockFixture{ + downstreamSSPID: *downstreamSSP.ID, + byComponentID: *downstreamBC.ID, + inheritedID: *inherited.ID, + linkID: *link.ID, + respAID: *respA.ID, + respBID: *respB.ID, + } +} diff --git a/internal/api/handler/oscal/subtree_lock_invariant_test.go b/internal/api/handler/oscal/subtree_lock_invariant_test.go index 5c90c51c..6725bc51 100644 --- a/internal/api/handler/oscal/subtree_lock_invariant_test.go +++ b/internal/api/handler/oscal/subtree_lock_invariant_test.go @@ -4,75 +4,173 @@ import ( "go/ast" "go/parser" "go/token" + "os" + "strings" "testing" "github.com/stretchr/testify/require" ) +const subtreeLockCall = "lockByComponentSubtreeWrite" + +// knownLeverageSatisfactionWriters is the set of writers this invariant was written against. It is +// NOT the input to the check — the check DISCOVERS writers by walking the package (see +// discoverLeverageSatisfactionWriters). This map only asserts that the discovery still finds the +// five we know about, so a discovery walk quietly broken by a refactor fails loudly instead of +// passing vacuously. +var knownLeverageSatisfactionWriters = []string{ + // Derives inline (does not go through resyncLeverageSatisfaction), so its lock is + // load-bearing on its own. + "ReAttest", + // Reuses an existing by-component that may already carry other links' inherited/satisfied + // rows, and resync rewrites the satisfaction of EVERY link on it — so a stale value here + // corrupts a pre-existing link, not the one being created. + "Subscribe", + // The read-modify-write itself. Self-locking, so its callers cannot forget. + "resyncLeverageSatisfaction", + // Lock taken early, to cover their own insert/delete as well as the derivation. + "CreateImplementedRequirementStatementByComponentSatisfied", + "DeleteImplementedRequirementStatementByComponentSatisfied", +} + // TestEveryLeverageSatisfactionWriterTakesTheSubtreeLock pins the invariant // lockByComponentSubtreeWrite exists to enforce: every writer that performs a read-modify-write // over a by-component's subtree — reading its satisfied set and UPDATEing SSPLeverageLink.Satisfaction // with a value computed in Go — must take the lock, or a concurrent writer's freshly-derived value // gets overwritten with a stale one. // -// This is asserted structurally rather than by exercising a race because the lock is a Postgres -// advisory lock and a no-op under the sqlite unit driver, so no unit test can observe it. The -// invariant has been half-missed twice (the satisfied DELETE, then Subscribe — each time while the -// comment claimed full coverage), and "partial adoption is worse than useless" is precisely the -// failure this guards: three of four writers locking reads as though the subtree were safe. +// Writers are DISCOVERED, not listed: the check walks every file in the package for functions that +// reach a satisfaction derivation, and requires each to take the lock. An earlier version iterated a +// hand-maintained allowlist, which could not fail for the regression it existed to catch — a sixth +// writer added without the lock was simply never examined, and "someone forgot" is exactly the +// failure mode here (the invariant has been half-missed twice, each time while the comment claimed +// full coverage). // -// resyncLeverageSatisfaction takes the lock itself, so the three writers that derive through it are -// covered by construction. It is still asserted here — if someone removes it from resync as -// "redundant", this test names the writers that would silently lose their protection. +// This is asserted structurally rather than by exercising a race because the lock is a Postgres +// advisory lock and a no-op under the sqlite unit driver, so no *unit* test can observe it. A +// Postgres-backed integration test can — see TestConcurrentSatisfiedWritesConvergeOnProjection in +// the integration suite, which drives the real race this structure stands in for. func TestEveryLeverageSatisfactionWriterTakesTheSubtreeLock(t *testing.T) { - const lockCall = "lockByComponentSubtreeWrite" + files := parsePackageFiles(t) - // Every function that reaches a satisfaction derivation, and the file it lives in. - writers := map[string]string{ - // Derives inline (does not go through resyncLeverageSatisfaction), so its lock is - // load-bearing on its own. - "ReAttest": "ssp_leverage.go", - // Reuses an existing by-component that may already carry other links' inherited/satisfied - // rows, and resync rewrites the satisfaction of EVERY link on it — so a stale value here - // corrupts a pre-existing link, not the one being created. - "Subscribe": "ssp_leverage.go", - // The read-modify-write itself. Self-locking, so its callers cannot forget. - "resyncLeverageSatisfaction": "ssp_by_components.go", - // Lock taken early, to cover their own insert/delete as well as the derivation. - "CreateImplementedRequirementStatementByComponentSatisfied": "ssp_by_components.go", - "DeleteImplementedRequirementStatementByComponentSatisfied": "ssp_by_components.go", - } + writers := discoverLeverageSatisfactionWriters(files) + require.NotEmpty(t, writers, "discovery found no satisfaction writers at all — the walk is broken, not the code") - fset := token.NewFileSet() - for fn, file := range writers { - parsed, err := parser.ParseFile(fset, file, nil, 0) - require.NoErrorf(t, err, "parsing %s", file) - - decl := findFuncDecl(parsed, fn) - require.NotNilf(t, decl, "expected to find %s in %s — if it was renamed, update this test rather than deleting the case", fn, file) - - require.Truef(t, callsFunc(decl, lockCall), - "%s (%s) performs a read-modify-write over a by-component's subtree but never calls %s.\n"+ + for name, decl := range writers { + require.Truef(t, callsFunc(decl, subtreeLockCall), + "%s performs a read-modify-write over a by-component's subtree but never calls %s.\n"+ "Every such writer must take the lock: it reads the satisfied set and UPDATEs "+ "SSPLeverageLink.Satisfaction with a value computed in Go, so skipping the lock lets it "+ "overwrite a concurrent writer's freshly-derived value with a stale one. Partial adoption "+ "is worse than none — it serializes only the writers that opted in while reading as "+ - "though the subtree were safe.", fn, file, lockCall) + "though the subtree were safe.", name, subtreeLockCall) + } + + // The discovery must still see everything we already knew about. + for _, known := range knownLeverageSatisfactionWriters { + require.Containsf(t, writers, known, + "discovery no longer finds %s. Either it was renamed (update knownLeverageSatisfactionWriters), "+ + "or discoverLeverageSatisfactionWriters has stopped recognising a derivation and this test "+ + "has gone vacuous.", known) } } -func findFuncDecl(file *ast.File, name string) *ast.FuncDecl { - for _, decl := range file.Decls { - fn, ok := decl.(*ast.FuncDecl) - if ok && fn.Name.Name == name { - return fn +// parsePackageFiles parses every non-test .go file in this package. +func parsePackageFiles(t *testing.T) []*ast.File { + t.Helper() + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, ".", func(fi os.FileInfo) bool { + return !strings.HasSuffix(fi.Name(), "_test.go") + }, 0) + require.NoError(t, err, "parsing package") + + pkg, ok := pkgs["oscal"] + require.True(t, ok, "package oscal not found in current directory") + + files := make([]*ast.File, 0, len(pkg.Files)) + for _, f := range pkg.Files { + files = append(files, f) + } + return files +} + +// discoverLeverageSatisfactionWriters finds every function that reaches a satisfaction derivation: +// either by calling resyncLeverageSatisfaction, or by deriving inline — writing +// SSPLeverageLink.Satisfaction via an Update/Updates/Save whose target names the satisfaction column. +// +// resyncLeverageSatisfaction itself is included: it IS the read-modify-write, and it self-locks so +// its callers cannot forget. If someone removes that lock as "redundant", this catches it. +func discoverLeverageSatisfactionWriters(files []*ast.File) map[string]*ast.FuncDecl { + writers := map[string]*ast.FuncDecl{} + for _, file := range files { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + if callsFunc(fn, "resyncLeverageSatisfaction") || writesLinkSatisfaction(fn) { + writers[fn.Name.Name] = fn + } } } - return nil + return writers +} + +// writesLinkSatisfaction reports whether decl contains a GORM write naming the satisfaction column +// — i.e. it derives the value inline rather than delegating to resyncLeverageSatisfaction. +func writesLinkSatisfaction(decl *ast.FuncDecl) bool { + found := false + ast.Inspect(decl, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + switch sel.Sel.Name { + case "Update", "Updates", "Save": + default: + return true + } + for _, arg := range call.Args { + if mentionsSatisfaction(arg) { + found = true + return false + } + } + return true + }) + return found +} + +// mentionsSatisfaction reports whether an expression names the satisfaction column or field, +// covering both Update("satisfaction", v) and Updates(map[string]any{"satisfaction": v}). +func mentionsSatisfaction(expr ast.Node) bool { + found := false + ast.Inspect(expr, func(n ast.Node) bool { + switch v := n.(type) { + case *ast.BasicLit: + if v.Kind == token.STRING && strings.Contains(strings.ToLower(v.Value), "satisfaction") { + found = true + return false + } + case *ast.Ident: + if strings.Contains(strings.ToLower(v.Name), "satisfaction") { + found = true + return false + } + } + return true + }) + return found } // callsFunc reports whether decl's body contains a call to the named function, including inside // closures (the handlers do their work inside a db.Transaction(func(tx *gorm.DB) error { ... })). +// Both bare calls (foo()) and selector calls (pkg.foo(), h.foo()) count — matching only bare idents +// would go silently blind the moment a call is refactored behind a receiver. func callsFunc(decl *ast.FuncDecl, name string) bool { found := false ast.Inspect(decl, func(n ast.Node) bool { @@ -80,9 +178,17 @@ func callsFunc(decl *ast.FuncDecl, name string) bool { if !ok { return true } - if ident, ok := call.Fun.(*ast.Ident); ok && ident.Name == name { - found = true - return false + switch fun := call.Fun.(type) { + case *ast.Ident: + if fun.Name == name { + found = true + return false + } + case *ast.SelectorExpr: + if fun.Sel.Name == name { + found = true + return false + } } return true }) diff --git a/internal/api/handler/oscal/system_security_plans.go b/internal/api/handler/oscal/system_security_plans.go index 83fada0b..260fcdda 100644 --- a/internal/api/handler/oscal/system_security_plans.go +++ b/internal/api/handler/oscal/system_security_plans.go @@ -1,11 +1,14 @@ package oscal import ( + "bytes" "context" "crypto/sha256" "encoding/binary" + "encoding/json" "errors" "fmt" + "io" "net/http" "strings" "sync" @@ -4408,7 +4411,26 @@ func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementStatementByCompo // (409-on-subscription-owned-inherited, satisfaction re-derivation) the sub-resource routes // enforce. So: only the fields this route owns are written, and nested subtrees in the body // are ignored rather than half-applied. +// +// The omitted-field half is merge semantics, and it has to be driven by the raw body: a decoded +// struct cannot distinguish "absent" from "present and empty", and GORM's map-form Updates writes +// every key it is given unconditionally (the zero-value skipping people expect applies only to +// struct-form Updates). So the update map is built from the keys the client actually sent. func (h *SystemSecurityPlanHandler) updateByComponentMetadata(ctx echo.Context, bc *relational.ByComponent) error { + // Read the body before Bind consumes it, then hand it back for binding. + rawBody, err := io.ReadAll(ctx.Request().Body) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + ctx.Request().Body = io.NopCloser(bytes.NewReader(rawBody)) + + present := map[string]json.RawMessage{} + if len(bytes.TrimSpace(rawBody)) > 0 { + if err := json.Unmarshal(rawBody, &present); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + } + var oscalBC oscalTypes_1_1_3.ByComponent if err := ctx.Bind(&oscalBC); err != nil { return ctx.JSON(http.StatusBadRequest, api.NewError(err)) @@ -4442,19 +4464,39 @@ func (h *SystemSecurityPlanHandler) updateByComponentMetadata(ctx echo.Context, return ctx.JSON(http.StatusBadRequest, api.NewError(err)) } + // Only the fields the body actually carried. An omitted key is left as stored; sending an + // explicit null/empty value still clears it, which is how a client asks for that. + updates := map[string]any{} + if _, ok := present["description"]; ok { + updates["description"] = parsed.Description + } + if _, ok := present["remarks"]; ok { + updates["remarks"] = parsed.Remarks + } + if _, ok := present["props"]; ok { + updates["props"] = parsed.Props + } + if _, ok := present["links"]; ok { + updates["links"] = parsed.Links + } + if _, ok := present["set-parameters"]; ok { + updates["set_parameters"] = parsed.SetParameters + } + if _, ok := present["implementation-status"]; ok { + updates["implementation_status"] = parsed.ImplementationStatus + } + if err := h.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&relational.ByComponent{}). - Where("id = ?", bc.ID). - Updates(map[string]any{ - "description": parsed.Description, - "remarks": parsed.Remarks, - "props": parsed.Props, - "links": parsed.Links, - "set_parameters": parsed.SetParameters, - "implementation_status": parsed.ImplementationStatus, - }).Error; err != nil { - return err + if len(updates) > 0 { + if err := tx.Model(&relational.ByComponent{}). + Where("id = ?", bc.ID). + Updates(updates).Error; err != nil { + return err + } } + // responsible-roles stays REPLACE, not merge: an omitted roles list clears the stored + // roles. That is deliberate and pinned by TestUpdateByComponentReplacesResponsibleRoles — + // it is a collection this route owns outright, unlike the scalars above. return replaceResponsibleRoles(tx, bc, *bc.ID, parsed.ResponsibleRoles) }); err != nil { h.sugar.Errorf("Failed to update by-component: %v", err) @@ -5083,6 +5125,16 @@ func deleteResponsibleRoles(tx *gorm.DB, roles []relational.ResponsibleRole) err // cascades at the DB level, so it is deleted explicitly, leaves-first, inside // the caller's transaction. func deleteByComponentCascade(tx *gorm.DB, byComponentID uuid.UUID) error { + // Taken before the preload, so the set this function reads is the set it deletes. Without it + // the guard below is a pre-check against a snapshot while the deletes take a fresh one: a + // Subscribe committing in between would have its inherited row destroyed and its link left + // dangling, which is the exact invariant the guard exists to hold. Subscribe takes this same + // lock, so the two serialize; advisory locks are re-entrant within a transaction, so callers + // already holding it pay nothing. + if err := lockByComponentSubtreeWrite(tx, byComponentID); err != nil { + return err + } + var bc relational.ByComponent if err := tx. Preload("ResponsibleRoles.Parties"). @@ -5103,42 +5155,46 @@ func deleteByComponentCascade(tx *gorm.DB, byComponentID uuid.UUID) error { // and requirement-level by-component DELETEs, and the requirement DELETE that cascades through // them. Guarding only the sub-resource route left the invariant trivially bypassable by // deleting the parent instead. Callers map this to 409, same as the sub-resource route. - if len(bc.Inherited) > 0 { - inheritedIDs := make([]uuid.UUID, 0, len(bc.Inherited)) - for i := range bc.Inherited { - inheritedIDs = append(inheritedIDs, *bc.Inherited[i].ID) - } - var linkCount int64 - if err := tx.Model(&relational.SSPLeverageLink{}). - Where("inherited_uuid IN ?", inheritedIDs). - Count(&linkCount).Error; err != nil { - return err - } - if linkCount > 0 { - return errInheritedOwnedBySubscription - } + inheritedIDs := make([]uuid.UUID, 0, len(bc.Inherited)) + for i := range bc.Inherited { + inheritedIDs = append(inheritedIDs, *bc.Inherited[i].ID) + } + if err := assertInheritedNotSubscribed(tx, inheritedIDs); err != nil { + return err } if err := deleteResponsibleRoles(tx, bc.ResponsibleRoles); err != nil { return err } + // Deletes are scoped to the ids checked above rather than to by_component_id, so the rows + // destroyed are exactly the rows guarded — and exactly the rows whose responsible_roles were + // cleaned up. A bulk delete would take a fresh snapshot, orphaning the roles of any row that + // appeared after the preload. for _, inherited := range bc.Inherited { if err := deleteResponsibleRoles(tx, inherited.ResponsibleRoles); err != nil { return err } } - if err := tx.Where("by_component_id = ?", bc.ID).Delete(&relational.InheritedControlImplementation{}).Error; err != nil { - return err + if len(inheritedIDs) > 0 { + if err := tx.Where("id IN ?", inheritedIDs).Delete(&relational.InheritedControlImplementation{}).Error; err != nil { + return err + } } + satisfiedIDs := make([]uuid.UUID, 0, len(bc.Satisfied)) + for i := range bc.Satisfied { + satisfiedIDs = append(satisfiedIDs, *bc.Satisfied[i].ID) + } for _, satisfied := range bc.Satisfied { if err := deleteResponsibleRoles(tx, satisfied.ResponsibleRoles); err != nil { return err } } - if err := tx.Where("by_component_id = ?", bc.ID).Delete(&relational.SatisfiedControlImplementationResponsibility{}).Error; err != nil { - return err + if len(satisfiedIDs) > 0 { + if err := tx.Where("id IN ?", satisfiedIDs).Delete(&relational.SatisfiedControlImplementationResponsibility{}).Error; err != nil { + return err + } } if bc.Export != nil { diff --git a/internal/service/migrator.go b/internal/service/migrator.go index 71bcbfb5..a162fca8 100644 --- a/internal/service/migrator.go +++ b/internal/service/migrator.go @@ -480,9 +480,24 @@ func MigrateUpWithConfig(db *gorm.DB, cfg *config.Config) error { // Sharing is decoupled from Leveraged Authorizations: subscribe no longer creates // an LA, so ssp_leverage_links.leveraged_auth_uuid must be nullable. AutoMigrate - // never relaxes an existing NOT NULL, so drop it explicitly (idempotent). + // never relaxes an existing NOT NULL, so drop it explicitly. + // + // Guarded so it is a genuine no-op once the constraint is gone, not merely idempotent: + // a bare ALTER TABLE takes an ACCESS EXCLUSIVE lock on every boot forever, and with no + // lock_timeout set anywhere a rolling deploy that starts behind a long-running query + // would queue on it and block all reads and writes to the table until it cleared. if err := db.Exec(` - ALTER TABLE ssp_leverage_links ALTER COLUMN leveraged_auth_uuid DROP NOT NULL + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'ssp_leverage_links' + AND column_name = 'leveraged_auth_uuid' + AND is_nullable = 'NO' + ) THEN + ALTER TABLE ssp_leverage_links ALTER COLUMN leveraged_auth_uuid DROP NOT NULL; + END IF; + END $$; `).Error; err != nil { return err } @@ -718,6 +733,50 @@ func migrateBackfillCatalogActive(db *gorm.DB) error { Update("active", true).Error } +const ( + // backfillBatchSize bounds how many legacy rows are held in memory at once. + backfillBatchSize = 500 + // inClauseChunkSize bounds an IN-list so it cannot approach Postgres's 65535 + // bind-parameter ceiling per statement. Well under it, since several IN-lists are built + // per batch and a migration error is fatal to boot. + inClauseChunkSize = 1000 +) + +// uniqueUUIDs extracts the deduplicated set of UUIDs from items, preserving first-seen order. +// Deduping matters here beyond tidiness: several provided-impls routinely share one export, so an +// un-deduped IN-list inflates for no reason and reaches the bind ceiling sooner. +// +// (handler/oscal has an identical helper, but it is unexported and lives in an HTTP handler +// package this one must not depend on.) +func uniqueUUIDs[T any](items []T, extract func(T) uuid.UUID) []uuid.UUID { + out := make([]uuid.UUID, 0, len(items)) + seen := make(map[uuid.UUID]bool, len(items)) + for _, item := range items { + id := extract(item) + if seen[id] { + continue + } + seen[id] = true + out = append(out, id) + } + return out +} + +// findByIDsInChunks runs `id IN ?` over ids in bounded chunks, appending every chunk's rows into +// out (which must be a pointer to a slice). A single unbounded IN-list is what puts the +// bind-parameter ceiling in reach. +func findByIDsInChunks[T any](db *gorm.DB, ids []uuid.UUID, out *[]T) error { + for start := 0; start < len(ids); start += inClauseChunkSize { + end := min(start+inClauseChunkSize, len(ids)) + var chunk []T + if err := db.Where("id IN ?", ids[start:end]).Find(&chunk).Error; err != nil { + return err + } + *out = append(*out, chunk...) + } + return nil +} + // migrateBackfillOfferingItemStatementIDs normalizes legacy ssp_export_offering_items // rows written before the statement became the canonical anchor for shared responsibility. // For each item with a NULL statement_id it walks provided_uuid -> export -> by_component @@ -726,117 +785,136 @@ func migrateBackfillCatalogActive(db *gorm.DB) error { // // A requirement-anchored by-component has no statement to derive, so those rows keep a NULL // statement_id: they stay readable and deletable, but Subscribe now rejects them with a 422 -// rather than silently falling back to requirement-anchoring. The count of both outcomes is -// logged so an operator can see how much legacy data still needs winding down. +// rather than silently falling back to requirement-anchoring. Outcomes are counted per cause and +// logged so an operator can see how much legacy data still needs winding down — and, crucially, +// can tell the two apart: a requirement-anchored row is expected legacy shape awaiting curation, +// while a dangling provided-uuid is referential corruption that warrants investigation. The +// affected item ids are logged at debug so there is something to act on. // // The DB column stays nullable on purpose — statement_id is enforced on the write path // (createExportOfferingItemRequest.validate), not by the schema, so legacy rows survive. +// +// Runs in batches inside one transaction: statement_id carries no index, so the scan is +// unavoidable, but loading every legacy row at once (and building unbounded IN-lists from the +// result) risked both memory and the 65535 bind-parameter ceiling — past which this returns an +// error, and a migration error is fatal to boot (cmd/run.go). A single transaction also means a +// mid-run failure rolls back cleanly instead of leaving a partial backfill. func migrateBackfillOfferingItemStatementIDs(db *gorm.DB) error { if !db.Migrator().HasTable(&relational.SSPExportOfferingItem{}) { return nil } - var items []relational.SSPExportOfferingItem - if err := db.Where("statement_id IS NULL").Find(&items).Error; err != nil { - return fmt.Errorf("failed to load legacy offering items: %w", err) - } - if len(items) == 0 { - return nil - } + var backfilled, danglingProvided, missingExport, requirementAnchored, emptyStatementID int + var undeterminableIDs []uuid.UUID - providedUUIDs := make([]uuid.UUID, 0, len(items)) - seen := make(map[uuid.UUID]bool, len(items)) - for _, item := range items { - if !seen[item.ProvidedUUID] { - seen[item.ProvidedUUID] = true - providedUUIDs = append(providedUUIDs, item.ProvidedUUID) - } - } + if err := db.Transaction(func(tx *gorm.DB) error { + var items []relational.SSPExportOfferingItem + return tx.Where("statement_id IS NULL").FindInBatches(&items, backfillBatchSize, func(batch *gorm.DB, _ int) error { + providedUUIDs := uniqueUUIDs(items, func(i relational.SSPExportOfferingItem) uuid.UUID { return i.ProvidedUUID }) - var provided []relational.ProvidedControlImplementation - if err := db.Where("id IN ?", providedUUIDs).Find(&provided).Error; err != nil { - return fmt.Errorf("failed to load provided control implementations: %w", err) - } - exportIDByProvided := make(map[uuid.UUID]uuid.UUID, len(provided)) - exportIDs := make([]uuid.UUID, 0, len(provided)) - for _, p := range provided { - exportIDByProvided[*p.ID] = p.ExportId - exportIDs = append(exportIDs, p.ExportId) - } + var provided []relational.ProvidedControlImplementation + if err := findByIDsInChunks(tx, providedUUIDs, &provided); err != nil { + return fmt.Errorf("failed to load provided control implementations: %w", err) + } + exportIDByProvided := make(map[uuid.UUID]uuid.UUID, len(provided)) + for _, p := range provided { + exportIDByProvided[*p.ID] = p.ExportId + } - var exports []relational.Export - if err := db.Where("id IN ?", exportIDs).Find(&exports).Error; err != nil { - return fmt.Errorf("failed to load exports: %w", err) - } - byComponentIDByExport := make(map[uuid.UUID]uuid.UUID, len(exports)) - byComponentIDs := make([]uuid.UUID, 0, len(exports)) - for _, e := range exports { - byComponentIDByExport[*e.ID] = e.ByComponentId - byComponentIDs = append(byComponentIDs, e.ByComponentId) - } + var exports []relational.Export + if err := findByIDsInChunks(tx, uniqueUUIDs(provided, func(p relational.ProvidedControlImplementation) uuid.UUID { return p.ExportId }), &exports); err != nil { + return fmt.Errorf("failed to load exports: %w", err) + } + byComponentIDByExport := make(map[uuid.UUID]uuid.UUID, len(exports)) + for _, e := range exports { + byComponentIDByExport[*e.ID] = e.ByComponentId + } - var byComponents []relational.ByComponent - if err := db.Where("id IN ?", byComponentIDs).Find(&byComponents).Error; err != nil { - return fmt.Errorf("failed to load by-components: %w", err) - } - statementIDByComponent := make(map[uuid.UUID]uuid.UUID, len(byComponents)) - statementRowIDs := make([]uuid.UUID, 0, len(byComponents)) - for _, bc := range byComponents { - if bc.ParentType == nil || *bc.ParentType != "statements" || bc.ParentID == nil { - continue - } - statementIDByComponent[*bc.ID] = *bc.ParentID - statementRowIDs = append(statementRowIDs, *bc.ParentID) - } + var byComponents []relational.ByComponent + if err := findByIDsInChunks(tx, uniqueUUIDs(exports, func(e relational.Export) uuid.UUID { return e.ByComponentId }), &byComponents); err != nil { + return fmt.Errorf("failed to load by-components: %w", err) + } + statementIDByComponent := make(map[uuid.UUID]uuid.UUID, len(byComponents)) + statementRowIDs := make([]uuid.UUID, 0, len(byComponents)) + for _, bc := range byComponents { + if bc.ParentType == nil || *bc.ParentType != "statements" || bc.ParentID == nil { + continue + } + statementIDByComponent[*bc.ID] = *bc.ParentID + statementRowIDs = append(statementRowIDs, *bc.ParentID) + } - var statements []relational.Statement - if len(statementRowIDs) > 0 { - if err := db.Where("id IN ?", statementRowIDs).Find(&statements).Error; err != nil { - return fmt.Errorf("failed to load statements: %w", err) - } - } - statementIDByRow := make(map[uuid.UUID]string, len(statements)) - for _, s := range statements { - statementIDByRow[*s.ID] = s.StatementId - } + var statements []relational.Statement + if err := findByIDsInChunks(tx, uniqueUUIDs(statementRowIDs, func(id uuid.UUID) uuid.UUID { return id }), &statements); err != nil { + return fmt.Errorf("failed to load statements: %w", err) + } + statementIDByRow := make(map[uuid.UUID]string, len(statements)) + for _, s := range statements { + statementIDByRow[*s.ID] = s.StatementId + } - backfilled, undeterminable := 0, 0 - for _, item := range items { - exportID, ok := exportIDByProvided[item.ProvidedUUID] - if !ok { - undeterminable++ - continue - } - byComponentID, ok := byComponentIDByExport[exportID] - if !ok { - undeterminable++ - continue - } - statementRowID, ok := statementIDByComponent[byComponentID] - if !ok { - undeterminable++ - continue - } - statementID, ok := statementIDByRow[statementRowID] - if !ok || statementID == "" { - undeterminable++ - continue - } + for _, item := range items { + exportID, ok := exportIDByProvided[item.ProvidedUUID] + if !ok { + danglingProvided++ + undeterminableIDs = append(undeterminableIDs, *item.ID) + continue + } + byComponentID, ok := byComponentIDByExport[exportID] + if !ok { + missingExport++ + undeterminableIDs = append(undeterminableIDs, *item.ID) + continue + } + statementRowID, ok := statementIDByComponent[byComponentID] + if !ok { + requirementAnchored++ + undeterminableIDs = append(undeterminableIDs, *item.ID) + continue + } + statementID, ok := statementIDByRow[statementRowID] + if !ok || statementID == "" { + emptyStatementID++ + undeterminableIDs = append(undeterminableIDs, *item.ID) + continue + } - if err := db.Model(&relational.SSPExportOfferingItem{}). - Where("id = ?", item.ID). - Update("statement_id", statementID).Error; err != nil { - return fmt.Errorf("failed to backfill statement_id on offering item %s: %w", item.ID, err) - } - backfilled++ + if err := tx.Model(&relational.SSPExportOfferingItem{}). + Where("id = ?", item.ID). + Update("statement_id", statementID).Error; err != nil { + return fmt.Errorf("failed to backfill statement_id on offering item %s: %w", item.ID, err) + } + backfilled++ + } + return nil + }).Error + }); err != nil { + return err + } + + if backfilled == 0 && len(undeterminableIDs) == 0 { + return nil } db.Logger.Info( context.Background(), - "Backfilled statement_id on %d legacy export offering item(s); %d could not be derived (requirement-anchored or dangling provided-uuid) and remain NULL", + "Backfilled statement_id on %d legacy export offering item(s); %d remain NULL "+ + "(%d requirement-anchored [expected legacy], %d dangling provided-uuid [data corruption], "+ + "%d export missing [data corruption], %d statement has empty statement-id)", backfilled, - undeterminable, + len(undeterminableIDs), + requirementAnchored, + danglingProvided, + missingExport, + emptyStatementID, ) + if len(undeterminableIDs) > 0 { + db.Logger.Info( + context.Background(), + "Offering items with a statement_id that could not be derived: %v", + undeterminableIDs, + ) + } return nil } diff --git a/internal/service/worker/risk_evidence_worker_test_helpers_test.go b/internal/service/worker/risk_evidence_worker_test_helpers_test.go index 76375070..4f31150a 100644 --- a/internal/service/worker/risk_evidence_worker_test_helpers_test.go +++ b/internal/service/worker/risk_evidence_worker_test_helpers_test.go @@ -40,15 +40,14 @@ func seedLeveragedResponsibility(t *testing.T, db *gorm.DB) (responsibilityUUID, require.NoError(t, db.Create(&downstreamSSP).Error) link := relational.SSPLeverageLink{ - DownstreamSSPID: *downstreamSSP.ID, - UpstreamSSPID: *upstreamSSP.ID, - OfferingID: uuid.New(), - ControlID: "ac-1", - ProvidedUUID: *provided.ID, - InheritedUUID: uuid.New(), - LeveragedAuthUUID: uuid.New(), - Satisfaction: relational.SSPLeverageSatisfactionPartial, - Status: relational.SSPLeverageStatusActive, + DownstreamSSPID: *downstreamSSP.ID, + UpstreamSSPID: *upstreamSSP.ID, + OfferingID: uuid.New(), + ControlID: "ac-1", + ProvidedUUID: *provided.ID, + InheritedUUID: uuid.New(), + Satisfaction: relational.SSPLeverageSatisfactionPartial, + Status: relational.SSPLeverageStatusActive, } require.NoError(t, db.Create(&link).Error) From 2fd340d344f39673ffe949e710d96b21123de628 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:01:22 -0300 Subject: [PATCH 11/13] fix: CI failures --- .../oscal/subtree_lock_invariant_test.go | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/internal/api/handler/oscal/subtree_lock_invariant_test.go b/internal/api/handler/oscal/subtree_lock_invariant_test.go index 6725bc51..de4c9212 100644 --- a/internal/api/handler/oscal/subtree_lock_invariant_test.go +++ b/internal/api/handler/oscal/subtree_lock_invariant_test.go @@ -76,21 +76,32 @@ func TestEveryLeverageSatisfactionWriterTakesTheSubtreeLock(t *testing.T) { } // parsePackageFiles parses every non-test .go file in this package. +// +// Files are enumerated and parsed individually rather than via parser.ParseDir, which is deprecated +// as of Go 1.25. The go/packages alternative the deprecation points at would pull a full load +// (and its build-tag handling) into a test that only needs this one directory's syntax trees. func parsePackageFiles(t *testing.T) []*ast.File { t.Helper() fset := token.NewFileSet() - pkgs, err := parser.ParseDir(fset, ".", func(fi os.FileInfo) bool { - return !strings.HasSuffix(fi.Name(), "_test.go") - }, 0) - require.NoError(t, err, "parsing package") - pkg, ok := pkgs["oscal"] - require.True(t, ok, "package oscal not found in current directory") + entries, err := os.ReadDir(".") + require.NoError(t, err, "reading package directory") - files := make([]*ast.File, 0, len(pkg.Files)) - for _, f := range pkg.Files { - files = append(files, f) + files := make([]*ast.File, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, name, nil, 0) + require.NoErrorf(t, err, "parsing %s", name) + // Guard against a stray file declaring another package (e.g. an oscal_test variant). + if file.Name.Name != "oscal" { + continue + } + files = append(files, file) } + require.NotEmpty(t, files, "package oscal not found in current directory") return files } From 5893c06c10eb64c61092d9960c9b2ac36fe93b1b Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:17:44 -0300 Subject: [PATCH 12/13] fix: address review feedback --- internal/api/error.go | 10 + internal/api/handler/filter.go | 155 ++++++--- .../oscal/filter_responsibilities_test.go | 110 +++++++ .../api/handler/oscal/ssp_by_components.go | 153 ++++++--- .../api/handler/oscal/ssp_export_offerings.go | 57 ++-- .../oscal/ssp_export_offerings_items_test.go | 309 ++++++++++++++++++ internal/api/handler/oscal/ssp_leverage.go | 30 +- .../api/handler/oscal/ssp_leverage_test.go | 88 +++++ .../oscal/ssp_shared_responsibility_test.go | 62 +++- ...btree_lock_concurrency_integration_test.go | 233 +++++++++---- internal/service/migrator.go | 37 ++- 11 files changed, 1050 insertions(+), 194 deletions(-) create mode 100644 internal/api/handler/oscal/ssp_export_offerings_items_test.go diff --git a/internal/api/error.go b/internal/api/error.go index 25b2f6fa..7d95c30b 100644 --- a/internal/api/error.go +++ b/internal/api/error.go @@ -45,6 +45,16 @@ func AccessForbidden() Error { return e } +// InternalServerError is the client-facing body for a 5xx. It is deliberately generic: NewError +// serialises err.Error() verbatim, so handing it a driver error leaks constraint names, column +// names and SQL state to the caller. Log the real error server-side and return this instead. +func InternalServerError() Error { + e := Error{} + e.Errors = make(map[string]any) + e.Errors["body"] = "internal server error" + return e +} + func NotFound() Error { e := Error{} e.Errors = make(map[string]any) diff --git a/internal/api/handler/filter.go b/internal/api/handler/filter.go index bdf530d8..b6c27ef6 100644 --- a/internal/api/handler/filter.go +++ b/internal/api/handler/filter.go @@ -15,6 +15,7 @@ import ( "go.uber.org/zap" "gorm.io/datatypes" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // FilterHandler handles CRUD operations for filters. @@ -464,18 +465,40 @@ func (h *FilterHandler) AttachResponsibility(ctx echo.Context) error { } var filter relational.Filter - if err := h.db.Preload("Controls").First(&filter, "id = ?", filterID).Error; err != nil { + if err := h.db.First(&filter, "id = ?", filterID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ctx.JSON(http.StatusNotFound, api.NewError(err)) } - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to load filter", "filterId", filterID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) } if err := h.db.First(&relational.SystemSecurityPlan{}, "id = ?", req.SSPID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("system security plan %s not found", req.SSPID))) } - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to load system security plan", "sspId", req.SSPID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) + } + + // The filter must belong to the SSP the attachment is being made for. Authorization is + // evaluated on the FILTER id (the route is guarded by guard.Update() on :id), so req.SSPID is + // body-supplied and never authorized on its own. Without this check a principal with update + // rights on their own filter could write a FilterResponsibility scoped to any other SSP, and + // that SSP's ResponsibilityPosture would then be computed from a filter it does not own. + // + // Second-order: Filter.SSPID carries OnDelete:CASCADE, so deleting the filter's owning SSP + // would silently delete attachments another SSP depends on. + // + // Global filters (SSPID == nil) stay attachable anywhere — that is deliberate existing + // behaviour, not an oversight. + // + // Ordered AFTER the SSP existence check on purpose: a non-existent sspId is a 404, and hoisting + // this above it would turn that into a "belongs to a different system security plan" 400, which + // is both wrong and misleading (TestAttachValidation pins the 404). + if filter.SSPID != nil && *filter.SSPID != req.SSPID { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf( + "filter %s belongs to a different system security plan", filterID))) } // The responsibility must be one the downstream SSP actually inherits: posture reads @@ -494,7 +517,9 @@ func (h *FilterHandler) AttachResponsibility(ctx echo.Context) error { Where("l.downstream_ssp_id = ? AND r.id = ? AND l.status NOT IN ?", req.SSPID, req.ResponsibilityUUID, []relational.SSPLeverageStatus{relational.SSPLeverageStatusRevoked, relational.SSPLeverageStatusSuperseded}). Count(&inherits).Error; err != nil { - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to check inherited responsibility", "sspId", req.SSPID, + "responsibilityUuid", req.ResponsibilityUUID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) } if inherits == 0 { return ctx.JSON(http.StatusBadRequest, api.NewError( @@ -511,7 +536,9 @@ func (h *FilterHandler) AttachResponsibility(ctx echo.Context) error { fmt.Errorf("filter is already attached to this responsibility for this SSP"))) } if !errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to check existing attachment", "filterId", filterID, + "responsibilityUuid", req.ResponsibilityUUID, "sspId", req.SSPID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) } row := relational.FilterResponsibility{ @@ -520,7 +547,7 @@ func (h *FilterHandler) AttachResponsibility(ctx echo.Context) error { SSPID: req.SSPID, } - var controlToLink *relational.Control + var resolvedControl *relational.Control if req.ControlID != nil && strings.TrimSpace(*req.ControlID) != "" { // Control's PK is composite (catalog_id, id), so a control id alone does not identify a // control — NIST 800-53 rev4 and rev5 both define AC-2. Resolve the catalog through the @@ -534,7 +561,8 @@ func (h *FilterHandler) AttachResponsibility(ctx echo.Context) error { "control %q is defined in multiple catalogs and this SSP's profiles do not disambiguate it", controlID))) } if err != nil { - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to resolve control catalog", "controlId", controlID, "sspId", req.SSPID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) } if catalogID == nil { return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("control %q not found", controlID))) @@ -547,47 +575,68 @@ func (h *FilterHandler) AttachResponsibility(ctx echo.Context) error { if errors.Is(err, gorm.ErrRecordNotFound) { return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("control %q not found", *req.ControlID))) } - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to load control", "controlId", controlID, "catalogId", *catalogID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) } row.ControlID = &control.ID row.ControlCatalogID = &control.CatalogID - - linkExists := false - for _, linked := range filter.Controls { - if linked.CatalogID == control.CatalogID && linked.ID == control.ID { - linkExists = true - break - } - } - if !linkExists { - controlToLink = &control - row.ControlLinkCreated = true - } else { - // Co-ownership: if the existing link was itself created by a responsibility - // attachment, this row claims it too, so the LAST detacher removes it. An - // independently created link (POST/PUT /filters) is never owned. - // Scoped by catalog as well as id: control_id alone spans catalogs, so a claim on - // another catalog's AC-2 would otherwise read as a claim on this one. - var owners int64 - if err := h.db.Model(&relational.FilterResponsibility{}). - Where("filter_id = ? AND control_id = ? AND control_catalog_id = ? AND control_link_created = true", - filterID, control.ID, control.CatalogID). - Count(&owners).Error; err != nil { - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) - } - row.ControlLinkCreated = owners > 0 - } + resolvedControl = &control } + // Link existence and co-ownership are decided INSIDE the transaction, under a row lock on the + // filter. ControlLinkCreated is provenance, not derived state — nothing recomputes it later — + // so a decision made against a snapshot another attach can invalidate is permanently wrong. + // + // The interleaving this prevents: attach A commits its filter_controls append; attach B, running + // between A's two writes, sees the link present but no owning row yet and records + // ControlLinkCreated = false; detaching A then finds no remaining claims and removes the + // filter_controls row that B still depends on. if err := h.db.Transaction(func(tx *gorm.DB) error { - if controlToLink != nil { - if err := tx.Model(&filter).Association("Controls").Append(controlToLink); err != nil { + if resolvedControl != nil { + // Lock the filter row first: it is what concurrent attaches contend on, so it + // serialises the read-decide-write below against another attach on the same filter. + var locked relational.Filter + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + First(&locked, "id = ?", filterID).Error; err != nil { return err } + + // Re-derive link existence inside the transaction with an explicit count. Reusing a + // preloaded filter.Controls slice here would move the query but keep the stale + // snapshot, which is the whole defect. + var linkCount int64 + if err := tx.Table("filter_controls"). + Where("filter_id = ? AND control_id = ? AND control_catalog_id = ?", + filterID, resolvedControl.ID, resolvedControl.CatalogID). + Count(&linkCount).Error; err != nil { + return err + } + + if linkCount == 0 { + if err := tx.Model(&locked).Association("Controls").Append(resolvedControl); err != nil { + return err + } + row.ControlLinkCreated = true + } else { + // Co-ownership: if the existing link was itself created by a responsibility + // attachment, this row claims it too, so the LAST detacher removes it. An + // independently created link (POST/PUT /filters) is never owned. + // Scoped by catalog as well as id: control_id alone spans catalogs, so a claim on + // another catalog's AC-2 would otherwise read as a claim on this one. + var owners int64 + if err := tx.Model(&relational.FilterResponsibility{}). + Where("filter_id = ? AND control_id = ? AND control_catalog_id = ? AND control_link_created = true", + filterID, resolvedControl.ID, resolvedControl.CatalogID). + Count(&owners).Error; err != nil { + return err + } + row.ControlLinkCreated = owners > 0 + } } return tx.Create(&row).Error }); err != nil { - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to attach responsibility", "filterId", filterID, "sspId", req.SSPID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) } return ctx.JSON(http.StatusCreated, GenericDataResponse[relational.FilterResponsibility]{Data: row}) @@ -626,6 +675,11 @@ var errAmbiguousControlCatalog = errors.New("control id matches controls in mult // 2. If the profiles don't import the control — which is legitimate and common, since a downstream // may rely on a leveraged capability without implementing the control itself — fall back to the // catalogs that define it. Exactly one match resolves; more than one is ambiguous and errors. +// The fallback is tried against ACTIVE catalogs first: control ids like AC-2 recur in +// essentially every catalog, so counting archived, superseded or draft catalogs would let a +// dead catalog manufacture ambiguity among live ones and turn a legitimate attach into a 400 +// the client cannot resolve. Only if no active catalog defines the control does it re-run +// unfiltered, so a control that lives solely in an archived catalog stays reachable. // // The point is that no arbitrary choice is ever made: the previous `First(id = ?)` returned // whichever row the planner yielded, silently pinning the filter (and every control-level @@ -654,6 +708,25 @@ func (h *FilterHandler) resolveCatalogIDForSSPControl(sspID uuid.UUID, controlID return nil, errAmbiguousControlCatalog } + var activeCatalogIDs []uuid.UUID + if err := h.db.Model(&relational.Control{}). + Joins("JOIN catalogs ON catalogs.id = controls.catalog_id"). + Where("catalogs.active AND UPPER(controls.id) = UPPER(?)", controlID). + Distinct(). + Pluck("controls.catalog_id", &activeCatalogIDs).Error; err != nil { + return nil, err + } + switch len(activeCatalogIDs) { + case 1: + return &activeCatalogIDs[0], nil + case 0: + // Fall through to the unfiltered lookup below: no ACTIVE catalog defines this control, so + // an archived one is the only candidate and is better than a spurious 404. + default: + // Genuine ambiguity among live catalogs — the caller has to say which it means. + return nil, errAmbiguousControlCatalog + } + var catalogIDs []uuid.UUID if err := h.db.Model(&relational.Control{}). Where("UPPER(id) = UPPER(?)", controlID). @@ -694,7 +767,9 @@ func (h *FilterHandler) DetachResponsibility(ctx echo.Context) error { if errors.Is(err, gorm.ErrRecordNotFound) { return ctx.JSON(http.StatusNotFound, api.NewError(err)) } - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to load attachment", "filterId", filterID, + "responsibilityUuid", responsibilityUUID, "sspId", sspID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) } if err := h.db.Transaction(func(tx *gorm.DB) error { @@ -732,7 +807,9 @@ func (h *FilterHandler) DetachResponsibility(ctx echo.Context) error { } return nil }); err != nil { - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to detach responsibility", "filterId", filterID, + "responsibilityUuid", responsibilityUUID, "sspId", sspID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) } return ctx.NoContent(http.StatusNoContent) diff --git a/internal/api/handler/oscal/filter_responsibilities_test.go b/internal/api/handler/oscal/filter_responsibilities_test.go index 560ae1b6..3d102638 100644 --- a/internal/api/handler/oscal/filter_responsibilities_test.go +++ b/internal/api/handler/oscal/filter_responsibilities_test.go @@ -74,6 +74,10 @@ type filterResponsibilityFixture struct { providedUUID string respAUUID string respBUUID string + + // catalogID is the ACTIVE catalog defining "ac-1", so tests can assert which catalog a + // control resolved through rather than merely that resolution succeeded. + catalogID uuid.UUID } // setupFilterResponsibilityFixture materializes a real leverage link: upstream SSP with a @@ -139,6 +143,7 @@ func (suite *FilterResponsibilityIntegrationSuite) setupFilterResponsibilityFixt providedUUID: providedUUID, respAUUID: respAUUID, respBUUID: respBUUID, + catalogID: *catalog.ID, } } @@ -355,6 +360,111 @@ func (suite *FilterResponsibilityIntegrationSuite) TestAttachRejectsAmbiguousCon suite.Equal(int64(0), suite.controlLinkCount(*filter.ID, "ac-1")) } +// TestAttachRejectsFilterOwnedByAnotherSSP pins the ownership boundary on attach. Authorization is +// evaluated on the FILTER id (the route is guarded by guard.Update() on :id), so sspId is +// body-supplied and never authorized on its own. Without the check, a principal with update rights +// on their own filter could write a filter_responsibilities row scoped to any other SSP, and that +// SSP's ResponsibilityPosture would then be computed from a filter it does not own. +// +// Every other fixture in this file builds the filter with fx.downstreamSSPID, so the cross-SSP case +// was previously unexercised. +func (suite *FilterResponsibilityIntegrationSuite) TestAttachRejectsFilterOwnedByAnotherSSP() { + fx := suite.setupFilterResponsibilityFixture() + + // A real, unrelated SSP to own the filter — Filter.SSPID is a foreign key, so an invented + // uuid would fail on insert rather than exercising the handler. + otherSSP := minimalSSP(uuid.New().String()) + rec := authedRequest(&suite.IntegrationTestSuite, fx.server, "POST", + "/api/oscal/system-security-plans", fx.contributorToken, otherSSP) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + + foreign := suite.makeFilter("filter owned elsewhere", otherSSP.UUID) + + rec = authedRequest(&suite.IntegrationTestSuite, fx.server, "POST", + fmt.Sprintf("/api/filters/%s/responsibilities", foreign.ID), fx.contributorToken, + map[string]any{"responsibilityUuid": fx.respAUUID, "sspId": fx.downstreamSSPID}) + suite.Require().Equal(http.StatusBadRequest, rec.Code, rec.Body.String()) + suite.Contains(rec.Body.String(), "different system security plan") + + var rows int64 + suite.Require().NoError(suite.DB.Model(&relational.FilterResponsibility{}). + Where("filter_id = ?", foreign.ID).Count(&rows).Error) + suite.Zero(rows, "a cross-SSP attach must not write a filter_responsibilities row") +} + +// TestAttachAllowsGlobalFilter pins the other half of the ownership rule: a global filter +// (SSPID == nil) stays attachable for any SSP. That is deliberate existing behaviour, and the +// cross-SSP guard must not tighten it. +func (suite *FilterResponsibilityIntegrationSuite) TestAttachAllowsGlobalFilter() { + fx := suite.setupFilterResponsibilityFixture() + + global := relational.Filter{ + Name: "global filter", + SSPID: nil, + Filter: datatypes.NewJSONType(labelfilter.Filter{ + Scope: &labelfilter.Scope{Condition: &labelfilter.Condition{Label: "env", Operator: "=", Value: "prod"}}, + }), + } + suite.Require().NoError(suite.DB.Create(&global).Error) + + rec := authedRequest(&suite.IntegrationTestSuite, fx.server, "POST", + fmt.Sprintf("/api/filters/%s/responsibilities", global.ID), fx.contributorToken, + map[string]any{"responsibilityUuid": fx.respAUUID, "sspId": fx.downstreamSSPID}) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) +} + +// TestAttachPrefersActiveCatalogOverArchived pins the catalog fallback tier. Control ids like AC-1 +// recur in essentially every catalog, so counting archived, superseded or draft catalogs would let +// a dead catalog manufacture ambiguity among live ones and turn a legitimate attach into a 400 the +// client cannot resolve from its side. +func (suite *FilterResponsibilityIntegrationSuite) TestAttachPrefersActiveCatalogOverArchived() { + fx := suite.setupFilterResponsibilityFixture() + + // An ARCHIVED catalog defining the same control id as the fixture's active one. Before the + // active-first fallback this made resolution ambiguous and produced a 400. + archived := false + archivedCatalog := relational.Catalog{Active: &archived} + suite.Require().NoError(suite.DB.Create(&archivedCatalog).Error) + suite.Require().NoError(suite.DB.Create(&relational.Control{ + CatalogID: *archivedCatalog.ID, ID: "ac-1", Title: "Access Control Policy (retired)", + }).Error) + + filter := suite.makeFilter("active catalog filter", fx.downstreamSSPID) + controlID := "AC-1" + suite.attach(fx, *filter.ID, fx.respAUUID, &controlID) + + // It resolved, and specifically through the ACTIVE catalog — asserting the stored catalog id + // rather than just a 201, since picking the archived one would also return 201. + var row relational.FilterResponsibility + suite.Require().NoError(suite.DB.First(&row, "filter_id = ?", filter.ID).Error) + suite.Require().NotNil(row.ControlCatalogID) + suite.Equal(fx.catalogID, *row.ControlCatalogID, + "control must resolve through the active catalog, not the archived one") +} + +// TestAttachResolvesControlDefinedOnlyInArchivedCatalog pins the fallback's second tier: when NO +// active catalog defines the control, the archived one is still reachable. Restricting the fallback +// to active catalogs outright would have turned this into a spurious 404. +func (suite *FilterResponsibilityIntegrationSuite) TestAttachResolvesControlDefinedOnlyInArchivedCatalog() { + fx := suite.setupFilterResponsibilityFixture() + + archived := false + archivedCatalog := relational.Catalog{Active: &archived} + suite.Require().NoError(suite.DB.Create(&archivedCatalog).Error) + suite.Require().NoError(suite.DB.Create(&relational.Control{ + CatalogID: *archivedCatalog.ID, ID: "au-9", Title: "Protection of Audit Information (retired)", + }).Error) + + filter := suite.makeFilter("archived only filter", fx.downstreamSSPID) + controlID := "AU-9" + suite.attach(fx, *filter.ID, fx.respAUUID, &controlID) + + var row relational.FilterResponsibility + suite.Require().NoError(suite.DB.First(&row, "filter_id = ?", filter.ID).Error) + suite.Require().NotNil(row.ControlCatalogID) + suite.Equal(*archivedCatalog.ID, *row.ControlCatalogID) +} + func (suite *FilterResponsibilityIntegrationSuite) TestAttachValidation() { fx := suite.setupFilterResponsibilityFixture() filter := suite.makeFilter("validation filter", fx.downstreamSSPID) diff --git a/internal/api/handler/oscal/ssp_by_components.go b/internal/api/handler/oscal/ssp_by_components.go index f2766ed2..bffcb41a 100644 --- a/internal/api/handler/oscal/ssp_by_components.go +++ b/internal/api/handler/oscal/ssp_by_components.go @@ -84,6 +84,37 @@ func byComponentIDsForSSP(db *gorm.DB, sspID uuid.UUID) ([]uuid.UUID, error) { return byComponentIDs, nil } +// ensureProvidedUUIDNotAlreadyInherited enforces "one provided-uuid may be inherited only once +// per SSP". A subscription's link is reached through its inherited row, and satisfaction is +// derived from the owning by-component's satisfied set — so a second by-component inheriting the +// same provided-uuid would make "which satisfied set governs this capability" ambiguous. Nothing +// in the schema forbids it (the leverage link's uniqueIndex only stops a second SUBSCRIBE), so it +// is enforced here, across both anchoring levels AND both creation paths: the hand-authored +// inherited create and Subscribe. Guarding only one left the invariant reachable by hand-authoring +// the row first and subscribing second. +// +// tx must be the caller's transaction, holding the by-component subtree lock — checking outside it +// races the very concurrent create the lock exists to serialize. +func ensureProvidedUUIDNotAlreadyInherited(tx *gorm.DB, sspID uuid.UUID, providedUUID uuid.UUID) error { + inSSP, err := byComponentIDsForSSP(tx, sspID) + if err != nil { + return err + } + if len(inSSP) == 0 { + return nil + } + var dupe int64 + if err := tx.Model(&relational.InheritedControlImplementation{}). + Where("provided_uuid = ? AND by_component_id IN ?", providedUUID, inSSP). + Count(&dupe).Error; err != nil { + return err + } + if dupe > 0 { + return errProvidedAlreadyInherited + } + return nil +} + // assertInheritedNotSubscribed enforces errInheritedOwnedBySubscription over a set of inherited // ids. Both delete paths — the sub-resource route and the by-component cascade — must apply the // same rule; guarding only one left the invariant bypassable by deleting the parent, and the two @@ -168,7 +199,8 @@ func (h *SystemSecurityPlanHandler) getByComponent(ctx echo.Context, bc *relatio if errors.Is(err, gorm.ErrRecordNotFound) { return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("by-component not found"))) } - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to reload by-component", "byComponentId", *bc.ID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) } return ctx.JSON(http.StatusOK, handler.GenericDataResponse[oscalTypes_1_1_3.ByComponent]{Data: *loaded.MarshalOscal()}) } @@ -235,7 +267,8 @@ func (h *SystemSecurityPlanHandler) resolveStatement(ctx echo.Context) (stmt *re _ = ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("SSP not found"))) return nil, false } - _ = ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to get ssp", "sspId", sspID, "error", err) + _ = ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) return nil, false } @@ -246,7 +279,8 @@ func (h *SystemSecurityPlanHandler) resolveStatement(ctx echo.Context) (stmt *re _ = ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("requirement not found"))) return nil, false } - _ = ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to get implemented requirement", "reqId", reqID, "error", err) + _ = ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) return nil, false } @@ -257,7 +291,8 @@ func (h *SystemSecurityPlanHandler) resolveStatement(ctx echo.Context) (stmt *re _ = ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("statement not found"))) return nil, false } - _ = ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to get statement", "stmtId", stmtID, "error", err) + _ = ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) return nil, false } @@ -323,7 +358,8 @@ func (h *SystemSecurityPlanHandler) getByComponentExportProvided(ctx echo.Contex Where("export_id = ?", export.ID). Order("id ASC"). Find(&provided).Error; err != nil { - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to list export provided", "exportId", export.ID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) } result := make([]oscalTypes_1_1_3.ProvidedControlImplementation, 0, len(provided)) @@ -347,7 +383,8 @@ func (h *SystemSecurityPlanHandler) getByComponentExportResponsibilities(ctx ech Where("export_id = ?", export.ID). Order("id ASC"). Find(&responsibilities).Error; err != nil { - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to list export responsibilities", "exportId", export.ID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) } result := make([]oscalTypes_1_1_3.ControlImplementationResponsibility, 0, len(responsibilities)) @@ -481,7 +518,8 @@ func (h *SystemSecurityPlanHandler) GetImplementedRequirementStatementByComponen Where("by_component_id = ?", bc.ID). Order("id ASC"). Find(&inherited).Error; err != nil { - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to list inherited control implementations", "byComponentId", bc.ID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) } result := make([]oscalTypes_1_1_3.InheritedControlImplementation, 0, len(inherited)) @@ -543,6 +581,26 @@ func (h *SystemSecurityPlanHandler) CreateImplementedRequirementStatementByCompo // still reads back as a real inherited capability. Same class of defect the offering-item // coherence check rejects, one layer down. The satisfied POST already validates its // responsibility-uuid this way. + // + // This lookup is GLOBAL BY DESIGN. It asks only whether the provided-uuid exists anywhere in + // the database, and deliberately does NOT apply isDownstreamAllowed. Subscribe does enforce + // the allow-list — it returns 403 (errDownstreamNotAllowed) when the downstream is not + // allow-listed for the offering — so the asymmetry between the two paths is a decision, not + // an oversight: a hand-authored inherited row is the author's own assertion about their own + // system, and holding it to the subscription trust boundary would block recording inheritance + // that is real but not modelled as a subscription. + // + // What that exposes, stated plainly: a caller with create rights on their own SSP gets a + // 201-vs-400 existence oracle over every provided-uuid in the installation, and can make + // inheritableResponsibilities resolve the responsibility set of an upstream they have no + // relationship with. + // + // What it does NOT expose: this path creates no SSPLeverageLink, so nothing recorded here + // reaches the drift detector or the inherits arm of the rollup. The effect stops at this + // SSP's own hand-authored rows. + // + // Revisit if provided-uuid existence ever becomes confidential: at that point scope this + // lookup to provided-uuids reachable through an offering this SSP is allow-listed for. if err := h.db.First(&relational.ProvidedControlImplementation{}, "id = ?", providedUUID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf( @@ -565,24 +623,9 @@ func (h *SystemSecurityPlanHandler) CreateImplementedRequirementStatementByCompo if err := lockByComponentSubtreeWrite(tx, *bc.ID); err != nil { return err } - // One provided-uuid may be inherited only once per SSP. A subscription's link is reached - // through its inherited row, and satisfaction is derived from the owning by-component's - // satisfied set — so a second by-component inheriting the same provided-uuid would make - // "which satisfied set governs this capability" ambiguous. Nothing in the schema forbids - // it, so it is enforced here, across both anchoring levels. - inSSP, err := byComponentIDsForSSP(tx, sspID) - if err != nil { - return err - } - var dupe int64 - if err := tx.Model(&relational.InheritedControlImplementation{}). - Where("provided_uuid = ? AND by_component_id IN ?", providedUUID, inSSP). - Count(&dupe).Error; err != nil { + if err := ensureProvidedUUIDNotAlreadyInherited(tx, sspID, providedUUID); err != nil { return err } - if dupe > 0 { - return errProvidedAlreadyInherited - } return tx.Create(relInherited).Error }); err != nil { if errors.Is(err, errProvidedAlreadyInherited) { @@ -596,9 +639,17 @@ func (h *SystemSecurityPlanHandler) CreateImplementedRequirementStatementByCompo return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } + // The transaction above has COMMITTED. A failure to re-read the row must not be reported as + // a 5xx: the resource exists, and a client retrying what looked like a failed create would + // either duplicate it or bounce off the 409. The reload only re-fetches what we already hold + // — UnmarshalOscal populated ResponsibleRoles and their Parties from the request body, which + // is what MarshalOscal renders — so the in-memory value is the same resource. Log loudly and + // serve it rather than downgrading a committed write. created, err := h.reloadInherited(*relInherited.ID) if err != nil { - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to reload inherited control implementation after a committed create; serving the in-memory value", + "inheritedId", *relInherited.ID, "byComponentId", *bc.ID, "error", err) + created = relInherited } return ctx.JSON(http.StatusCreated, handler.GenericDataResponse[oscalTypes_1_1_3.InheritedControlImplementation]{Data: *created.MarshalOscal()}) } @@ -641,16 +692,26 @@ func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementStatementByCompo if errors.Is(err, gorm.ErrRecordNotFound) { return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("inherited entry not found"))) } - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to get inherited control implementation", "inheritedId", inheritedID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) } var oscalInherited oscalTypes_1_1_3.InheritedControlImplementation if err := ctx.Bind(&oscalInherited); err != nil { return ctx.JSON(http.StatusBadRequest, api.NewError(err)) } - if oscalInherited.ProvidedUuid != "" && oscalInherited.ProvidedUuid != existing.ProvidedUuid.String() { - return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf( - "provided-uuid is immutable: it is the identity the leverage link and drift detector join on"))) + // Compared as parsed uuid VALUES, not strings: uuid.UUID.String() always emits lowercase, + // but RFC 4122 permits uppercase and plenty of JSON/UI toolchains preserve whatever casing + // they were handed. A client round-tripping a GET into a PUT with a canonically-uppercase + // provided-uuid is sending a semantically identical value and must not be told it tried to + // mutate an immutable field. An empty string still means "omitted, leave unchanged"; a + // malformed value deliberately falls into the same 400. + if s := oscalInherited.ProvidedUuid; s != "" { + parsed, err := uuid.Parse(s) + if err != nil || parsed != existing.ProvidedUuid { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf( + "provided-uuid is immutable: it is the identity the leverage link and drift detector join on"))) + } } // Echo back the stored identities so UnmarshalOscal's MustParse can't panic on a body // that legitimately omits them. @@ -678,7 +739,8 @@ func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementStatementByCompo updated, err := h.reloadInherited(*existing.ID) if err != nil { - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to reload inherited control implementation", "inheritedId", *existing.ID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) } return ctx.JSON(http.StatusOK, handler.GenericDataResponse[oscalTypes_1_1_3.InheritedControlImplementation]{Data: *updated.MarshalOscal()}) } @@ -723,7 +785,8 @@ func (h *SystemSecurityPlanHandler) DeleteImplementedRequirementStatementByCompo if errors.Is(err, gorm.ErrRecordNotFound) { return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("inherited entry not found"))) } - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to get inherited control implementation", "inheritedId", inheritedID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) } if err := h.db.Transaction(func(tx *gorm.DB) error { @@ -780,7 +843,8 @@ func (h *SystemSecurityPlanHandler) GetImplementedRequirementStatementByComponen Where("by_component_id = ?", bc.ID). Order("id ASC"). Find(&satisfied).Error; err != nil { - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to list satisfied responsibilities", "byComponentId", bc.ID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) } result := make([]oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility, 0, len(satisfied)) @@ -870,9 +934,13 @@ func (h *SystemSecurityPlanHandler) CreateImplementedRequirementStatementByCompo return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } + // Committed already — same reasoning as the inherited create above: a post-commit reload + // failure must not become a 5xx that invites a duplicate-creating retry. created, err := h.reloadSatisfied(*relSatisfied.ID) if err != nil { - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to reload satisfied responsibility after a committed create; serving the in-memory value", + "satisfiedId", *relSatisfied.ID, "byComponentId", *bc.ID, "error", err) + created = relSatisfied } return ctx.JSON(http.StatusCreated, handler.GenericDataResponse[oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility]{Data: *created.MarshalOscal()}) } @@ -917,16 +985,23 @@ func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementStatementByCompo if errors.Is(err, gorm.ErrRecordNotFound) { return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("satisfied entry not found"))) } - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to get satisfied responsibility", "satisfiedId", satisfiedID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) } var oscalSatisfied oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility if err := ctx.Bind(&oscalSatisfied); err != nil { return ctx.JSON(http.StatusBadRequest, api.NewError(err)) } - if oscalSatisfied.ResponsibilityUuid != "" && oscalSatisfied.ResponsibilityUuid != existing.ResponsibilityUuid.String() { - return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf( - "responsibility-uuid is immutable: it is the identity satisfaction derivation and the drift detector join on"))) + // Parsed-value comparison for the same reason as provided-uuid on the inherited PUT: an + // uppercase round-trip of the stored uuid is the same uuid. Empty stays "omitted, leave + // unchanged"; malformed falls into the same 400. + if s := oscalSatisfied.ResponsibilityUuid; s != "" { + parsed, err := uuid.Parse(s) + if err != nil || parsed != existing.ResponsibilityUuid { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf( + "responsibility-uuid is immutable: it is the identity satisfaction derivation and the drift detector join on"))) + } } oscalSatisfied.UUID = existing.ID.String() oscalSatisfied.ResponsibilityUuid = existing.ResponsibilityUuid.String() @@ -953,7 +1028,8 @@ func (h *SystemSecurityPlanHandler) UpdateImplementedRequirementStatementByCompo updated, err := h.reloadSatisfied(*existing.ID) if err != nil { - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to reload satisfied responsibility", "satisfiedId", *existing.ID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) } return ctx.JSON(http.StatusOK, handler.GenericDataResponse[oscalTypes_1_1_3.SatisfiedControlImplementationResponsibility]{Data: *updated.MarshalOscal()}) } @@ -1002,7 +1078,8 @@ func (h *SystemSecurityPlanHandler) DeleteImplementedRequirementStatementByCompo if errors.Is(err, gorm.ErrRecordNotFound) { return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("satisfied entry not found"))) } - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + h.sugar.Errorw("Failed to get satisfied responsibility", "satisfiedId", satisfiedID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) } if err := h.db.Transaction(func(tx *gorm.DB) error { diff --git a/internal/api/handler/oscal/ssp_export_offerings.go b/internal/api/handler/oscal/ssp_export_offerings.go index 1b02ed3d..ee24b516 100644 --- a/internal/api/handler/oscal/ssp_export_offerings.go +++ b/internal/api/handler/oscal/ssp_export_offerings.go @@ -1178,6 +1178,29 @@ func parseExportOfferingPagination(ctx echo.Context) (limit int, offset int, err return limit, offset, nil } +// allowedDownstreamSQL is bulkAllowedOfferings' rule expressed as a SQL predicate on +// ssp_export_offerings.id, so a paginated query can be constrained by the allow-list BEFORE +// LIMIT/OFFSET is applied: an offering with no allow-list rows is open to every downstream +// (the type-level default), one with rows is open only to those it lists. +// +// Deliberately correlated subqueries rather than a `WHERE id IN (?)` over a pre-resolved set: +// the permitted set is UNBOUNDED — every offering lacking allow-list rows is in it — so an IN +// list would grow with the tenant's offering count and eventually blow the 65535 bind-parameter +// ceiling, the same hazard findByIDsInChunks works around in the migrator backfill. The single +// bound parameter here is the downstream SSP id. Written with EXISTS/NOT EXISTS (not a LEFT +// JOIN or a jsonb/array trick) so it runs identically on Postgres and the sqlite unit-test +// driver. +const allowedDownstreamSQL = `( + NOT EXISTS ( + SELECT 1 FROM ssp_export_offering_allowed_downstreams ad + WHERE ad.offering_id = ssp_export_offerings.id + ) + OR EXISTS ( + SELECT 1 FROM ssp_export_offering_allowed_downstreams ad + WHERE ad.offering_id = ssp_export_offerings.id AND ad.downstream_ssp_id = ? + ) +)` + func (h *SSPExportOfferingHandler) ListAll(ctx echo.Context) error { limit, offset, err := parseExportOfferingPagination(ctx) if err != nil { @@ -1324,8 +1347,10 @@ func (h *SSPExportOfferingHandler) ByControl(ctx echo.Context) error { // Same bounds as ListAll. A widely-offered control (AC-2 across a large tenant) would // otherwise return every published item plus the batched resolution queries behind it in one - // unpaginated response; downstreamSspId does not bound it, being optional and applied after - // the fetch. + // unpaginated response. downstreamSspId narrows it further and is applied IN SQL, not after + // the fetch, so that LIMIT/OFFSET page over the FILTERED set: filtering a fetched page + // post-hoc makes a page whose rows are all filtered out look like the end of the collection, + // and a client paginating from offset 0 silently stops before every later match. limit, offset, err := parseExportOfferingPagination(ctx) if err != nil { return ctx.JSON(http.StatusBadRequest, api.NewError(err)) @@ -1344,10 +1369,14 @@ func (h *SSPExportOfferingHandler) ByControl(ctx echo.Context) error { // casing across the codebase (fixtures use "ac-2", catalogs "AC-2"), so match the way // every other control lookup here does: fold case rather than compare bytes. var items []relational.SSPExportOfferingItem - if err := h.db. + query := h.db. Joins("JOIN ssp_export_offerings ON ssp_export_offerings.id = ssp_export_offering_items.offering_id"). Where("ssp_export_offerings.status = ? AND UPPER(ssp_export_offering_items.control_id) = UPPER(?)", - relational.SSPExportOfferingStatusPublished, controlID). + relational.SSPExportOfferingStatusPublished, controlID) + if downstreamSSPID != nil { + query = query.Where(allowedDownstreamSQL, *downstreamSSPID) + } + if err := query. Order("ssp_export_offering_items.id ASC"). Limit(limit).Offset(offset). Find(&items).Error; err != nil { @@ -1369,26 +1398,6 @@ func (h *SSPExportOfferingHandler) ByControl(ctx echo.Context) error { offeringByID[*o.ID] = o } - // Drop items whose offering the downstream isn't allow-listed for, before spending any - // further resolution on them. - if downstreamSSPID != nil { - allowedOfferings, err := bulkAllowedOfferings(h.db, offeringIDs, *downstreamSSPID) - if err != nil { - h.sugar.Errorf("Failed to check offering allow-list: %v", err) - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) - } - filtered := make([]relational.SSPExportOfferingItem, 0, len(items)) - for _, item := range items { - if allowedOfferings[item.OfferingID] { - filtered = append(filtered, item) - } - } - items = filtered - if len(items) == 0 { - return ctx.JSON(http.StatusOK, handler.GenericDataListResponse[ControlExportOffer]{Data: []ControlExportOffer{}}) - } - } - sspIDs := uniqueUUIDs(offerings, func(o relational.SSPExportOffering) uuid.UUID { return o.SSPID }) var ssps []relational.SystemSecurityPlan if err := h.db.Preload("Metadata").Where("id IN ?", sspIDs).Find(&ssps).Error; err != nil { diff --git a/internal/api/handler/oscal/ssp_export_offerings_items_test.go b/internal/api/handler/oscal/ssp_export_offerings_items_test.go new file mode 100644 index 00000000..ff9fcfb4 --- /dev/null +++ b/internal/api/handler/oscal/ssp_export_offerings_items_test.go @@ -0,0 +1,309 @@ +package oscal + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/compliance-framework/api/internal/api/handler" + "github.com/compliance-framework/api/internal/service/relational" +) + +// --- ByControl pagination vs. the downstream allow-list ----------------------------------- + +// seqUUID builds a deterministically ordered uuid, so a test can pin the order +// `ORDER BY ssp_export_offering_items.id ASC` produces. Random v4 ids would make "the +// matching rows are on the SECOND page" a coin flip rather than a fact. +func seqUUID(n int) uuid.UUID { + return uuid.MustParse(fmt.Sprintf("00000000-0000-0000-0000-%012d", n)) +} + +// seedByControlPage adds `count` published offerings, each with a single ac-2 item, whose item +// ids sort in creation order starting at `startSeq`. Every item points at the fixture's real +// component and provided rows so ByControl resolves them like any other. `allowed` names the +// downstream SSP each offering is allow-listed for; the zero uuid means "no allow-list rows", +// i.e. open to every downstream. +func seedByControlPage(t *testing.T, db *gorm.DB, fx sharedResponsibilityFixture, startSeq, count int, allowed uuid.UUID) []uuid.UUID { + t.Helper() + + itemIDs := make([]uuid.UUID, 0, count) + for i := 0; i < count; i++ { + offering := relational.SSPExportOffering{ + SSPID: fx.upstreamSSPID, Title: fmt.Sprintf("Offering %d", startSeq+i), Version: 1, + Status: relational.SSPExportOfferingStatusPublished, + } + require.NoError(t, db.Create(&offering).Error) + + itemID := seqUUID(startSeq + i) + item := relational.SSPExportOfferingItem{ + OfferingID: *offering.ID, ControlID: "ac-2", StatementID: statementID("ac-2_smt.a"), + ComponentUUID: fx.componentID, ProvidedUUID: fx.providedID, + } + item.ID = &itemID + require.NoError(t, db.Create(&item).Error) + itemIDs = append(itemIDs, itemID) + + if allowed != uuid.Nil { + require.NoError(t, db.Create(&relational.SSPExportOfferingAllowedDownstream{ + OfferingID: *offering.ID, DownstreamSSPID: allowed, + }).Error) + } + } + return itemIDs +} + +func byControlPage(t *testing.T, h *SSPExportOfferingHandler, downstream *uuid.UUID, limit, offset int) []ControlExportOffer { + t.Helper() + + target := fmt.Sprintf("/?limit=%d&offset=%d", limit, offset) + if downstream != nil { + target += "&downstreamSspId=" + downstream.String() + } + e := echo.New() + rec := httptest.NewRecorder() + ctx := e.NewContext(httptest.NewRequest(http.MethodGet, target, nil), rec) + ctx.SetParamNames("controlId") + ctx.SetParamValues("ac-2") + + require.NoError(t, h.ByControl(ctx)) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var resp handler.GenericDataListResponse[ControlExportOffer] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + return resp.Data +} + +// TestByControlPaginatesOverAllowListFilteredRows is the pagination bug the allow-list predicate +// exists to prevent. When the filter ran AFTER Limit/Offset, a page whose rows were all filtered +// out came back as `200 []` — indistinguishable from the end of the collection — so a client +// paginating from offset 0 with downstreamSspId set stopped before reaching any match. Here the +// only permitted offerings sort last, so the first page is entirely non-matching under the old +// ordering; the filtered rows must still be returned. +func TestByControlPaginatesOverAllowListFilteredRows(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := NewSSPExportOfferingHandler(zap.NewNop().Sugar(), db, nil) + + // The fixture's own offering would sort unpredictably (random uuid) — drop its item so the + // only rows in play are the sequenced ones below. + require.NoError(t, db.Delete(&relational.SSPExportOfferingItem{}, "id = ?", fx.itemID).Error) + + stranger := uuid.New() + // Ten offerings allow-listed to somebody else: they sort first and match nothing. + seedByControlPage(t, db, fx, 1, 10, stranger) + // Two offerings with no allow-list at all: open to every downstream, and they sort last. + wanted := seedByControlPage(t, db, fx, 11, 2, uuid.Nil) + + // Unfiltered, the first page is full and the matches are indeed on page three. + require.Len(t, byControlPage(t, h, nil, 5, 0), 5) + + // Filtered, the two matches must arrive on the FIRST page — the filter is applied before + // LIMIT/OFFSET, so pagination walks the filtered set, not the raw one. + page := byControlPage(t, h, &fx.downstreamSSPID, 5, 0) + require.Len(t, page, 2, "the allow-listed rows must be returned, not swallowed by a page of filtered-out rows") + got := []uuid.UUID{page[0].ItemID, page[1].ItemID} + require.ElementsMatch(t, wanted, got) + + // And the page after them is genuinely empty, which is what "end of collection" should mean. + require.Empty(t, byControlPage(t, h, &fx.downstreamSSPID, 5, 2)) +} + +// TestByControlEmptyAllowListMatchesNothing pins the degenerate direction of the SQL predicate: +// when EVERY candidate offering carries an allow-list and none names the caller's downstream, +// the permitted set is empty and the query must match NOTHING. A filter that collapses to +// "no constraint" when the permitted set is empty would leak the entire catalog. +func TestByControlEmptyAllowListMatchesNothing(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := NewSSPExportOfferingHandler(zap.NewNop().Sugar(), db, nil) + + // Put the fixture's own offering behind an allow-list too, so nothing is open by default. + stranger := uuid.New() + require.NoError(t, db.Create(&relational.SSPExportOfferingAllowedDownstream{ + OfferingID: fx.offeringID, DownstreamSSPID: stranger, + }).Error) + seedByControlPage(t, db, fx, 1, 3, stranger) + + require.Len(t, byControlPage(t, h, nil, 100, 0), 4, "the rows exist and are visible unfiltered") + require.Empty(t, byControlPage(t, h, &fx.downstreamSSPID, 100, 0), + "an empty permitted set must match nothing, never everything") + require.Len(t, byControlPage(t, h, &stranger, 100, 0), 4) +} + +// --- CreateItem / UpdateItem parity ------------------------------------------------------- + +// offeringItemWriteCase drives one of the two item write handlers through the same request, so +// the create and update paths cannot drift apart on validation or on the control-id casing +// invariant — the reason UpdateItem being untested mattered. +type offeringItemWriteCase struct { + name string + method string + okStatus int + invoke func(h *SSPExportOfferingHandler, ctx echo.Context) error + extraParam func(fx sharedResponsibilityFixture) (name, value string) +} + +func offeringItemWriteCases() []offeringItemWriteCase { + return []offeringItemWriteCase{ + { + name: "CreateItem", + method: http.MethodPost, + okStatus: http.StatusCreated, + invoke: func(h *SSPExportOfferingHandler, ctx echo.Context) error { return h.CreateItem(ctx) }, + }, + { + name: "UpdateItem", + method: http.MethodPut, + okStatus: http.StatusOK, + invoke: func(h *SSPExportOfferingHandler, ctx echo.Context) error { return h.UpdateItem(ctx) }, + extraParam: func(fx sharedResponsibilityFixture) (string, string) { + return "itemId", fx.itemID.String() + }, + }, + } +} + +func (c offeringItemWriteCase) call(t *testing.T, h *SSPExportOfferingHandler, fx sharedResponsibilityFixture, body string) *httptest.ResponseRecorder { + t.Helper() + + e := echo.New() + httpReq := httptest.NewRequest(c.method, "/", bytes.NewBufferString(body)) + httpReq.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + ctx := e.NewContext(httpReq, rec) + + names := []string{"id", "offeringId"} + values := []string{fx.upstreamSSPID.String(), fx.offeringID.String()} + if c.extraParam != nil { + n, v := c.extraParam(fx) + names = append(names, n) + values = append(values, v) + } + ctx.SetParamNames(names...) + ctx.SetParamValues(values...) + + require.NoError(t, c.invoke(h, ctx)) + return rec +} + +// TestOfferingItemWriteStoresCanonicalControlIdCasing covers BOTH write paths: whatever casing +// the curator sends, validateOfferingItemCoherence canonicalizes it to the requirement's catalog +// casing THROUGH THE POINTER, and the handler must persist that canonicalized value — not the +// bytes the client sent. Subscribe's findOrCreateImplementedRequirement matches control_id +// EXACTLY, so storing "AC-2" against a catalog "ac-2" splits a downstream's tree across two +// requirement rows for one control. The create path was covered; the update path was not. +func TestOfferingItemWriteStoresCanonicalControlIdCasing(t *testing.T) { + for _, tc := range offeringItemWriteCases() { + t.Run(tc.name, func(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := NewSSPExportOfferingHandler(zap.NewNop().Sugar(), db, nil) + + // Poison the stored casing first, so a handler that writes the pre-canonicalization + // value leaves "AC-2" behind rather than coincidentally matching the fixture. + require.NoError(t, db.Model(&relational.SSPExportOfferingItem{}). + Where("id = ?", fx.itemID).Update("control_id", "AC-2").Error) + + body := `{"controlId":"AC-2","statementId":"ac-2_smt.a","componentUuid":"` + + fx.componentID.String() + `","providedUuid":"` + fx.providedID.String() + `"}` + rec := tc.call(t, h, fx, body) + require.Equal(t, tc.okStatus, rec.Code, rec.Body.String()) + + var resp handler.GenericDataResponse[relational.SSPExportOfferingItem] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, "ac-2", resp.Data.ControlID) + + // The response could be built from the request struct; assert what is actually on disk. + var stored relational.SSPExportOfferingItem + require.NoError(t, db.First(&stored, "id = ?", resp.Data.ID).Error) + require.Equal(t, "ac-2", stored.ControlID, + "the persisted control-id must be the catalog's canonical casing, not the client's") + }) + } +} + +// TestOfferingItemWriteRejectsIncoherentTuple: both write paths must reject a +// (controlId, statementId, componentUuid, providedUuid) tuple whose parts do not describe one +// real statement-anchored by-component inside the offering's own SSP, rather than persisting +// four independently-plausible identifiers for the incoherence to surface downstream. +func TestOfferingItemWriteRejectsIncoherentTuple(t *testing.T) { + for _, tc := range offeringItemWriteCases() { + t.Run(tc.name, func(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := NewSSPExportOfferingHandler(zap.NewNop().Sugar(), db, nil) + + for _, bad := range []struct { + name, body, wantErr string + }{ + { + name: "statement-id does not belong to the provided capability", + body: `{"controlId":"ac-2","statementId":"ac-2_smt.z","componentUuid":"` + + fx.componentID.String() + `","providedUuid":"` + fx.providedID.String() + `"}`, + wantErr: "does not match the statement", + }, + { + name: "control-id does not belong to the provided capability", + body: `{"controlId":"ac-99","statementId":"ac-2_smt.a","componentUuid":"` + + fx.componentID.String() + `","providedUuid":"` + fx.providedID.String() + `"}`, + wantErr: "does not match the control", + }, + { + name: "component is not the one exporting the provided capability", + body: `{"controlId":"ac-2","statementId":"ac-2_smt.a","componentUuid":"` + + uuid.New().String() + `","providedUuid":"` + fx.providedID.String() + `"}`, + wantErr: "does not match the component", + }, + { + name: "provided capability does not exist", + body: `{"controlId":"ac-2","statementId":"ac-2_smt.a","componentUuid":"` + + fx.componentID.String() + `","providedUuid":"` + uuid.New().String() + `"}`, + wantErr: "does not exist", + }, + } { + t.Run(bad.name, func(t *testing.T) { + rec := tc.call(t, h, fx, bad.body) + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + require.Contains(t, rec.Body.String(), bad.wantErr) + }) + } + + // Nothing was written or overwritten by any of the rejected requests. + var stored relational.SSPExportOfferingItem + require.NoError(t, db.First(&stored, "id = ?", fx.itemID).Error) + require.Equal(t, "ac-2", stored.ControlID) + require.Equal(t, fx.providedID, stored.ProvidedUUID) + require.Equal(t, int64(1), countRows(t, db, &relational.SSPExportOfferingItem{}, "offering_id = ?", fx.offeringID)) + }) + } +} + +// TestUpdateItemUnknownItemReturns404: the update targets a row by (itemId, offeringId), so an +// id that belongs to no item of this offering must 404 rather than silently succeed. +func TestUpdateItemUnknownItemReturns404(t *testing.T) { + db := newSSPLeverageTestDB(t) + fx := newSharedResponsibilityFixture(t, db) + h := NewSSPExportOfferingHandler(zap.NewNop().Sugar(), db, nil) + + e := echo.New() + body := `{"controlId":"ac-2","statementId":"ac-2_smt.a","componentUuid":"` + + fx.componentID.String() + `","providedUuid":"` + fx.providedID.String() + `"}` + httpReq := httptest.NewRequest(http.MethodPut, "/", bytes.NewBufferString(body)) + httpReq.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + ctx := e.NewContext(httpReq, rec) + ctx.SetParamNames("id", "offeringId", "itemId") + ctx.SetParamValues(fx.upstreamSSPID.String(), fx.offeringID.String(), uuid.New().String()) + + require.NoError(t, h.UpdateItem(ctx)) + require.Equal(t, http.StatusNotFound, rec.Code, rec.Body.String()) +} diff --git a/internal/api/handler/oscal/ssp_leverage.go b/internal/api/handler/oscal/ssp_leverage.go index d92ef82b..fe891634 100644 --- a/internal/api/handler/oscal/ssp_leverage.go +++ b/internal/api/handler/oscal/ssp_leverage.go @@ -783,6 +783,17 @@ func (h *SSPLeverageHandler) Subscribe(ctx echo.Context) error { tracker.addByComponent(byComponent, *stmt.ID, bcCreated) + // Same "one provided-uuid inherited once per SSP" invariant the hand-authored + // inherited create enforces, applied on this creation path too. The link's + // uniqueIndex on (downstream_ssp_id, provided_uuid) only blocks a second SUBSCRIBE: + // a hand-authored inherited row for this provided-uuid leaves no link behind, so + // without this check the subscribe would land on a different (statement, component) + // pair and create a SECOND inherited row for it. Runs under the subtree lock taken + // just above, which is what makes it race-free. + if err := ensureProvidedUUIDNotAlreadyInherited(tx, downstreamSSPID, item.ProvidedUUID); err != nil { + return err + } + inherited := relational.InheritedControlImplementation{ ByComponentId: *byComponent.ID, ProvidedUuid: item.ProvidedUUID, @@ -869,6 +880,9 @@ func (h *SSPLeverageHandler) Subscribe(ctx echo.Context) error { if errors.Is(err, errDuplicateLeverageLink) { return ctx.JSON(http.StatusConflict, api.NewError(err)) } + if errors.Is(err, errProvidedAlreadyInherited) { + return ctx.JSON(http.StatusConflict, api.NewError(err)) + } if errors.Is(err, errDownstreamNotAllowed) { return echo.NewHTTPError(http.StatusForbidden, err.Error()) } @@ -1103,9 +1117,19 @@ type leveragedControlProjection struct { } // projectLeveragedControls builds the projection for every leverage link on one downstream -// SSP in a fixed number of queries (six), 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. +// 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. 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 { diff --git a/internal/api/handler/oscal/ssp_leverage_test.go b/internal/api/handler/oscal/ssp_leverage_test.go index 896bbab7..3bee867f 100644 --- a/internal/api/handler/oscal/ssp_leverage_test.go +++ b/internal/api/handler/oscal/ssp_leverage_test.go @@ -407,3 +407,91 @@ func (suite *SSPLeverageIntegrationSuite) TestResponsibilityFilterFlipsPosture() entry = fetchProjection() suite.Equal("satisfied", entry.ResponsibilityPosture[uuid.MustParse(respBUUID)]) } + +// TestSubscribeRejectsProvidedUUIDAlreadyHandAuthored covers the second creation path of the +// "one provided-uuid may be inherited only once per SSP" invariant. The leverage link's +// uniqueIndex on (downstream_ssp_id, provided_uuid) only stops a second SUBSCRIBE, so the +// ambiguous state was reachable in this order: hand-author an inherited row for provided X +// (no link exists yet), then subscribe to the offering item carrying X — which lands on a +// DIFFERENT (statement, component) pair and would otherwise create a second inherited row. +func (suite *SSPLeverageIntegrationSuite) TestSubscribeRejectsProvidedUUIDAlreadyHandAuthored() { + suite.Require().NoError(suite.Migrator.Refresh()) + + contributorToken := createRoledUser(&suite.IntegrationTestSuite, "dupe-contributor@example.com", "contributor") + server := newCedarServer(&suite.IntegrationTestSuite) + + upstreamComponentUUID := uuid.New().String() + providedUUID := uuid.New().String() + respAUUID := uuid.New().String() + upstreamSSP := sspWithLeverageableCapability(upstreamComponentUUID, providedUUID, respAUUID, uuid.New().String()) + rec := authedRequest(&suite.IntegrationTestSuite, server, "POST", "/api/oscal/system-security-plans", contributorToken, upstreamSSP) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + + rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", + fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings", upstreamSSP.UUID), + contributorToken, map[string]string{"title": "Offering"}) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + var createdOffering handler.GenericDataResponse[relational.SSPExportOffering] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &createdOffering)) + offeringID := createdOffering.Data.ID.String() + + rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", + fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings/%s/items", upstreamSSP.UUID, offeringID), + contributorToken, leverageOfferingItemBody(upstreamComponentUUID, providedUUID)) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + var createdItem handler.GenericDataResponse[relational.SSPExportOfferingItem] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &createdItem)) + + rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", + fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings/%s/publish", upstreamSSP.UUID, offeringID), + contributorToken, nil) + suite.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + + // Downstream carries its OWN statement-anchored by-component (its own component, not the + // leveraged-system component Subscribe materializes) — so the hand-authored inherited row + // and the one Subscribe would create sit on different by-components. + downstreamComponentUUID := uuid.New().String() + downstreamSSP := sspWithLeverageableCapability(downstreamComponentUUID, uuid.New().String(), uuid.New().String(), uuid.New().String()) + rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", "/api/oscal/system-security-plans", contributorToken, downstreamSSP) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + + downstreamReq := downstreamSSP.ControlImplementation.ImplementedRequirements[0] + downstreamStmt := (*downstreamReq.Statements)[0] + downstreamBC := (*downstreamStmt.ByComponents)[0] + + // Hand-author an inherited row for the upstream's provided-uuid. No link exists yet, so + // nothing else forbids it. + rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", + fmt.Sprintf("/api/oscal/system-security-plans/%s/control-implementation/implemented-requirements/%s/statements/%s/by-components/%s/inherited", + downstreamSSP.UUID, downstreamReq.UUID, downstreamStmt.UUID, downstreamBC.UUID), + contributorToken, map[string]any{ + "uuid": uuid.New().String(), + "provided-uuid": providedUUID, + "description": "Hand-authored inherited entry", + }) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + + // Subscribing to the item carrying the same provided-uuid must now be refused, not + // silently create a second inherited row for it. + rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", + "/api/oscal/ssp-export-offerings/"+offeringID+"/subscribe", contributorToken, map[string]any{ + "downstreamSspId": downstreamSSP.UUID, + "items": []map[string]any{{"itemId": createdItem.Data.ID.String(), "satisfiedResponsibilityUuids": []string{respAUUID}}}, + }) + suite.Require().Equal(http.StatusConflict, rec.Code, rec.Body.String()) + + // Exactly one inherited row for the provided-uuid survives anywhere in the downstream SSP, + // and the failed subscribe left no link behind. + inSSP, err := byComponentIDsForSSP(suite.DB, uuid.MustParse(downstreamSSP.UUID)) + suite.Require().NoError(err) + var inheritedCount int64 + suite.Require().NoError(suite.DB.Model(&relational.InheritedControlImplementation{}). + Where("provided_uuid = ? AND by_component_id IN ?", providedUUID, inSSP). + Count(&inheritedCount).Error) + suite.Equal(int64(1), inheritedCount, "the provided-uuid must be inherited exactly once in the SSP") + + var linkCount int64 + suite.Require().NoError(suite.DB.Model(&relational.SSPLeverageLink{}). + Where("downstream_ssp_id = ?", downstreamSSP.UUID).Count(&linkCount).Error) + suite.Equal(int64(0), linkCount, "the rejected subscribe must not leave a leverage link") +} diff --git a/internal/api/handler/oscal/ssp_shared_responsibility_test.go b/internal/api/handler/oscal/ssp_shared_responsibility_test.go index acfde612..a9faf432 100644 --- a/internal/api/handler/oscal/ssp_shared_responsibility_test.go +++ b/internal/api/handler/oscal/ssp_shared_responsibility_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "github.com/google/uuid" @@ -674,13 +675,37 @@ func TestInheritedPutRejectsProvidedUuidChange(t *testing.T) { } require.NoError(t, db.Create(&inherited).Error) - body := fmt.Sprintf(`{"provided-uuid": %q, "description": "sneaky"}`, uuid.New().String()) - ctx, rec := newByComponentContext(http.MethodPut, body, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID, - [2]string{"inheritedId", inherited.ID.String()}) - require.NoError(t, h.UpdateImplementedRequirementStatementByComponentInherited(ctx)) + put := func(body string) *httptest.ResponseRecorder { + ctx, rec := newByComponentContext(http.MethodPut, body, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID, + [2]string{"inheritedId", inherited.ID.String()}) + require.NoError(t, h.UpdateImplementedRequirementStatementByComponentInherited(ctx)) + return rec + } + // A genuinely different provided-uuid is rejected. + rec := put(fmt.Sprintf(`{"provided-uuid": %q, "description": "sneaky"}`, uuid.New().String())) require.Equal(t, http.StatusBadRequest, rec.Code) require.Contains(t, rec.Body.String(), "immutable") + + // A malformed provided-uuid is rejected too, with the same message: it cannot be shown to be + // the stored value, so it is treated as an attempt to change it. + rec = put(`{"provided-uuid": "not-a-uuid", "description": "sneaky"}`) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "immutable") + + var stored relational.InheritedControlImplementation + require.NoError(t, db.First(&stored, "id = ?", inherited.ID).Error) + require.Equal(t, "inherited", stored.Description, "the rejected PUTs must not have written anything") + + // RFC 4122 permits uppercase, and toolchains that round-trip a GET into a PUT preserve + // whatever casing they were handed. The same uuid in uppercase is the same uuid. + rec = put(fmt.Sprintf(`{"provided-uuid": %q, "description": "revised"}`, + strings.ToUpper(inherited.ProvidedUuid.String()))) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + require.NoError(t, db.First(&stored, "id = ?", inherited.ID).Error) + require.Equal(t, inherited.ProvidedUuid, stored.ProvidedUuid, "an uppercase round-trip must not mutate provided-uuid") + require.Equal(t, "revised", stored.Description) } // TestDeleteSubscriptionOwnedInheritedConflicts: an inherited entry an SSPLeverageLink still @@ -791,13 +816,34 @@ func TestSatisfiedPutRejectsResponsibilityUuidChange(t *testing.T) { } require.NoError(t, db.Create(&satisfied).Error) - body := fmt.Sprintf(`{"responsibility-uuid": %q}`, uuid.New().String()) - ctx, rec := newByComponentContext(http.MethodPut, body, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID, - [2]string{"satisfiedId", satisfied.ID.String()}) - require.NoError(t, h.UpdateImplementedRequirementStatementByComponentSatisfied(ctx)) + put := func(body string) *httptest.ResponseRecorder { + ctx, rec := newByComponentContext(http.MethodPut, body, fx.upstreamSSPID, fx.requirementID, fx.statementID, fx.byComponentID, + [2]string{"satisfiedId", satisfied.ID.String()}) + require.NoError(t, h.UpdateImplementedRequirementStatementByComponentSatisfied(ctx)) + return rec + } + rec := put(fmt.Sprintf(`{"responsibility-uuid": %q}`, uuid.New().String())) require.Equal(t, http.StatusBadRequest, rec.Code) require.Contains(t, rec.Body.String(), "immutable") + + // Malformed folds into the same 400 — it cannot be shown to be the stored value. + rec = put(`{"responsibility-uuid": "not-a-uuid"}`) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "immutable") + + var stored relational.SatisfiedControlImplementationResponsibility + require.NoError(t, db.First(&stored, "id = ?", satisfied.ID).Error) + require.Equal(t, "satisfied", stored.Description, "the rejected PUTs must not have written anything") + + // The stored uuid in canonical uppercase is the same uuid, not a mutation. + rec = put(fmt.Sprintf(`{"responsibility-uuid": %q, "description": "revised"}`, + strings.ToUpper(satisfied.ResponsibilityUuid.String()))) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + require.NoError(t, db.First(&stored, "id = ?", satisfied.ID).Error) + require.Equal(t, satisfied.ResponsibilityUuid, stored.ResponsibilityUuid, "an uppercase round-trip must not mutate responsibility-uuid") + require.Equal(t, "revised", stored.Description) } // TestSatisfiedWritesOnHandAuthoredEntriesSkipLeverageResync: a by-component with no leverage diff --git a/internal/api/handler/oscal/subtree_lock_concurrency_integration_test.go b/internal/api/handler/oscal/subtree_lock_concurrency_integration_test.go index 18e4717f..1b571a69 100644 --- a/internal/api/handler/oscal/subtree_lock_concurrency_integration_test.go +++ b/internal/api/handler/oscal/subtree_lock_concurrency_integration_test.go @@ -43,114 +43,193 @@ func (suite *SubtreeLockConcurrencySuite) SetupTest() { // first's — leaving `partial` stored while both responsibilities are in fact satisfied. The drift // detector and the notification path consume that stored value. func (suite *SubtreeLockConcurrencySuite) TestConcurrentSatisfiedWritesConvergeOnProjection() { - fx := suite.seedTwoResponsibilityFixture() + // Repeated over a fresh fixture each iteration: a single lucky interleaving must not be able + // to carry the suite. + for iter := 0; iter < 20; iter++ { + fx := suite.seedTwoResponsibilityFixture() - // Both writers target the same by-component, each satisfying one of the two responsibilities. - var wg sync.WaitGroup - errs := make([]error, 2) - responsibilities := []uuid.UUID{fx.respAID, fx.respBID} - - wg.Add(len(responsibilities)) - for i, respID := range responsibilities { - go func(i int, respID uuid.UUID) { - defer wg.Done() - errs[i] = suite.DB.Transaction(func(tx *gorm.DB) error { - if err := lockByComponentSubtreeWrite(tx, fx.byComponentID); err != nil { - return err - } - satisfied := relational.SatisfiedControlImplementationResponsibility{ - ByComponentId: fx.byComponentID, - ResponsibilityUuid: respID, - Description: "satisfied concurrently", - } - if err := tx.Create(&satisfied).Error; err != nil { - return err - } - return resyncLeverageSatisfaction(tx, fx.downstreamSSPID, fx.byComponentID) - }) - }(i, respID) - } - wg.Wait() + // Both writers target the same by-component, each satisfying one of the two responsibilities. + var wg sync.WaitGroup + errs := make([]error, 2) + responsibilities := []uuid.UUID{fx.respAID, fx.respBID} - for i, err := range errs { - suite.Require().NoErrorf(err, "concurrent satisfied write %d failed", i) - } + // Rendezvous: both transactions must have inserted their satisfied row and still be open + // when either one derives satisfaction. Without it, goroutine 1 routinely runs its whole + // BEGIN → INSERT → resync → COMMIT before goroutine 2 opens a transaction, and the `full` + // assertion below holds even with the lock removed — green for the wrong reason. + // + // The barrier sits AFTER the INSERT and BEFORE resyncLeverageSatisfaction, which is what + // takes the advisory lock. It must never be crossed while holding that lock: if both + // goroutines waited here with one of them holding it, the other could never arrive and the + // test would deadlock rather than race. + started := make(chan struct{}, 2) + release := make(chan struct{}) - var satisfiedCount int64 - suite.Require().NoError(suite.DB.Model(&relational.SatisfiedControlImplementationResponsibility{}). - Where("by_component_id = ?", fx.byComponentID).Count(&satisfiedCount).Error) - suite.Require().Equal(int64(2), satisfiedCount, "both satisfied rows must persist") + wg.Add(len(responsibilities)) + for i, respID := range responsibilities { + go func(i int, respID uuid.UUID) { + defer wg.Done() + errs[i] = suite.DB.Transaction(func(tx *gorm.DB) error { + satisfied := relational.SatisfiedControlImplementationResponsibility{ + ByComponentId: fx.byComponentID, + ResponsibilityUuid: respID, + Description: "satisfied concurrently", + } + if err := tx.Create(&satisfied).Error; err != nil { + return err + } + started <- struct{}{} + <-release + // Takes lockByComponentSubtreeWrite itself; this is the read-modify-write the + // lock exists to serialize. + return resyncLeverageSatisfaction(tx, fx.downstreamSSPID, fx.byComponentID) + }) + }(i, respID) + } + <-started + <-started + close(release) + wg.Wait() - var link relational.SSPLeverageLink - suite.Require().NoError(suite.DB.First(&link, "id = ?", fx.linkID).Error) - suite.Equal(relational.SSPLeverageSatisfactionFull, link.Satisfaction, - "both responsibilities are satisfied, so the stored satisfaction must be full — "+ - "`partial` means one writer's derivation overwrote the other's with a stale value") + for i, err := range errs { + suite.Require().NoErrorf(err, "iteration %d: concurrent satisfied write %d failed", iter, i) + } + + var satisfiedCount int64 + suite.Require().NoError(suite.DB.Model(&relational.SatisfiedControlImplementationResponsibility{}). + Where("by_component_id = ?", fx.byComponentID).Count(&satisfiedCount).Error) + suite.Require().Equalf(int64(2), satisfiedCount, "iteration %d: both satisfied rows must persist", iter) + + var link relational.SSPLeverageLink + suite.Require().NoError(suite.DB.First(&link, "id = ?", fx.linkID).Error) + suite.Require().Equalf(relational.SSPLeverageSatisfactionFull, link.Satisfaction, + "iteration %d: both responsibilities are satisfied, so the stored satisfaction must be "+ + "full — `partial` means one writer's derivation overwrote the other's with a stale value", iter) + } } -// TestConcurrentSatisfiedWriteAndInheritedDeleteDoNotDangle: a satisfied create and a delete of the -// inherited row it depends on, racing. Whichever order they serialize in, the result must be -// coherent — the delete is either refused (the link owns the row) or the satisfied row is rejected -// as no longer inheritable. What must never happen is a surviving link whose inherited row is gone. +// TestConcurrentSatisfiedWriteAndInheritedDeleteDoNotDangle: a satisfied create over one inherited +// row racing the DELETE of a SIBLING inherited row on the same by-component. +// +// The sibling deliberately has no leverage link (a hand-authored inherited entry), so +// assertInheritedNotSubscribed permits its delete — meaning the delete genuinely runs and the two +// transactions genuinely contend. Racing the delete of the LINKED row instead would prove nothing: +// assertInheritedNotSubscribed refuses it in every possible interleaving, so both assertions would +// hold with the lock deleted, run sequentially, or with the satisfied writer removed entirely. +// +// Both orderings are legal, so this asserts COHERENCE, not a winner: both transactions succeed, the +// unlinked row is gone, and the surviving link's stored Satisfaction equals what a fresh derivation +// over the final satisfied set produces — no stale value survived, and no satisfied row points at a +// deleted inherited row. +// +// SCOPE, stated so this is not over-trusted: this test is non-vacuous (the delete genuinely runs and +// the coherence assertions can fail on a dangling satisfied row or a stale satisfaction), but it is +// NOT lock-discriminating — it was verified to still pass with lockByComponentSubtreeWrite neutered. +// The lock-discriminating test in this suite is TestConcurrentSatisfiedWritesConvergeOnProjection, +// which was verified to go red ("full" -> "partial", a genuine lost update) without the lock. Do not +// treat a green run here as evidence the lock is present or working. func (suite *SubtreeLockConcurrencySuite) TestConcurrentSatisfiedWriteAndInheritedDeleteDoNotDangle() { fx := suite.seedTwoResponsibilityFixture() var wg sync.WaitGroup + errs := make([]error, 2) + started := make(chan struct{}, 2) + release := make(chan struct{}) wg.Add(2) + // Satisfies BOTH responsibilities, so the derived satisfaction moves off the seeded `partial` + // and a stale stored value is distinguishable from a freshly derived one. go func() { defer wg.Done() - _ = suite.DB.Transaction(func(tx *gorm.DB) error { - if err := lockByComponentSubtreeWrite(tx, fx.byComponentID); err != nil { - return err - } - satisfied := relational.SatisfiedControlImplementationResponsibility{ - ByComponentId: fx.byComponentID, - ResponsibilityUuid: fx.respAID, - Description: "satisfied during delete", - } - if err := tx.Create(&satisfied).Error; err != nil { - return err + errs[0] = suite.DB.Transaction(func(tx *gorm.DB) error { + for _, respID := range []uuid.UUID{fx.respAID, fx.respBID} { + satisfied := relational.SatisfiedControlImplementationResponsibility{ + ByComponentId: fx.byComponentID, + ResponsibilityUuid: respID, + Description: "satisfied during delete", + } + if err := tx.Create(&satisfied).Error; err != nil { + return err + } } + // Rendezvous before the advisory lock is taken — see the note in the lost-update test. + started <- struct{}{} + <-release return resyncLeverageSatisfaction(tx, fx.downstreamSSPID, fx.byComponentID) }) }() go func() { defer wg.Done() - _ = suite.DB.Transaction(func(tx *gorm.DB) error { + errs[1] = suite.DB.Transaction(func(tx *gorm.DB) error { + started <- struct{}{} + <-release if err := lockByComponentSubtreeWrite(tx, fx.byComponentID); err != nil { return err } - if err := assertInheritedNotSubscribed(tx, []uuid.UUID{fx.inheritedID}); err != nil { + if err := assertInheritedNotSubscribed(tx, []uuid.UUID{fx.unlinkedInheritedID}); err != nil { return err } - return tx.Delete(&relational.InheritedControlImplementation{}, "id = ?", fx.inheritedID).Error + return tx.Delete(&relational.InheritedControlImplementation{}, "id = ?", fx.unlinkedInheritedID).Error }) }() + <-started + <-started + close(release) wg.Wait() - // The link is owned by a subscription, so the delete must have been refused outright: the - // inherited row survives and nothing dangles. + suite.Require().NoError(errs[0], "the satisfied write must succeed") + // The target row carries no leverage link, so the guard permits it in every interleaving. + suite.Require().NoError(errs[1], "deleting the unsubscribed inherited row must succeed") + + // The satisfied write actually happened — without this the test could pass having exercised + // nothing. + var satisfiedCount int64 + suite.Require().NoError(suite.DB.Model(&relational.SatisfiedControlImplementationResponsibility{}). + Where("by_component_id = ?", fx.byComponentID).Count(&satisfiedCount).Error) + suite.Require().Equal(int64(2), satisfiedCount, "both satisfied rows must persist") + + var unlinkedCount int64 + suite.Require().NoError(suite.DB.Model(&relational.InheritedControlImplementation{}). + Where("id = ?", fx.unlinkedInheritedID).Count(&unlinkedCount).Error) + suite.Equal(int64(0), unlinkedCount, "the unsubscribed inherited row must be gone") + + // The linked row is untouched, so the link still resolves. + var link relational.SSPLeverageLink + suite.Require().NoError(suite.DB.First(&link, "id = ?", fx.linkID).Error) + suite.Require().Equal(fx.inheritedID, link.InheritedUUID, "link.InheritedUUID must still resolve") + var inheritedCount int64 suite.Require().NoError(suite.DB.Model(&relational.InheritedControlImplementation{}). Where("id = ?", fx.inheritedID).Count(&inheritedCount).Error) - suite.Equal(int64(1), inheritedCount, - "the inherited row is referenced by a leverage link, so it must never be deleted") + suite.Require().Equal(int64(1), inheritedCount, "the subscribed inherited row must survive") - var link relational.SSPLeverageLink + // Coherence: whatever order the two transactions serialized in, the stored value must equal + // what a fresh derivation over the FINAL satisfied set produces. + suite.Require().Equal(relational.SSPLeverageSatisfactionFull, link.Satisfaction, + "both responsibilities are satisfied, so the stored satisfaction must be full") + settled := link.Satisfaction + suite.Require().NoError(suite.DB.Transaction(func(tx *gorm.DB) error { + return resyncLeverageSatisfaction(tx, fx.downstreamSSPID, fx.byComponentID) + })) suite.Require().NoError(suite.DB.First(&link, "id = ?", fx.linkID).Error) - suite.Equal(fx.inheritedID, link.InheritedUUID, "link.InheritedUUID must still resolve") + suite.Equal(settled, link.Satisfaction, + "a fresh resync must be a no-op — a change means the racing writers left a stale value") } type subtreeLockFixture struct { downstreamSSPID uuid.UUID byComponentID uuid.UUID inheritedID uuid.UUID - linkID uuid.UUID - respAID uuid.UUID - respBID uuid.UUID + // unlinkedInheritedID is a sibling inherited row on the same by-component that no + // SSPLeverageLink references — the hand-authored shape resyncLeverageSatisfaction documents as + // "no link, skipped silently". It is what makes a delete racing the satisfied write actually + // reachable: assertInheritedNotSubscribed refuses the linked row unconditionally. + unlinkedInheritedID uuid.UUID + linkID uuid.UUID + respAID uuid.UUID + respBID uuid.UUID } // seedTwoResponsibilityFixture builds an upstream capability carrying TWO responsibilities, a @@ -176,6 +255,10 @@ func (suite *SubtreeLockConcurrencySuite) seedTwoResponsibilityFixture() subtree suite.Require().NoError(db.Create(&export).Error) provided := relational.ProvidedControlImplementation{ExportId: *export.ID, Description: "provided"} suite.Require().NoError(db.Create(&provided).Error) + // A second capability, inherited downstream but never subscribed to — carries no + // responsibilities, so it contributes nothing to the derived satisfaction of the linked one. + providedUnlinked := relational.ProvidedControlImplementation{ExportId: *export.ID, Description: "provided (unlinked)"} + suite.Require().NoError(db.Create(&providedUnlinked).Error) respA := relational.ControlImplementationResponsibility{ ExportId: *export.ID, ProvidedUuid: *provided.ID, Description: "responsibility A", @@ -203,6 +286,11 @@ func (suite *SubtreeLockConcurrencySuite) seedTwoResponsibilityFixture() subtree } suite.Require().NoError(db.Create(&inherited).Error) + unlinkedInherited := relational.InheritedControlImplementation{ + ByComponentId: *downstreamBC.ID, ProvidedUuid: *providedUnlinked.ID, Description: "inherited (no link)", + } + suite.Require().NoError(db.Create(&unlinkedInherited).Error) + // Starts `partial`: nothing is satisfied yet, so a stale value is distinguishable from a // freshly derived one. link := relational.SSPLeverageLink{ @@ -218,11 +306,12 @@ func (suite *SubtreeLockConcurrencySuite) seedTwoResponsibilityFixture() subtree suite.Require().NoError(db.Create(&link).Error) return subtreeLockFixture{ - downstreamSSPID: *downstreamSSP.ID, - byComponentID: *downstreamBC.ID, - inheritedID: *inherited.ID, - linkID: *link.ID, - respAID: *respA.ID, - respBID: *respB.ID, + downstreamSSPID: *downstreamSSP.ID, + byComponentID: *downstreamBC.ID, + inheritedID: *inherited.ID, + unlinkedInheritedID: *unlinkedInherited.ID, + linkID: *link.ID, + respAID: *respA.ID, + respBID: *respB.ID, } } diff --git a/internal/service/migrator.go b/internal/service/migrator.go index a162fca8..1e1d3214 100644 --- a/internal/service/migrator.go +++ b/internal/service/migrator.go @@ -740,6 +740,9 @@ const ( // bind-parameter ceiling per statement. Well under it, since several IN-lists are built // per batch and a migration error is fatal to boot. inClauseChunkSize = 1000 + // maxUndeterminableSample bounds how many undeterminable row IDs are retained for logging, + // so the diagnostic list cannot grow with the size of the legacy table. + maxUndeterminableSample = 100 ) // uniqueUUIDs extracts the deduplicated set of UUIDs from items, preserving first-seen order. @@ -805,7 +808,10 @@ func migrateBackfillOfferingItemStatementIDs(db *gorm.DB) error { } var backfilled, danglingProvided, missingExport, requirementAnchored, emptyStatementID int - var undeterminableIDs []uuid.UUID + // Only a bounded sample of the undeterminable IDs is kept: requirement-anchored rows are the + // expected legacy shape, so a legacy table can produce millions of them and this runs on the + // boot path. The per-cause counters below carry the full totals. + var undeterminableSample []uuid.UUID if err := db.Transaction(func(tx *gorm.DB) error { var items []relational.SSPExportOfferingItem @@ -857,25 +863,33 @@ func migrateBackfillOfferingItemStatementIDs(db *gorm.DB) error { exportID, ok := exportIDByProvided[item.ProvidedUUID] if !ok { danglingProvided++ - undeterminableIDs = append(undeterminableIDs, *item.ID) + if len(undeterminableSample) < maxUndeterminableSample { + undeterminableSample = append(undeterminableSample, *item.ID) + } continue } byComponentID, ok := byComponentIDByExport[exportID] if !ok { missingExport++ - undeterminableIDs = append(undeterminableIDs, *item.ID) + if len(undeterminableSample) < maxUndeterminableSample { + undeterminableSample = append(undeterminableSample, *item.ID) + } continue } statementRowID, ok := statementIDByComponent[byComponentID] if !ok { requirementAnchored++ - undeterminableIDs = append(undeterminableIDs, *item.ID) + if len(undeterminableSample) < maxUndeterminableSample { + undeterminableSample = append(undeterminableSample, *item.ID) + } continue } statementID, ok := statementIDByRow[statementRowID] if !ok || statementID == "" { emptyStatementID++ - undeterminableIDs = append(undeterminableIDs, *item.ID) + if len(undeterminableSample) < maxUndeterminableSample { + undeterminableSample = append(undeterminableSample, *item.ID) + } continue } @@ -892,7 +906,8 @@ func migrateBackfillOfferingItemStatementIDs(db *gorm.DB) error { return err } - if backfilled == 0 && len(undeterminableIDs) == 0 { + undeterminable := requirementAnchored + danglingProvided + missingExport + emptyStatementID + if backfilled == 0 && undeterminable == 0 { return nil } @@ -902,17 +917,19 @@ func migrateBackfillOfferingItemStatementIDs(db *gorm.DB) error { "(%d requirement-anchored [expected legacy], %d dangling provided-uuid [data corruption], "+ "%d export missing [data corruption], %d statement has empty statement-id)", backfilled, - len(undeterminableIDs), + undeterminable, requirementAnchored, danglingProvided, missingExport, emptyStatementID, ) - if len(undeterminableIDs) > 0 { + if len(undeterminableSample) > 0 { db.Logger.Info( context.Background(), - "Offering items with a statement_id that could not be derived: %v", - undeterminableIDs, + "Offering items with a statement_id that could not be derived (showing first %d of %d): %v", + len(undeterminableSample), + undeterminable, + undeterminableSample, ) } return nil From 07f1f13f920e2f44a16da6842b896802241f5c6c Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:03:19 -0300 Subject: [PATCH 13/13] fix: address review feedback --- .../api/handler/oscal/ssp_by_components.go | 72 ++++------ .../api/handler/oscal/ssp_leverage_test.go | 133 ++++++++++++++++++ 2 files changed, 160 insertions(+), 45 deletions(-) diff --git a/internal/api/handler/oscal/ssp_by_components.go b/internal/api/handler/oscal/ssp_by_components.go index bffcb41a..cab15b63 100644 --- a/internal/api/handler/oscal/ssp_by_components.go +++ b/internal/api/handler/oscal/ssp_by_components.go @@ -49,41 +49,6 @@ var errProvidedAlreadyInherited = errors.New("provided-uuid is already inherited var errDuplicateInheritedUUID = errors.New("an inherited entry with this uuid already exists") var errDuplicateSatisfiedUUID = errors.New("a satisfied entry with this uuid already exists") -// byComponentIDsForSSP lists every by-component id under an SSP, across both anchoring levels -// (statement-anchored, and the legacy requirement-anchored shape). The chain is -// control_implementations -> implemented_requirements -> statements -> by_components; nothing -// carries an SSP id further down, so it has to be walked. -func byComponentIDsForSSP(db *gorm.DB, sspID uuid.UUID) ([]uuid.UUID, error) { - var requirementIDs []uuid.UUID - if err := db.Model(&relational.ImplementedRequirement{}). - Joins("JOIN control_implementations ON control_implementations.id = implemented_requirements.control_implementation_id"). - Where("control_implementations.system_security_plan_id = ?", sspID). - Pluck("implemented_requirements.id", &requirementIDs).Error; err != nil { - return nil, err - } - if len(requirementIDs) == 0 { - return nil, nil - } - - var statementIDs []uuid.UUID - if err := db.Model(&relational.Statement{}). - Where("implemented_requirement_id IN ?", requirementIDs). - Pluck("id", &statementIDs).Error; err != nil { - return nil, err - } - - query := db.Model(&relational.ByComponent{}). - Where("parent_id IN ? AND parent_type = ?", requirementIDs, "implemented_requirements") - if len(statementIDs) > 0 { - query = query.Or("parent_id IN ? AND parent_type = ?", statementIDs, "statements") - } - var byComponentIDs []uuid.UUID - if err := query.Pluck("id", &byComponentIDs).Error; err != nil { - return nil, err - } - return byComponentIDs, nil -} - // ensureProvidedUUIDNotAlreadyInherited enforces "one provided-uuid may be inherited only once // per SSP". A subscription's link is reached through its inherited row, and satisfaction is // derived from the owning by-component's satisfied set — so a second by-component inheriting the @@ -95,18 +60,35 @@ func byComponentIDsForSSP(db *gorm.DB, sspID uuid.UUID) ([]uuid.UUID, error) { // // tx must be the caller's transaction, holding the by-component subtree lock — checking outside it // races the very concurrent create the lock exists to serialize. +// +// Subscribe calls this once PER ITEM, which is what catches two items in one request carrying the +// same provided-uuid — hoisting it out of that loop would resolve the SSP once and miss exactly +// that case. So it has to be cheap per call: this walks the chain in ONE query with two bind +// parameters, rather than materializing the SSP's requirement/statement/by-component id sets and +// passing them back as `IN ?` lists. Those lists are unbounded in SSP size, and `IN ?` expands to +// one bind parameter per element — the same 65535-bind ceiling allowedDownstreamSQL avoids for the +// offering allow-list and findByIDsInChunks works around in the migrator backfill. An N-item +// subscribe also re-derived those sets N times, inside the advisory lock's critical section. +// +// No CAST anywhere: ByComponent.ParentID and Statement.ImplementedRequirementId are both uuid, so +// the joins compare like types. Casting to uuid here would break the sqlite-backed unit suites. +// The parent_type literals match byComponentIDsForSSP's, which are GORM's polymorphic table names. func ensureProvidedUUIDNotAlreadyInherited(tx *gorm.DB, sspID uuid.UUID, providedUUID uuid.UUID) error { - inSSP, err := byComponentIDsForSSP(tx, sspID) - if err != nil { - return err - } - if len(inSSP) == 0 { - return nil - } + const dupeSQL = ` + SELECT COUNT(*) + FROM inherited_control_implementations i + JOIN by_components bc ON bc.id = i.by_component_id + LEFT JOIN statements s + ON bc.parent_type = 'statements' AND s.id = bc.parent_id + JOIN implemented_requirements ir + ON ir.id = CASE WHEN bc.parent_type = 'statements' + THEN s.implemented_requirement_id + ELSE bc.parent_id END + JOIN control_implementations ci ON ci.id = ir.control_implementation_id + WHERE ci.system_security_plan_id = ? AND i.provided_uuid = ?` + var dupe int64 - if err := tx.Model(&relational.InheritedControlImplementation{}). - Where("provided_uuid = ? AND by_component_id IN ?", providedUUID, inSSP). - Count(&dupe).Error; err != nil { + if err := tx.Raw(dupeSQL, sspID, providedUUID).Scan(&dupe).Error; err != nil { return err } if dupe > 0 { diff --git a/internal/api/handler/oscal/ssp_leverage_test.go b/internal/api/handler/oscal/ssp_leverage_test.go index 3bee867f..bc696420 100644 --- a/internal/api/handler/oscal/ssp_leverage_test.go +++ b/internal/api/handler/oscal/ssp_leverage_test.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/suite" "gorm.io/datatypes" + "gorm.io/gorm" "github.com/compliance-framework/api/internal/api/handler" "github.com/compliance-framework/api/internal/converters/labelfilter" @@ -68,6 +69,49 @@ func sspWithLeverageableCapability(componentUUID, providedUUID, respAUUID, respB return ssp } +// byComponentIDsForSSP lists every by-component id under an SSP, across both anchoring levels +// (statement-anchored, and the legacy requirement-anchored shape). The chain is +// control_implementations -> implemented_requirements -> statements -> by_components; nothing +// carries an SSP id further down, so it has to be walked. +// +// This is TEST-ONLY, which is why it lives here. Production used to walk the chain this way inside +// ensureProvidedUUIDNotAlreadyInherited, but that materialized the SSP's whole id set on every +// call and passed it back as unbounded `IN ?` lists — one bind parameter per by-component, against +// the 65535 ceiling, re-derived once per subscribe item. That helper now does the same walk as a +// single correlated query with two bind parameters. Tests still want the id list itself in order +// to assert over it, so the set-materializing form survives here rather than as dead production +// code the linter flags. +func byComponentIDsForSSP(db *gorm.DB, sspID uuid.UUID) ([]uuid.UUID, error) { + var requirementIDs []uuid.UUID + if err := db.Model(&relational.ImplementedRequirement{}). + Joins("JOIN control_implementations ON control_implementations.id = implemented_requirements.control_implementation_id"). + Where("control_implementations.system_security_plan_id = ?", sspID). + Pluck("implemented_requirements.id", &requirementIDs).Error; err != nil { + return nil, err + } + if len(requirementIDs) == 0 { + return nil, nil + } + + var statementIDs []uuid.UUID + if err := db.Model(&relational.Statement{}). + Where("implemented_requirement_id IN ?", requirementIDs). + Pluck("id", &statementIDs).Error; err != nil { + return nil, err + } + + query := db.Model(&relational.ByComponent{}). + Where("parent_id IN ? AND parent_type = ?", requirementIDs, "implemented_requirements") + if len(statementIDs) > 0 { + query = query.Or("parent_id IN ? AND parent_type = ?", statementIDs, "statements") + } + var byComponentIDs []uuid.UUID + if err := query.Pluck("id", &byComponentIDs).Error; err != nil { + return nil, err + } + return byComponentIDs, nil +} + // leverageOfferingItemBody is the statement-anchored offering item pointing at the fixture's // provided capability. statementId is required on every write, and the whole (controlId, // statementId, componentUuid, providedUuid) tuple must actually resolve inside the upstream SSP. @@ -495,3 +539,92 @@ func (suite *SSPLeverageIntegrationSuite) TestSubscribeRejectsProvidedUUIDAlread Where("downstream_ssp_id = ?", downstreamSSP.UUID).Count(&linkCount).Error) suite.Equal(int64(0), linkCount, "the rejected subscribe must not leave a leverage link") } + +// TestSubscribeRejectsDuplicateProvidedUUIDWithinOneRequest pins the INTRA-REQUEST half of the +// "one provided-uuid inherited once per SSP" invariant: two items in a SINGLE subscribe carrying +// the same provided-uuid. +// +// It is rejected 400 by the request-level dedup guard (the seenProvidedUUIDs check in Subscribe), +// NOT 409 by ensureProvidedUUIDNotAlreadyInherited — the request never reaches the item loop. That +// guard predates the per-SSP invariant and exists for its own reason: a repeated provided-uuid +// would slip past the existing-link pre-check (which only sees committed rows) and hit +// UNIQUE(downstream_ssp_id, provided_uuid) mid-transaction. +// +// Recorded explicitly because it corrects a plausible assumption about why +// ensureProvidedUUIDNotAlreadyInherited sits inside the item loop: the intra-request case is +// already covered here, so the per-item placement is defence in depth against a future caller +// reaching the loop another way, not the only thing standing between this input and a duplicate. +// If the request-level guard is ever relaxed, this test goes red first and points at the reason. +func (suite *SSPLeverageIntegrationSuite) TestSubscribeRejectsDuplicateProvidedUUIDWithinOneRequest() { + suite.Require().NoError(suite.Migrator.Refresh()) + + contributorToken := createRoledUser(&suite.IntegrationTestSuite, "intra-dupe@example.com", "contributor") + server := newCedarServer(&suite.IntegrationTestSuite) + + upstreamComponentUUID := uuid.New().String() + providedUUID := uuid.New().String() + respAUUID := uuid.New().String() + upstreamSSP := sspWithLeverageableCapability(upstreamComponentUUID, providedUUID, respAUUID, uuid.New().String()) + rec := authedRequest(&suite.IntegrationTestSuite, server, "POST", "/api/oscal/system-security-plans", contributorToken, upstreamSSP) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + + rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", + fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings", upstreamSSP.UUID), + contributorToken, map[string]string{"title": "Offering"}) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + var createdOffering handler.GenericDataResponse[relational.SSPExportOffering] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &createdOffering)) + offeringID := createdOffering.Data.ID.String() + + // TWO items resolving to the same provided-uuid. Nothing forbids this: SSPExportOfferingItem + // carries no uniqueness over (controlId, statementId, componentUuid, providedUuid), so an + // upstream can publish the same capability twice in one offering. + itemIDs := make([]string, 0, 2) + for range 2 { + rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", + fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings/%s/items", upstreamSSP.UUID, offeringID), + contributorToken, leverageOfferingItemBody(upstreamComponentUUID, providedUUID)) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + var createdItem handler.GenericDataResponse[relational.SSPExportOfferingItem] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &createdItem)) + itemIDs = append(itemIDs, createdItem.Data.ID.String()) + } + suite.Require().NotEqual(itemIDs[0], itemIDs[1], "the two items must be distinct rows") + + rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", + fmt.Sprintf("/api/oscal/system-security-plans/%s/export-offerings/%s/publish", upstreamSSP.UUID, offeringID), + contributorToken, nil) + suite.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + + downstreamSSP := minimalSSP(uuid.New().String()) + rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", "/api/oscal/system-security-plans", contributorToken, downstreamSSP) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + + rec = authedRequest(&suite.IntegrationTestSuite, server, "POST", + "/api/oscal/ssp-export-offerings/"+offeringID+"/subscribe", contributorToken, map[string]any{ + "downstreamSspId": downstreamSSP.UUID, + "items": []map[string]any{ + {"itemId": itemIDs[0], "satisfiedResponsibilityUuids": []string{respAUUID}}, + {"itemId": itemIDs[1], "satisfiedResponsibilityUuids": []string{respAUUID}}, + }, + }) + suite.Require().Equal(http.StatusBadRequest, rec.Code, rec.Body.String()) + suite.Contains(rec.Body.String(), "duplicate provided-uuid", + "rejected by the request-level dedup guard, before the item loop is entered") + + // Rejected before any write, so the SSP holds no inherited row and no link at all. + inSSP, err := byComponentIDsForSSP(suite.DB, uuid.MustParse(downstreamSSP.UUID)) + suite.Require().NoError(err) + var inheritedCount int64 + if len(inSSP) > 0 { + suite.Require().NoError(suite.DB.Model(&relational.InheritedControlImplementation{}). + Where("provided_uuid = ? AND by_component_id IN ?", providedUUID, inSSP). + Count(&inheritedCount).Error) + } + suite.Equal(int64(0), inheritedCount, "the rejected subscribe must write no inherited row") + + var linkCount int64 + suite.Require().NoError(suite.DB.Model(&relational.SSPLeverageLink{}). + Where("downstream_ssp_id = ?", downstreamSSP.UUID).Count(&linkCount).Error) + suite.Equal(int64(0), linkCount, "the rejected subscribe must not leave a leverage link") +}