diff --git a/.env.example b/.env.example index e3155673..a7b78029 100644 --- a/.env.example +++ b/.env.example @@ -56,3 +56,10 @@ CCF_PLAYBACK_MAX_CONCURRENT=4 # Policy evaluation artifacts: POST /api/agent/artifacts (see docs/artifacts.md) CCF_ARTIFACT_MAX_BYTES=16777216 CCF_ARTIFACT_MAX_CONCURRENT=8 + +# Evidence subjects +# Agent evidence whose labels match no component subject template: off (accept), warn (accept +# and log) or enforce (reject with 400). +CCF_EVIDENCE_REQUIRE_SUBJECT=off +# Require evidence submitted by a user to name a subject (subjects[].subject-uuid). +CCF_MANUAL_EVIDENCE_REQUIRE_SUBJECT=false diff --git a/cmd/root.go b/cmd/root.go index 8e4ac725..701fea9d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,6 +28,8 @@ func setDefaultEnvironmentVariables() { viper.SetDefault("metrics_enabled", "true") viper.SetDefault("metrics_port", ":9090") viper.SetDefault("evidence_default_expiry_months", "1") + viper.SetDefault("evidence_require_subject", "off") + viper.SetDefault("manual_evidence_require_subject", "false") viper.SetDefault("digest_enabled", "true") viper.SetDefault("digest_schedule", "@weekly") viper.SetDefault("pprof_enabled", "false") @@ -67,6 +69,8 @@ func bindEnvironmentVariables() { viper.MustBindEnv("metrics_port") viper.MustBindEnv("use_dev_logger") viper.MustBindEnv("evidence_default_expiry_months") + viper.MustBindEnv("evidence_require_subject") + viper.MustBindEnv("manual_evidence_require_subject") viper.MustBindEnv("digest_enabled") viper.MustBindEnv("digest_schedule") viper.MustBindEnv("worker_enabled") diff --git a/docs/docs.go b/docs/docs.go index 92dab49a..01d4140f 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -3639,6 +3639,26 @@ const docTemplate = `{ } } }, + "/evidence/config": { + "get": { + "description": "Reports whether evidence submitted by a user must name a subject (CCF_MANUAL_EVIDENCE_REQUIRE_SUBJECT).", + "produces": [ + "application/json" + ], + "tags": [ + "Evidence" + ], + "summary": "Get evidence submission config", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_evidenceConfigResponse" + } + } + } + } + }, "/evidence/for-control/{id}": { "get": { "description": "Retrieves Evidence records associated with a specific Control ID, including related activities, inventory items, components, subjects, and labels.", @@ -3850,6 +3870,12 @@ const docTemplate = `{ "description": "Case-insensitive evidence name search", "name": "name", "in": "query" + }, + { + "type": "string", + "description": "Only evidence with this subject", + "name": "subjectUuid", + "in": "query" } ], "responses": { @@ -30917,6 +30943,87 @@ const docTemplate = `{ ] } }, + "/subjects": { + "get": { + "description": "Lists the entities evidence can name as its subject: defined components, SSP system components, parties and users. Used to pick and filter evidence subjects.", + "produces": [ + "application/json" + ], + "tags": [ + "Subjects" + ], + "summary": "List subjects", + "parameters": [ + { + "type": "string", + "description": "Case-insensitive title search", + "name": "search", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated kinds: defined-component, system-component, party, user", + "name": "kind", + "in": "query" + }, + { + "type": "string", + "description": "Only system components of this SSP", + "name": "ssp", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated subject UUIDs to look up (at most 100)", + "name": "ids", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/service.ListResponse-subjects_Summary" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/users/me": { "get": { "description": "Retrieves the details of the currently logged-in user", @@ -34550,6 +34657,13 @@ const docTemplate = `{ "status": { "$ref": "#/definitions/oscalTypes_1_1_3.ObjectiveStatus" }, + "subject-references": { + "description": "SubjectReferences are the evidence's subjects in display order. Present (possibly\nempty) on responses that load them, so clients can tell \"no subjects\" from \"not\nsupported\"; omitted otherwise.", + "type": "array", + "items": { + "$ref": "#/definitions/oscalTypes_1_1_3.SubjectReference" + } + }, "subjects": { "type": "array", "items": { @@ -35076,6 +35190,7 @@ const docTemplate = `{ "type": "string" }, "identifier": { + "description": "Identifier is the legacy way to name a subject, used when SubjectUUID isn't set.", "type": "string" }, "links": { @@ -35093,6 +35208,10 @@ const docTemplate = `{ "remarks": { "type": "string" }, + "subject-uuid": { + "description": "SubjectUUID names an existing subject: a defined component, SSP system component, party\nor user (see GET /subjects). Its type and title come from that subject.", + "type": "string" + }, "type": { "description": "InventoryItem\nComponent", "type": "string" @@ -36412,6 +36531,19 @@ const docTemplate = `{ } } }, + "handler.GenericDataResponse-handler_evidenceConfigResponse": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/handler.evidenceConfigResponse" + } + ] + } + } + }, "handler.GenericDataResponse-handler_groupResponse": { "type": "object", "properties": { @@ -38209,6 +38341,13 @@ const docTemplate = `{ "status": { "$ref": "#/definitions/oscalTypes_1_1_3.ObjectiveStatus" }, + "subject-references": { + "description": "SubjectReferences are the evidence's subjects in display order. Present (possibly\nempty) on responses that load them, so clients can tell \"no subjects\" from \"not\nsupported\"; omitted otherwise.", + "type": "array", + "items": { + "$ref": "#/definitions/oscalTypes_1_1_3.SubjectReference" + } + }, "subjects": { "type": "array", "items": { @@ -38753,6 +38892,15 @@ const docTemplate = `{ } } }, + "handler.evidenceConfigResponse": { + "type": "object", + "properties": { + "manual-subject-required": { + "description": "ManualSubjectRequired is true when evidence submitted by a user must name a subject.", + "type": "boolean" + } + } + }, "handler.evidenceLinkResponse": { "type": "object", "properties": { @@ -47502,6 +47650,13 @@ const docTemplate = `{ } ] }, + "subject-references": { + "description": "SubjectReferences are the evidence's subjects as OSCAL subject references: derived from\nsubject templates, declared, or legacy plugin identifiers.", + "type": "array", + "items": { + "$ref": "#/definitions/relational.EvidenceSubjectReference" + } + }, "subjects": { "description": "Who or What are we providing evidence for. What's under test.", "type": "array", @@ -47602,6 +47757,58 @@ const docTemplate = `{ } } }, + "relational.EvidenceSubjectReference": { + "type": "object", + "properties": { + "evidenceId": { + "description": "EvidenceID is indexed on its own so an evidence's subjects load without the\n(subject_uuid, evidence_id) index, which serves lookups by subject.", + "type": "string" + }, + "group": { + "type": "string" + }, + "id": { + "type": "string" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/definitions/relational.Link" + } + }, + "priority": { + "description": "Priority is the template's display priority: higher subjects are shown first.", + "type": "integer" + }, + "props": { + "type": "array", + "items": { + "$ref": "#/definitions/relational.Prop" + } + }, + "remarks": { + "type": "string" + }, + "source": { + "description": "Source is one of the EvidenceSubjectSource* values.", + "type": "string" + }, + "subjectUuid": { + "type": "string" + }, + "templateId": { + "description": "TemplateID is the subject template a template-derived subject came from.", + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "description": "Type is the OSCAL subject type, e.g. component.", + "type": "string" + } + } + }, "relational.Export": { "type": "object", "properties": { @@ -50236,6 +50443,29 @@ const docTemplate = `{ } } }, + "service.ListResponse-subjects_Summary": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/subjects.Summary" + } + }, + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, "service.ListResponse-templates_riskTemplateResponse": { "type": "object", "properties": { @@ -50305,6 +50535,85 @@ const docTemplate = `{ } } }, + "subjects.IdentityLabel": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "subjects.Kind": { + "type": "string", + "enum": [ + "defined-component", + "system-component", + "party", + "user" + ], + "x-enum-varnames": [ + "KindDefinedComponent", + "KindSystemComponent", + "KindParty", + "KindUser" + ] + }, + "subjects.LinkedSSP": { + "type": "object", + "properties": { + "component-id": { + "type": "string" + }, + "component-title": { + "type": "string" + }, + "ssp-id": { + "type": "string" + }, + "ssp-title": { + "type": "string" + } + } + }, + "subjects.Summary": { + "type": "object", + "properties": { + "context": { + "description": "Context is what the subject belongs to: a defined component's component definition, or\na system component's SSP.", + "type": "string" + }, + "identity": { + "description": "Identity is a defined component's identity labels: the evidence labels that identify it.", + "type": "array", + "items": { + "$ref": "#/definitions/subjects.IdentityLabel" + } + }, + "kind": { + "$ref": "#/definitions/subjects.Kind" + }, + "linked-ssps": { + "description": "LinkedSSPs are the SSP system components a defined component is linked to.", + "type": "array", + "items": { + "$ref": "#/definitions/subjects.LinkedSSP" + } + }, + "subject-uuid": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "description": "Type is the OSCAL subject type: component, party or user.", + "type": "string" + } + } + }, "suggestions.DashboardSuggestion": { "type": "object", "properties": { @@ -50549,9 +50858,15 @@ const docTemplate = `{ "templates.batchSubjectTemplateItem": { "type": "object", "properties": { + "component-type": { + "type": "string" + }, "description-template": { "type": "string" }, + "display-priority": { + "type": "integer" + }, "id": { "type": "string" }, @@ -50689,6 +51004,13 @@ const docTemplate = `{ "items": { "$ref": "#/definitions/templates.subjectTemplateResponse" } + }, + "warnings": { + "description": "Warnings lists non-component templates, which are accepted but produce no evidence\nsubjects.", + "type": "array", + "items": { + "type": "string" + } } } }, @@ -50907,12 +51229,18 @@ const docTemplate = `{ "templates.subjectTemplateResponse": { "type": "object", "properties": { + "component-type": { + "type": "string" + }, "createdAt": { "type": "string" }, "description-template": { "type": "string" }, + "display-priority": { + "type": "integer" + }, "id": { "type": "string" }, @@ -51092,9 +51420,15 @@ const docTemplate = `{ "type" ], "properties": { + "component-type": { + "type": "string" + }, "description-template": { "type": "string" }, + "display-priority": { + "type": "integer" + }, "identity-label-keys": { "type": "array", "items": { diff --git a/docs/swagger.json b/docs/swagger.json index 9984d5bb..8aeaa21c 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -3633,6 +3633,26 @@ } } }, + "/evidence/config": { + "get": { + "description": "Reports whether evidence submitted by a user must name a subject (CCF_MANUAL_EVIDENCE_REQUIRE_SUBJECT).", + "produces": [ + "application/json" + ], + "tags": [ + "Evidence" + ], + "summary": "Get evidence submission config", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_evidenceConfigResponse" + } + } + } + } + }, "/evidence/for-control/{id}": { "get": { "description": "Retrieves Evidence records associated with a specific Control ID, including related activities, inventory items, components, subjects, and labels.", @@ -3844,6 +3864,12 @@ "description": "Case-insensitive evidence name search", "name": "name", "in": "query" + }, + { + "type": "string", + "description": "Only evidence with this subject", + "name": "subjectUuid", + "in": "query" } ], "responses": { @@ -30911,6 +30937,87 @@ ] } }, + "/subjects": { + "get": { + "description": "Lists the entities evidence can name as its subject: defined components, SSP system components, parties and users. Used to pick and filter evidence subjects.", + "produces": [ + "application/json" + ], + "tags": [ + "Subjects" + ], + "summary": "List subjects", + "parameters": [ + { + "type": "string", + "description": "Case-insensitive title search", + "name": "search", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated kinds: defined-component, system-component, party, user", + "name": "kind", + "in": "query" + }, + { + "type": "string", + "description": "Only system components of this SSP", + "name": "ssp", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated subject UUIDs to look up (at most 100)", + "name": "ids", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/service.ListResponse-subjects_Summary" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/users/me": { "get": { "description": "Retrieves the details of the currently logged-in user", @@ -34544,6 +34651,13 @@ "status": { "$ref": "#/definitions/oscalTypes_1_1_3.ObjectiveStatus" }, + "subject-references": { + "description": "SubjectReferences are the evidence's subjects in display order. Present (possibly\nempty) on responses that load them, so clients can tell \"no subjects\" from \"not\nsupported\"; omitted otherwise.", + "type": "array", + "items": { + "$ref": "#/definitions/oscalTypes_1_1_3.SubjectReference" + } + }, "subjects": { "type": "array", "items": { @@ -35070,6 +35184,7 @@ "type": "string" }, "identifier": { + "description": "Identifier is the legacy way to name a subject, used when SubjectUUID isn't set.", "type": "string" }, "links": { @@ -35087,6 +35202,10 @@ "remarks": { "type": "string" }, + "subject-uuid": { + "description": "SubjectUUID names an existing subject: a defined component, SSP system component, party\nor user (see GET /subjects). Its type and title come from that subject.", + "type": "string" + }, "type": { "description": "InventoryItem\nComponent", "type": "string" @@ -36406,6 +36525,19 @@ } } }, + "handler.GenericDataResponse-handler_evidenceConfigResponse": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/handler.evidenceConfigResponse" + } + ] + } + } + }, "handler.GenericDataResponse-handler_groupResponse": { "type": "object", "properties": { @@ -38203,6 +38335,13 @@ "status": { "$ref": "#/definitions/oscalTypes_1_1_3.ObjectiveStatus" }, + "subject-references": { + "description": "SubjectReferences are the evidence's subjects in display order. Present (possibly\nempty) on responses that load them, so clients can tell \"no subjects\" from \"not\nsupported\"; omitted otherwise.", + "type": "array", + "items": { + "$ref": "#/definitions/oscalTypes_1_1_3.SubjectReference" + } + }, "subjects": { "type": "array", "items": { @@ -38747,6 +38886,15 @@ } } }, + "handler.evidenceConfigResponse": { + "type": "object", + "properties": { + "manual-subject-required": { + "description": "ManualSubjectRequired is true when evidence submitted by a user must name a subject.", + "type": "boolean" + } + } + }, "handler.evidenceLinkResponse": { "type": "object", "properties": { @@ -47496,6 +47644,13 @@ } ] }, + "subject-references": { + "description": "SubjectReferences are the evidence's subjects as OSCAL subject references: derived from\nsubject templates, declared, or legacy plugin identifiers.", + "type": "array", + "items": { + "$ref": "#/definitions/relational.EvidenceSubjectReference" + } + }, "subjects": { "description": "Who or What are we providing evidence for. What's under test.", "type": "array", @@ -47596,6 +47751,58 @@ } } }, + "relational.EvidenceSubjectReference": { + "type": "object", + "properties": { + "evidenceId": { + "description": "EvidenceID is indexed on its own so an evidence's subjects load without the\n(subject_uuid, evidence_id) index, which serves lookups by subject.", + "type": "string" + }, + "group": { + "type": "string" + }, + "id": { + "type": "string" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/definitions/relational.Link" + } + }, + "priority": { + "description": "Priority is the template's display priority: higher subjects are shown first.", + "type": "integer" + }, + "props": { + "type": "array", + "items": { + "$ref": "#/definitions/relational.Prop" + } + }, + "remarks": { + "type": "string" + }, + "source": { + "description": "Source is one of the EvidenceSubjectSource* values.", + "type": "string" + }, + "subjectUuid": { + "type": "string" + }, + "templateId": { + "description": "TemplateID is the subject template a template-derived subject came from.", + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "description": "Type is the OSCAL subject type, e.g. component.", + "type": "string" + } + } + }, "relational.Export": { "type": "object", "properties": { @@ -50230,6 +50437,29 @@ } } }, + "service.ListResponse-subjects_Summary": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/subjects.Summary" + } + }, + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, "service.ListResponse-templates_riskTemplateResponse": { "type": "object", "properties": { @@ -50299,6 +50529,85 @@ } } }, + "subjects.IdentityLabel": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "subjects.Kind": { + "type": "string", + "enum": [ + "defined-component", + "system-component", + "party", + "user" + ], + "x-enum-varnames": [ + "KindDefinedComponent", + "KindSystemComponent", + "KindParty", + "KindUser" + ] + }, + "subjects.LinkedSSP": { + "type": "object", + "properties": { + "component-id": { + "type": "string" + }, + "component-title": { + "type": "string" + }, + "ssp-id": { + "type": "string" + }, + "ssp-title": { + "type": "string" + } + } + }, + "subjects.Summary": { + "type": "object", + "properties": { + "context": { + "description": "Context is what the subject belongs to: a defined component's component definition, or\na system component's SSP.", + "type": "string" + }, + "identity": { + "description": "Identity is a defined component's identity labels: the evidence labels that identify it.", + "type": "array", + "items": { + "$ref": "#/definitions/subjects.IdentityLabel" + } + }, + "kind": { + "$ref": "#/definitions/subjects.Kind" + }, + "linked-ssps": { + "description": "LinkedSSPs are the SSP system components a defined component is linked to.", + "type": "array", + "items": { + "$ref": "#/definitions/subjects.LinkedSSP" + } + }, + "subject-uuid": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "description": "Type is the OSCAL subject type: component, party or user.", + "type": "string" + } + } + }, "suggestions.DashboardSuggestion": { "type": "object", "properties": { @@ -50543,9 +50852,15 @@ "templates.batchSubjectTemplateItem": { "type": "object", "properties": { + "component-type": { + "type": "string" + }, "description-template": { "type": "string" }, + "display-priority": { + "type": "integer" + }, "id": { "type": "string" }, @@ -50683,6 +50998,13 @@ "items": { "$ref": "#/definitions/templates.subjectTemplateResponse" } + }, + "warnings": { + "description": "Warnings lists non-component templates, which are accepted but produce no evidence\nsubjects.", + "type": "array", + "items": { + "type": "string" + } } } }, @@ -50901,12 +51223,18 @@ "templates.subjectTemplateResponse": { "type": "object", "properties": { + "component-type": { + "type": "string" + }, "createdAt": { "type": "string" }, "description-template": { "type": "string" }, + "display-priority": { + "type": "integer" + }, "id": { "type": "string" }, @@ -51086,9 +51414,15 @@ "type" ], "properties": { + "component-type": { + "type": "string" + }, "description-template": { "type": "string" }, + "display-priority": { + "type": "integer" + }, "identity-label-keys": { "type": "array", "items": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6f7fd94f..e60a83ad 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -220,6 +220,14 @@ definitions: type: string status: $ref: '#/definitions/oscalTypes_1_1_3.ObjectiveStatus' + subject-references: + description: |- + SubjectReferences are the evidence's subjects in display order. Present (possibly + empty) on responses that load them, so clients can tell "no subjects" from "not + supported"; omitted otherwise. + items: + $ref: '#/definitions/oscalTypes_1_1_3.SubjectReference' + type: array subjects: items: $ref: '#/definitions/oscalTypes_1_1_3.AssessmentSubject' @@ -613,6 +621,8 @@ definitions: description: type: string identifier: + description: Identifier is the legacy way to name a subject, used when SubjectUUID + isn't set. type: string links: items: @@ -624,6 +634,11 @@ definitions: type: array remarks: type: string + subject-uuid: + description: |- + SubjectUUID names an existing subject: a defined component, SSP system component, party + or user (see GET /subjects). Its type and title come from that subject. + type: string type: description: |- InventoryItem @@ -1512,6 +1527,13 @@ definitions: - $ref: '#/definitions/handler.configuredSystemDestinationResponse' description: Wrapped response data type: object + handler.GenericDataResponse-handler_evidenceConfigResponse: + properties: + data: + allOf: + - $ref: '#/definitions/handler.evidenceConfigResponse' + description: Wrapped response data + type: object handler.GenericDataResponse-handler_groupResponse: properties: data: @@ -2555,6 +2577,14 @@ definitions: type: string status: $ref: '#/definitions/oscalTypes_1_1_3.ObjectiveStatus' + subject-references: + description: |- + SubjectReferences are the evidence's subjects in display order. Present (possibly + empty) on responses that load them, so clients can tell "no subjects" from "not + supported"; omitted otherwise. + items: + $ref: '#/definitions/oscalTypes_1_1_3.SubjectReference' + type: array subjects: items: $ref: '#/definitions/oscalTypes_1_1_3.AssessmentSubject' @@ -2928,6 +2958,13 @@ definitions: description: the granting group's name, when inherited type: string type: object + handler.evidenceConfigResponse: + properties: + manual-subject-required: + description: ManualSubjectRequired is true when evidence submitted by a user + must name a subject. + type: boolean + type: object handler.evidenceLinkResponse: properties: created-at: @@ -8791,6 +8828,13 @@ definitions: allOf: - $ref: '#/definitions/datatypes.JSONType-oscalTypes_1_1_3_ObjectiveStatus' description: Did we satisfy what was being tested for, or did we fail ? + subject-references: + description: |- + SubjectReferences are the evidence's subjects as OSCAL subject references: derived from + subject templates, declared, or legacy plugin identifiers. + items: + $ref: '#/definitions/relational.EvidenceSubjectReference' + type: array subjects: description: Who or What are we providing evidence for. What's under test. items: @@ -8859,6 +8903,46 @@ definitions: type: type: string type: object + relational.EvidenceSubjectReference: + properties: + evidenceId: + description: |- + EvidenceID is indexed on its own so an evidence's subjects load without the + (subject_uuid, evidence_id) index, which serves lookups by subject. + type: string + group: + type: string + id: + type: string + links: + items: + $ref: '#/definitions/relational.Link' + type: array + priority: + description: 'Priority is the template''s display priority: higher subjects + are shown first.' + type: integer + props: + items: + $ref: '#/definitions/relational.Prop' + type: array + remarks: + type: string + source: + description: Source is one of the EvidenceSubjectSource* values. + type: string + subjectUuid: + type: string + templateId: + description: TemplateID is the subject template a template-derived subject + came from. + type: string + title: + type: string + type: + description: Type is the OSCAL subject type, e.g. component. + type: string + type: object relational.Export: properties: byComponentId: @@ -10655,6 +10739,21 @@ definitions: totalPages: type: integer type: object + service.ListResponse-subjects_Summary: + properties: + data: + items: + $ref: '#/definitions/subjects.Summary' + type: array + limit: + type: integer + page: + type: integer + total: + type: integer + totalPages: + type: integer + type: object service.ListResponse-templates_riskTemplateResponse: properties: data: @@ -10700,6 +10799,65 @@ definitions: totalPages: type: integer type: object + subjects.IdentityLabel: + properties: + key: + type: string + value: + type: string + type: object + subjects.Kind: + enum: + - defined-component + - system-component + - party + - user + type: string + x-enum-varnames: + - KindDefinedComponent + - KindSystemComponent + - KindParty + - KindUser + subjects.LinkedSSP: + properties: + component-id: + type: string + component-title: + type: string + ssp-id: + type: string + ssp-title: + type: string + type: object + subjects.Summary: + properties: + context: + description: |- + Context is what the subject belongs to: a defined component's component definition, or + a system component's SSP. + type: string + identity: + description: 'Identity is a defined component''s identity labels: the evidence + labels that identify it.' + items: + $ref: '#/definitions/subjects.IdentityLabel' + type: array + kind: + $ref: '#/definitions/subjects.Kind' + linked-ssps: + description: LinkedSSPs are the SSP system components a defined component + is linked to. + items: + $ref: '#/definitions/subjects.LinkedSSP' + type: array + subject-uuid: + type: string + title: + type: string + type: + description: 'Type is the OSCAL subject type: component, party or user.' + type: string + type: object suggestions.DashboardSuggestion: properties: acceptedFilterId: @@ -10873,8 +11031,12 @@ definitions: type: object templates.batchSubjectTemplateItem: properties: + component-type: + type: string description-template: type: string + display-priority: + type: integer id: type: string identity-label-keys: @@ -10966,6 +11128,13 @@ definitions: items: $ref: '#/definitions/templates.subjectTemplateResponse' type: array + warnings: + description: |- + Warnings lists non-component templates, which are accepted but produce no evidence + subjects. + items: + type: string + type: array type: object templates.batchUpsertSubjectTemplatesRequest: properties: @@ -11106,10 +11275,14 @@ definitions: type: object templates.subjectTemplateResponse: properties: + component-type: + type: string createdAt: type: string description-template: type: string + display-priority: + type: integer id: type: string identity-label-keys: @@ -11222,8 +11395,12 @@ definitions: type: object templates.upsertSubjectTemplateRequest: properties: + component-type: + type: string description-template: type: string + display-priority: + type: integer identity-label-keys: items: type: string @@ -14820,6 +14997,20 @@ paths: summary: Get compliance status counts by filter/dashboard ID tags: - Evidence + /evidence/config: + get: + description: Reports whether evidence submitted by a user must name a subject + (CCF_MANUAL_EVIDENCE_REQUIRE_SUBJECT). + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_evidenceConfigResponse' + summary: Get evidence submission config + tags: + - Evidence /evidence/for-control/{id}: get: description: Retrieves Evidence records associated with a specific Control ID, @@ -14961,6 +15152,10 @@ paths: in: query name: name type: string + - description: Only evidence with this subject + in: query + name: subjectUuid + type: string produces: - application/json responses: @@ -32743,6 +32938,61 @@ paths: summary: Get risk score timeseries tags: - Risks + /subjects: + get: + description: 'Lists the entities evidence can name as its subject: defined components, + SSP system components, parties and users. Used to pick and filter evidence + subjects.' + parameters: + - description: Case-insensitive title search + in: query + name: search + type: string + - description: 'Comma-separated kinds: defined-component, system-component, + party, user' + in: query + name: kind + type: string + - description: Only system components of this SSP + in: query + name: ssp + type: string + - description: Comma-separated subject UUIDs to look up (at most 100) + in: query + name: ids + type: string + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/service.ListResponse-subjects_Summary' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List subjects + tags: + - Subjects /users/{id}: get: description: Get minimal user details by user ID diff --git a/internal/api/handler/api.go b/internal/api/handler/api.go index 8ade6893..fdbb9463 100644 --- a/internal/api/handler/api.go +++ b/internal/api/handler/api.go @@ -160,6 +160,13 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB evidenceSignatureGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) evidenceHandler.RegisterSignatureRoutes(evidenceSignatureGroup, evidenceGuard.Read()) + // Subjects lists user and party names, so unlike the evidence reads it needs a logged-in + // user as well as evidence read. + subjectHandler := NewSubjectHandler(logger, db) + subjectGroup := server.API().Group("/subjects") + subjectGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) + subjectHandler.Register(subjectGroup, evidenceGuard.Read()) + // Evidence→risk lookups return risk register data, so they need auth and the risk // read guard rather than joining the intentionally anonymous evidence read routes. evidenceRiskGroup := server.API().Group("/evidence") diff --git a/internal/api/handler/evidence.go b/internal/api/handler/evidence.go index b84a79e6..89857e4b 100644 --- a/internal/api/handler/evidence.go +++ b/internal/api/handler/evidence.go @@ -53,6 +53,7 @@ func NewEvidenceHandler(sugar *zap.SugaredLogger, evidenceService *evidencesvc.E func (h *EvidenceHandler) Register(api *echo.Group) { api.POST("", h.Create) + api.GET("/config", h.Config) api.GET("/:id", h.Get) api.GET("/history/:id", h.History) api.GET("/latest/:id", h.Latest) @@ -73,6 +74,7 @@ func (h *EvidenceHandler) RegisterCreate(api *echo.Group, middlewares ...echo.Mi // optional-auth middleware) as middlewares applied uniformly. POST /search and // POST /status-over-time are queries, not mutations — hence read. func (h *EvidenceHandler) RegisterReadRoutes(api *echo.Group, middlewares ...echo.MiddlewareFunc) { + api.GET("/config", h.Config, middlewares...) api.GET("/:id", h.Get, middlewares...) api.GET("/history/:id", h.History, middlewares...) api.GET("/latest/:id", h.Latest, middlewares...) @@ -167,6 +169,11 @@ type EvidenceComponent struct { } type EvidenceSubject struct { + // SubjectUUID names an existing subject: a defined component, SSP system component, party + // or user (see GET /subjects). Its type and title come from that subject. + SubjectUUID *uuid.UUID `json:"subject-uuid,omitempty"` + + // Identifier is the legacy way to name a subject, used when SubjectUUID isn't set. Identifier string // InventoryItem @@ -373,7 +380,12 @@ func (h *EvidenceHandler) Create(ctx echo.Context) error { } subjects := []relational.AssessmentSubject{} + declaredSubjectUUIDs := []uuid.UUID{} for _, i := range input.Subjects { + if i.SubjectUUID != nil { + declaredSubjectUUIDs = append(declaredSubjectUUIDs, *i.SubjectUUID) + continue + } id, err := internal.SeededUUID(map[string]string{ "identifier": i.Identifier, }) @@ -432,14 +444,18 @@ func (h *EvidenceHandler) Create(ctx echo.Context) error { } created, err := h.evidenceService.Create(ctx.Request().Context(), evidencesvc.CreateEvidenceParams{ - Evidence: evidence, - Components: components, - InventoryItems: inventoryItems, - Activities: activities, - Subjects: subjects, - Labels: labels, - Signer: authcontext.SignerContextFromEcho(ctx), + Evidence: evidence, + Components: components, + InventoryItems: inventoryItems, + Activities: activities, + Subjects: subjects, + DeclaredSubjectUUIDs: declaredSubjectUUIDs, + Labels: labels, + Signer: authcontext.SignerContextFromEcho(ctx), }) + if errors.Is(err, evidencesvc.ErrSubjectRequired) || errors.Is(err, evidencesvc.ErrUnknownSubject) || errors.Is(err, evidencesvc.ErrTooManySubjects) { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } if err != nil { return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } @@ -452,6 +468,25 @@ func (h *EvidenceHandler) Create(ctx echo.Context) error { return ctx.JSON(http.StatusCreated, GenericDataResponse[*CreatedEvidenceResponse]{Data: output}) } +type evidenceConfigResponse struct { + // ManualSubjectRequired is true when evidence submitted by a user must name a subject. + ManualSubjectRequired bool `json:"manual-subject-required"` +} + +// Config godoc +// +// @Summary Get evidence submission config +// @Description Reports whether evidence submitted by a user must name a subject (CCF_MANUAL_EVIDENCE_REQUIRE_SUBJECT). +// @Tags Evidence +// @Produce json +// @Success 200 {object} GenericDataResponse[evidenceConfigResponse] +// @Router /evidence/config [get] +func (h *EvidenceHandler) Config(ctx echo.Context) error { + return ctx.JSON(http.StatusOK, GenericDataResponse[evidenceConfigResponse]{ + Data: evidenceConfigResponse{ManualSubjectRequired: h.evidenceService.ManualEvidenceRequiresSubject()}, + }) +} + // Search godoc // // @Summary Search Evidence @@ -465,6 +500,7 @@ func (h *EvidenceHandler) Create(ctx echo.Context) error { // @Param sortBy query string false "Sort field: lastSeenAt, name, status" // @Param sortDirection query string false "Sort direction: asc, desc" // @Param name query string false "Case-insensitive evidence name search" +// @Param subjectUuid query string false "Only evidence with this subject" // @Success 200 {object} svc.ListResponse[PublicEvidenceResponse] // @Failure 400 {object} api.Error // @Failure 422 {object} api.Error @@ -499,6 +535,7 @@ func (h *EvidenceHandler) Search(ctx echo.Context) error { if err != nil { return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } + out.SubjectReferences = compactSubjectReferences(evidence.SubjectReferences) output = append(output, out) } @@ -539,10 +576,20 @@ func parseEvidenceSearchOptions(ctx echo.Context, pagination *svc.PaginationPara ) } + var subjectUUID *uuid.UUID + if raw := strings.TrimSpace(ctx.QueryParam("subjectUuid")); raw != "" { + parsed, err := uuid.Parse(raw) + if err != nil { + return evidencesvc.SearchOptions{}, fmt.Errorf("invalid subjectUuid parameter: %w", err) + } + subjectUUID = &parsed + } + return evidencesvc.SearchOptions{ Limit: pagination.Limit, Offset: pagination.Offset, Name: ctx.QueryParam("name"), + SubjectUUID: subjectUUID, SortBy: sortBy, SortDirection: sortDirection, }, nil @@ -566,7 +613,11 @@ type EvidenceFields struct { InventoryItems []oscalTypes_1_1_3.InventoryItem `json:"inventory-items,omitempty"` Components []oscalTypes_1_1_3.SystemComponent `json:"components,omitempty"` Subjects []oscalTypes_1_1_3.AssessmentSubject `json:"subjects,omitempty"` - Status oscalTypes_1_1_3.ObjectiveStatus `json:"status"` + // SubjectReferences are the evidence's subjects in display order. Present (possibly + // empty) on responses that load them, so clients can tell "no subjects" from "not + // supported"; omitted otherwise. + SubjectReferences *[]oscalTypes_1_1_3.SubjectReference `json:"subject-references,omitempty"` + Status oscalTypes_1_1_3.ObjectiveStatus `json:"status"` } type PublicEvidenceResponse struct { @@ -631,11 +682,49 @@ func newPublicEvidenceResponse(evidence *relational.Evidence) (*PublicEvidenceRe return &PublicEvidenceResponse{EvidenceFields: *fields}, nil } +// newEvidenceDetailResponse is a single evidence record with its full subject references. +func newEvidenceDetailResponse(evidence *relational.Evidence) (*PublicEvidenceResponse, error) { + out, err := newPublicEvidenceResponse(evidence) + if err != nil { + return nil, err + } + out.SubjectReferences = fullSubjectReferences(evidence.SubjectReferences) + return out, nil +} + +// fullSubjectReferences are all of the evidence's subject references, in display order. +func fullSubjectReferences(refs []relational.EvidenceSubjectReference) *[]oscalTypes_1_1_3.SubjectReference { + out := make([]oscalTypes_1_1_3.SubjectReference, 0, len(refs)) + for _, ref := range relational.SortEvidenceSubjectReferencesForDisplay(refs) { + out = append(out, ref.MarshalOscal()) + } + return &out +} + +// compactSubjectReferences are the subject references a search row carries: uuid, type and +// title, in display order. Legacy subjects are left out because they don't attribute the +// evidence, so evidence with only legacy subjects lists as unattributed. +func compactSubjectReferences(refs []relational.EvidenceSubjectReference) *[]oscalTypes_1_1_3.SubjectReference { + out := make([]oscalTypes_1_1_3.SubjectReference, 0, len(refs)) + for _, ref := range relational.SortEvidenceSubjectReferencesForDisplay(refs) { + if ref.Source == relational.EvidenceSubjectSourceLegacy { + continue + } + out = append(out, oscalTypes_1_1_3.SubjectReference{ + SubjectUuid: ref.SubjectUUID.String(), + Type: ref.Type, + Title: ref.Title, + }) + } + return &out +} + func newCreatedEvidenceResponse(evidence *relational.Evidence) (*CreatedEvidenceResponse, error) { fields, err := buildEvidenceFields(evidence) if err != nil { return nil, err } + fields.SubjectReferences = fullSubjectReferences(evidence.SubjectReferences) response := &CreatedEvidenceResponse{EvidenceFields: *fields} if evidence.Signature != nil { @@ -678,7 +767,7 @@ func (h *EvidenceHandler) Get(ctx echo.Context) error { return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } - output, err := newPublicEvidenceResponse(evidence) + output, err := newEvidenceDetailResponse(evidence) if err != nil { return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } @@ -724,7 +813,7 @@ func (h *EvidenceHandler) History(ctx echo.Context) error { output := []*PublicEvidenceResponse{} for _, e := range evidences { - out, convErr := newPublicEvidenceResponse(&e) + out, convErr := newEvidenceDetailResponse(&e) if convErr != nil { return ctx.JSON(http.StatusInternalServerError, api.NewError(convErr)) } @@ -763,7 +852,7 @@ func (h *EvidenceHandler) Latest(ctx echo.Context) error { return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } - output, err := newPublicEvidenceResponse(evidence) + output, err := newEvidenceDetailResponse(evidence) if err != nil { return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } diff --git a/internal/api/handler/evidence_integration_test.go b/internal/api/handler/evidence_integration_test.go index f1b562b9..58a4b959 100644 --- a/internal/api/handler/evidence_integration_test.go +++ b/internal/api/handler/evidence_integration_test.go @@ -15,10 +15,12 @@ import ( "github.com/compliance-framework/api/internal" "github.com/compliance-framework/api/internal/api" "github.com/compliance-framework/api/internal/authn" + "github.com/compliance-framework/api/internal/config" "github.com/compliance-framework/api/internal/converters/labelfilter" svc "github.com/compliance-framework/api/internal/service" "github.com/compliance-framework/api/internal/service/relational" evidencesvc "github.com/compliance-framework/api/internal/service/relational/evidence" + templaterel "github.com/compliance-framework/api/internal/service/relational/templates" sdktypes "github.com/compliance-framework/api/sdk/types" oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" "github.com/google/uuid" @@ -2017,3 +2019,318 @@ func (suite *EvidenceApiIntegrationSuite) TestComplianceByFilter() { assert.Equal(suite.T(), http.StatusBadRequest, rec.Code) }) } + +func (suite *EvidenceApiIntegrationSuite) TestConfigReportsManualSubjectRequirement() { + suite.Require().NoError(suite.Migrator.Refresh()) + defer func() { suite.Config.EvidenceSubjects = nil }() + + getConfig := func() bool { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/evidence/config", nil) + suite.setupServer().E().ServeHTTP(rec, req) + suite.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + + var body GenericDataResponse[evidenceConfigResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &body)) + return body.Data.ManualSubjectRequired + } + + suite.False(getConfig(), "not required when the flag is unset") + + suite.Config.EvidenceSubjects = &config.EvidenceSubjectConfig{ManualRequireSubject: true} + suite.True(getConfig()) + + suite.Config.EvidenceSubjects = &config.EvidenceSubjectConfig{ManualRequireSubject: false} + suite.False(getConfig()) +} + +// setupServerWithSubjectTemplates wires the subject template resolver into evidence creation, +// as cmd/run.go does. +func (suite *EvidenceApiIntegrationSuite) setupServerWithSubjectTemplates() *api.Server { + logger, _ := zap.NewDevelopment() + 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, + evidencesvc.WithComponentDefinitionResolver(templaterel.NewSubjectTemplateService(suite.DB))) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, &APIServices{EvidenceService: evidenceSvc}) + return server +} + +func (suite *EvidenceApiIntegrationSuite) createOrganizationSubjectTemplate(plugin string) { + title := "GitHub Organization: {{ .organization }}" + _, err := templaterel.NewSubjectTemplateService(suite.DB).Create(templaterel.SubjectTemplatePayload{ + Name: "github-organization", + Type: "component", + SourceMode: "runtime-derived", + TitleTemplate: &title, + DisplayPriority: 2, + IdentityLabelKeys: []string{"organization"}, + SelectorLabels: []templaterel.SubjectTemplateSelectorLabelInput{{Key: "_plugin", Value: plugin}}, + LabelSchema: []templaterel.SubjectTemplateLabelSchemaFieldInput{{Key: "organization"}}, + }) + suite.Require().NoError(err) +} + +func (suite *EvidenceApiIntegrationSuite) postEvidence(server *api.Server, request EvidenceCreateRequest) *httptest.ResponseRecorder { + body, err := json.Marshal(request) + suite.Require().NoError(err) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/evidence", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + server.E().ServeHTTP(rec, req) + return rec +} + +func subjectEvidenceRequest(labels map[string]string) EvidenceCreateRequest { + return EvidenceCreateRequest{ + UUID: uuid.New(), + Title: "All teams use closed visibility", + Start: time.Now().Add(-time.Hour), + End: time.Now().Add(-time.Minute), + Labels: labels, + Status: oscalTypes_1_1_3.ObjectiveStatus{State: relational.EvidenceStatusSatisfied}, + } +} + +func (suite *EvidenceApiIntegrationSuite) TestCreateStoresTemplateDerivedAndLegacySubjects() { + suite.Require().NoError(suite.Migrator.Refresh()) + suite.Config.StrictDisablePublicAgentEndpoints = false + suite.createOrganizationSubjectTemplate("github-settings") + server := suite.setupServerWithSubjectTemplates() + + request := subjectEvidenceRequest(map[string]string{"_plugin": "github-settings", "organization": "acme"}) + request.Subjects = []EvidenceSubject{{Identifier: "github/acme/api", Type: "Component"}} + rec := suite.postEvidence(server, request) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + + var evidence relational.Evidence + suite.Require().NoError(suite.DB.First(&evidence, "uuid = ?", request.UUID).Error) + + var refs []relational.EvidenceSubjectReference + suite.Require().NoError(suite.DB.Where("evidence_id = ?", evidence.ID).Order("source desc").Find(&refs).Error) + suite.Require().Len(refs, 2) + + template := refs[0] + suite.Equal(relational.EvidenceSubjectSourceTemplate, template.Source) + suite.Equal("component", template.Type) + suite.Equal("GitHub Organization: acme", template.Title) + suite.Equal(2, template.Priority) + suite.Nil(template.Group) + suite.NotNil(template.TemplateID) + + legacy := refs[1] + suite.Equal(relational.EvidenceSubjectSourceLegacy, legacy.Source) + seeded, err := internal.SeededUUID(map[string]string{"identifier": "github/acme/api"}) + suite.Require().NoError(err) + suite.Equal(seeded, legacy.SubjectUUID) + + // The subject references can be found by subject, through the (subject_uuid, evidence_id) index. + var bySubject int64 + suite.Require().NoError(suite.DB.Model(&relational.EvidenceSubjectReference{}). + Where("subject_uuid = ?", template.SubjectUUID).Count(&bySubject).Error) + suite.Equal(int64(1), bySubject) +} + +func (suite *EvidenceApiIntegrationSuite) TestCreateRejectsUnattributedAgentEvidenceWhenEnforced() { + suite.Require().NoError(suite.Migrator.Refresh()) + suite.Config.StrictDisablePublicAgentEndpoints = false + suite.Config.EvidenceSubjects = &config.EvidenceSubjectConfig{RequireSubject: config.EvidenceRequireSubjectEnforce} + defer func() { suite.Config.EvidenceSubjects = nil }() + suite.createOrganizationSubjectTemplate("github-settings") + server := suite.setupServerWithSubjectTemplates() + + unattributed := suite.postEvidence(server, subjectEvidenceRequest(map[string]string{"_plugin": "some-other-plugin"})) + suite.Equal(http.StatusBadRequest, unattributed.Code, unattributed.Body.String()) + suite.Contains(unattributed.Body.String(), "evidence has no subject") + + attributed := suite.postEvidence(server, subjectEvidenceRequest(map[string]string{"_plugin": "github-settings", "organization": "acme"})) + suite.Equal(http.StatusCreated, attributed.Code, attributed.Body.String()) +} + +func (suite *EvidenceApiIntegrationSuite) TestSubjectReferencesOnDetailAndSearchResponses() { + suite.Require().NoError(suite.Migrator.Refresh()) + suite.Config.StrictDisablePublicAgentEndpoints = false + suite.createOrganizationSubjectTemplate("github-settings") + server := suite.setupServerWithSubjectTemplates() + + attributed := subjectEvidenceRequest(map[string]string{"_plugin": "github-settings", "organization": "acme"}) + attributed.Subjects = []EvidenceSubject{{Identifier: "github/acme/api", Type: "Component"}} + suite.Require().Equal(http.StatusCreated, suite.postEvidence(server, attributed).Code) + legacyOnly := subjectEvidenceRequest(map[string]string{"_plugin": "legacy-plugin"}) + legacyOnly.Subjects = []EvidenceSubject{{Identifier: "legacy/thing", Type: "Component"}} + suite.Require().Equal(http.StatusCreated, suite.postEvidence(server, legacyOnly).Code) + + type subjectRef map[string]any + type evidenceRow struct { + ID uuid.UUID `json:"id"` + UUID uuid.UUID `json:"uuid"` + SubjectReferences *[]subjectRef `json:"subject-references"` + } + + // Search rows carry compact references, without legacy subjects. + searchRec := httptest.NewRecorder() + searchReq := httptest.NewRequest(http.MethodPost, "/api/evidence/search", bytes.NewReader([]byte(`{"filter":{}}`))) + searchReq.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + server.E().ServeHTTP(searchRec, searchReq) + suite.Require().Equal(http.StatusOK, searchRec.Code, searchRec.Body.String()) + var search svc.ListResponse[evidenceRow] + suite.Require().NoError(json.Unmarshal(searchRec.Body.Bytes(), &search)) + suite.Require().Len(search.Data, 2) + + var attributedRow evidenceRow + for _, row := range search.Data { + suite.Require().NotNil(row.SubjectReferences, "search rows always carry subject-references") + switch row.UUID { + case attributed.UUID: + attributedRow = row + suite.Require().Len(*row.SubjectReferences, 1) + ref := (*row.SubjectReferences)[0] + suite.Equal("component", ref["type"]) + suite.Equal("GitHub Organization: acme", ref["title"]) + suite.NotEmpty(ref["subject-uuid"]) + suite.Len(ref, 3, "compact references have only subject-uuid, type and title") + case legacyOnly.UUID: + suite.Empty(*row.SubjectReferences, "evidence with only legacy subjects lists as unattributed") + } + } + + // The detail response carries every subject reference, in display order. + detailRec := httptest.NewRecorder() + detailReq := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/evidence/%s", attributedRow.ID), nil) + server.E().ServeHTTP(detailRec, detailReq) + suite.Require().Equal(http.StatusOK, detailRec.Code, detailRec.Body.String()) + var detail GenericDataResponse[evidenceRow] + suite.Require().NoError(json.Unmarshal(detailRec.Body.Bytes(), &detail)) + suite.Require().NotNil(detail.Data.SubjectReferences) + suite.Require().Len(*detail.Data.SubjectReferences, 2) + suite.Equal("GitHub Organization: acme", (*detail.Data.SubjectReferences)[0]["title"], "the template subject (priority 2) comes first") + suite.NotEmpty((*detail.Data.SubjectReferences)[0]["props"]) + suite.Equal("Component", (*detail.Data.SubjectReferences)[1]["type"]) +} + +func (suite *EvidenceApiIntegrationSuite) TestSearchFiltersBySubject() { + suite.Require().NoError(suite.Migrator.Refresh()) + suite.Config.StrictDisablePublicAgentEndpoints = false + suite.createOrganizationSubjectTemplate("github-settings") + server := suite.setupServerWithSubjectTemplates() + + acme := subjectEvidenceRequest(map[string]string{"_plugin": "github-settings", "organization": "acme"}) + suite.Require().Equal(http.StatusCreated, suite.postEvidence(server, acme).Code) + globex := subjectEvidenceRequest(map[string]string{"_plugin": "github-settings", "organization": "globex"}) + suite.Require().Equal(http.StatusCreated, suite.postEvidence(server, globex).Code) + + var acmeRef relational.EvidenceSubjectReference + suite.Require().NoError(suite.DB.Where("title = ?", "GitHub Organization: acme").First(&acmeRef).Error) + + search := func(query string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/evidence/search"+query, bytes.NewReader([]byte(`{"filter":{}}`))) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + server.E().ServeHTTP(rec, req) + return rec + } + + rec := search("?subjectUuid=" + acmeRef.SubjectUUID.String()) + suite.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + var filtered svc.ListResponse[PublicEvidenceResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &filtered)) + suite.Require().Len(filtered.Data, 1) + suite.Equal(acme.UUID, filtered.Data[0].UUID) + suite.Equal(int64(1), filtered.Total) + + unknown := search("?subjectUuid=" + uuid.NewString()) + suite.Require().Equal(http.StatusOK, unknown.Code) + var none svc.ListResponse[PublicEvidenceResponse] + suite.Require().NoError(json.Unmarshal(unknown.Body.Bytes(), &none)) + suite.Empty(none.Data) + + suite.Equal(http.StatusBadRequest, search("?subjectUuid=not-a-uuid").Code) +} + +func (suite *EvidenceApiIntegrationSuite) postEvidenceAsUser(server *api.Server, request EvidenceCreateRequest) *httptest.ResponseRecorder { + token, err := suite.GetAuthToken() + suite.Require().NoError(err) + body, err := json.Marshal(request) + suite.Require().NoError(err) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/evidence", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set(echo.HeaderAuthorization, fmt.Sprintf("Bearer %s", *token)) + server.E().ServeHTTP(rec, req) + return rec +} + +func (suite *EvidenceApiIntegrationSuite) createParty(name string) uuid.UUID { + id := uuid.New() + suite.Require().NoError(suite.DB.Omit("Locations", "MemberOfOrganizations").Create(&relational.Party{ + UUIDModel: relational.UUIDModel{ID: &id}, + Type: relational.PartyTypeOrganization, + Name: &name, + }).Error) + return id +} + +func (suite *EvidenceApiIntegrationSuite) TestCreateStoresDeclaredSubjects() { + suite.Require().NoError(suite.Migrator.Refresh()) + suite.Config.StrictDisablePublicAgentEndpoints = false + server := suite.setupServerWithSubjectTemplates() + partyID := suite.createParty("Network Team") + + request := subjectEvidenceRequest(map[string]string{"source": "manual"}) + request.Title = "Quarterly firewall rule review" + request.Subjects = []EvidenceSubject{{SubjectUUID: &partyID}} + rec := suite.postEvidenceAsUser(server, request) + suite.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + + var refs []relational.EvidenceSubjectReference + suite.Require().NoError(suite.DB.Find(&refs).Error) + suite.Require().Len(refs, 1) + suite.Equal(partyID, refs[0].SubjectUUID) + suite.Equal("party", refs[0].Type, "the type comes from the subject") + suite.Equal("Network Team", refs[0].Title) + suite.Equal(relational.EvidenceSubjectSourceDeclared, refs[0].Source) + suite.Equal([]relational.Prop{{ + Ns: relational.CCFOSCALNamespace, Name: relational.EvidenceSubjectPropSource, Value: relational.EvidenceSubjectSourceDeclared, + }}, []relational.Prop(refs[0].Props)) + + var assessmentSubjects int64 + suite.Require().NoError(suite.DB.Model(&relational.AssessmentSubject{}).Count(&assessmentSubjects).Error) + suite.Zero(assessmentSubjects, "declared subjects don't create legacy assessment subjects") +} + +func (suite *EvidenceApiIntegrationSuite) TestCreateRejectsUnknownDeclaredSubject() { + suite.Require().NoError(suite.Migrator.Refresh()) + server := suite.setupServerWithSubjectTemplates() + + unknown := uuid.New() + request := subjectEvidenceRequest(nil) + request.Subjects = []EvidenceSubject{{SubjectUUID: &unknown}} + rec := suite.postEvidenceAsUser(server, request) + + suite.Equal(http.StatusBadRequest, rec.Code, rec.Body.String()) + suite.Contains(rec.Body.String(), "unknown subject") + var count int64 + suite.Require().NoError(suite.DB.Model(&relational.Evidence{}).Count(&count).Error) + suite.Zero(count) +} + +func (suite *EvidenceApiIntegrationSuite) TestCreateAppliesManualSubjectRequirement() { + suite.Require().NoError(suite.Migrator.Refresh()) + suite.Config.StrictDisablePublicAgentEndpoints = false + suite.Config.EvidenceSubjects = &config.EvidenceSubjectConfig{ManualRequireSubject: true} + defer func() { suite.Config.EvidenceSubjects = nil }() + server := suite.setupServerWithSubjectTemplates() + partyID := suite.createParty("Network Team") + + withoutSubject := suite.postEvidenceAsUser(server, subjectEvidenceRequest(nil)) + suite.Equal(http.StatusBadRequest, withoutSubject.Code, withoutSubject.Body.String()) + suite.Contains(withoutSubject.Body.String(), "evidence has no subject") + + withSubject := subjectEvidenceRequest(nil) + withSubject.Subjects = []EvidenceSubject{{SubjectUUID: &partyID}} + created := suite.postEvidenceAsUser(server, withSubject) + suite.Equal(http.StatusCreated, created.Code, created.Body.String()) + + agentEvidence := suite.postEvidence(server, subjectEvidenceRequest(nil)) + suite.Equal(http.StatusCreated, agentEvidence.Code, "the manual requirement doesn't apply to agent evidence") +} diff --git a/internal/api/handler/evidence_test.go b/internal/api/handler/evidence_test.go index 27a9603f..bb620848 100644 --- a/internal/api/handler/evidence_test.go +++ b/internal/api/handler/evidence_test.go @@ -8,6 +8,9 @@ import ( "testing" "time" + "github.com/compliance-framework/api/internal/service/relational" + oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" + "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" ) @@ -46,3 +49,40 @@ func TestImplementedComponentLinkID_UnambiguousPairs(t *testing.T) { assert.NoError(t, err) assert.Equal(t, a, again, "link IDs must be deterministic") } + +func TestSubjectReferencesForResponses(t *testing.T) { + ns := relational.CCFOSCALNamespace + template := relational.EvidenceSubjectReference{ + SubjectUUID: uuid.New(), + Type: "component", + Title: "GitHub Organization: acme", + Source: relational.EvidenceSubjectSourceTemplate, + Priority: 1, + Props: []relational.Prop{{Ns: ns, Name: relational.EvidenceSubjectPropSource, Value: relational.EvidenceSubjectSourceTemplate}}, + Links: []relational.Link{{Href: "https://github.com/acme", Rel: "canonical"}}, + } + legacy := relational.EvidenceSubjectReference{ + SubjectUUID: uuid.New(), + Type: "Component", + Source: relational.EvidenceSubjectSourceLegacy, + Props: []relational.Prop{{Ns: ns, Name: relational.EvidenceSubjectPropSource, Value: relational.EvidenceSubjectSourceLegacy}}, + } + refs := []relational.EvidenceSubjectReference{legacy, template} + + full := *fullSubjectReferences(refs) + assert.Len(t, full, 2) + assert.Equal(t, template.SubjectUUID.String(), full[0].SubjectUuid, "display order: higher priority first") + assert.NotNil(t, full[0].Props) + assert.NotNil(t, full[0].Links) + assert.Equal(t, legacy.SubjectUUID.String(), full[1].SubjectUuid) + + compact := *compactSubjectReferences(refs) + assert.Equal(t, []oscalTypes_1_1_3.SubjectReference{{ + SubjectUuid: template.SubjectUUID.String(), + Type: "component", + Title: "GitHub Organization: acme", + }}, compact, "only uuid, type and title, without legacy subjects") + + assert.NotNil(t, compactSubjectReferences(nil), "no subjects is an empty list, not an absent one") + assert.Empty(t, *compactSubjectReferences([]relational.EvidenceSubjectReference{legacy})) +} diff --git a/internal/api/handler/subjects.go b/internal/api/handler/subjects.go new file mode 100644 index 00000000..399a62f0 --- /dev/null +++ b/internal/api/handler/subjects.go @@ -0,0 +1,101 @@ +package handler + +import ( + "fmt" + "net/http" + "strings" + + "github.com/compliance-framework/api/internal/api" + svc "github.com/compliance-framework/api/internal/service" + "github.com/compliance-framework/api/internal/service/relational/subjects" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "go.uber.org/zap" + "gorm.io/gorm" +) + +type SubjectHandler struct { + service *subjects.Service + pagination *svc.PaginationConfig + sugar *zap.SugaredLogger +} + +func NewSubjectHandler(sugar *zap.SugaredLogger, db *gorm.DB) *SubjectHandler { + return &SubjectHandler{ + service: subjects.NewService(db), + pagination: svc.NewPaginationConfig(), + sugar: sugar, + } +} + +func (h *SubjectHandler) Register(api *echo.Group, middlewares ...echo.MiddlewareFunc) { + api.GET("", h.List, middlewares...) +} + +// List godoc +// +// @Summary List subjects +// @Description Lists the entities evidence can name as its subject: defined components, SSP system components, parties and users. Used to pick and filter evidence subjects. +// @Tags Subjects +// @Produce json +// @Param search query string false "Case-insensitive title search" +// @Param kind query string false "Comma-separated kinds: defined-component, system-component, party, user" +// @Param ssp query string false "Only system components of this SSP" +// @Param ids query string false "Comma-separated subject UUIDs to look up (at most 100)" +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} svc.ListResponse[subjects.Summary] +// @Failure 400 {object} api.Error +// @Failure 401 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /subjects [get] +func (h *SubjectHandler) List(ctx echo.Context) error { + pagination, err := h.pagination.ParseParams(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + params := subjects.ListParams{ + Search: ctx.QueryParam("search"), + Limit: pagination.Limit, + Offset: pagination.Offset, + } + if raw := strings.TrimSpace(ctx.QueryParam("kind")); raw != "" { + for _, value := range strings.Split(raw, ",") { + kind, ok := subjects.ParseKind(value) + if !ok { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("unsupported kind %q", strings.TrimSpace(value)))) + } + params.Kinds = append(params.Kinds, kind) + } + } + if raw := strings.TrimSpace(ctx.QueryParam("ids")); raw != "" { + values := strings.Split(raw, ",") + if len(values) > subjects.MaxLookupIDs { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("ids accepts at most %d subjects", subjects.MaxLookupIDs))) + } + for _, value := range values { + id, err := uuid.Parse(strings.TrimSpace(value)) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("invalid ids: %w", err))) + } + params.IDs = append(params.IDs, id) + } + } + if raw := strings.TrimSpace(ctx.QueryParam("ssp")); raw != "" { + sspID, err := uuid.Parse(raw) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("invalid ssp: %w", err))) + } + params.SSPID = &sspID + } + + items, total, err := h.service.List(ctx.Request().Context(), params) + if err != nil { + h.sugar.Errorw("Failed to list subjects", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.InternalServerError()) + } + + return ctx.JSON(http.StatusOK, svc.NewListResponse(items, total, pagination.Page, pagination.Limit)) +} diff --git a/internal/api/handler/subjects_integration_test.go b/internal/api/handler/subjects_integration_test.go new file mode 100644 index 00000000..600ed12d --- /dev/null +++ b/internal/api/handler/subjects_integration_test.go @@ -0,0 +1,237 @@ +//go:build integration + +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/compliance-framework/api/internal" + "github.com/compliance-framework/api/internal/api" + svc "github.com/compliance-framework/api/internal/service" + "github.com/compliance-framework/api/internal/service/relational" + riskrel "github.com/compliance-framework/api/internal/service/relational/risks" + "github.com/compliance-framework/api/internal/service/relational/subjects" + "github.com/compliance-framework/api/internal/tests" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/suite" + "go.uber.org/zap" + "gorm.io/gorm/clause" +) + +func TestSubjectsApi(t *testing.T) { + suite.Run(t, new(SubjectsApiIntegrationSuite)) +} + +type SubjectsApiIntegrationSuite struct { + tests.IntegrationTestSuite + server *api.Server + sspID uuid.UUID + // definedComponentID is linked from the Payments Platform SSP's system component. + definedComponentID uuid.UUID + partyID uuid.UUID + // otherSSPID has a system component too, to check the ssp filter. + otherSSPID uuid.UUID +} + +func (suite *SubjectsApiIntegrationSuite) SetupTest() { + suite.Require().NoError(suite.Migrator.Refresh()) + + logger, _ := zap.NewDevelopment() + metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) + suite.server = api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) + RegisterHandlers(suite.server, logger.Sugar(), suite.DB, suite.Config, &APIServices{}) + + db := suite.DB + cdID := uuid.New() + suite.Require().NoError(db.Omit(clause.Associations).Create(&relational.ComponentDefinition{UUIDModel: relational.UUIDModel{ID: &cdID}}).Error) + suite.Require().NoError(db.Create(&relational.Metadata{ + Title: "github-settings components", + ParentID: internal.Pointer(cdID.String()), + ParentType: internal.Pointer("component_definitions"), + }).Error) + suite.definedComponentID = uuid.New() + suite.Require().NoError(db.Omit(clause.Associations).Create(&relational.DefinedComponent{ + UUIDModel: relational.UUIDModel{ID: &suite.definedComponentID}, + Title: "GitHub Organization: acme", Type: "service", ComponentDefinitionID: &cdID, + }).Error) + suite.Require().NoError(db.Create(&riskrel.ComponentDefinitionLabel{ + DefinedComponentID: suite.definedComponentID, ComponentDefinitionID: cdID, Key: "organization", Value: "acme", + }).Error) + + suite.sspID = suite.createSSPWithComponent("Payments Platform", "Perimeter Firewall", &suite.definedComponentID) + suite.otherSSPID = suite.createSSPWithComponent("Data Platform", "Data Warehouse", nil) + + // A component created from evidence components[] belongs to no SSP and isn't listed. + suite.Require().NoError(db.Omit(clause.Associations).Create(&relational.SystemComponent{Title: "Evidence-only component", Type: "software"}).Error) + + suite.partyID = uuid.New() + suite.Require().NoError(db.Omit(clause.Associations).Create(&relational.Party{ + UUIDModel: relational.UUIDModel{ID: &suite.partyID}, + Type: relational.PartyTypeOrganization, + Name: internal.Pointer("Network Team"), + }).Error) + + for _, user := range []relational.User{ + {Email: "active@example.com", FirstName: "Avery", LastName: "Active", IsActive: true}, + {Email: "locked@example.com", FirstName: "Lee", LastName: "Locked", IsActive: true, IsLocked: true}, + } { + suite.Require().NoError(db.Create(&user).Error) + } + inactive := relational.User{Email: "inactive@example.com", FirstName: "Ivy", LastName: "Inactive"} + suite.Require().NoError(db.Create(&inactive).Error) + suite.Require().NoError(db.Model(&inactive).Update("is_active", false).Error) + deleted := relational.User{Email: "deleted@example.com", FirstName: "Dee", LastName: "Deleted", IsActive: true} + suite.Require().NoError(db.Create(&deleted).Error) + suite.Require().NoError(db.Delete(&deleted).Error) +} + +func (suite *SubjectsApiIntegrationSuite) createSSPWithComponent(sspTitle, componentTitle string, definedComponentID *uuid.UUID) uuid.UUID { + db := suite.DB + sspID := uuid.New() + suite.Require().NoError(db.Omit(clause.Associations).Create(&relational.SystemSecurityPlan{UUIDModel: relational.UUIDModel{ID: &sspID}}).Error) + suite.Require().NoError(db.Create(&relational.Metadata{ + Title: sspTitle, + ParentID: internal.Pointer(sspID.String()), + ParentType: internal.Pointer("system_security_plans"), + }).Error) + implementationID := uuid.New() + suite.Require().NoError(db.Omit(clause.Associations).Create(&relational.SystemImplementation{ + UUIDModel: relational.UUIDModel{ID: &implementationID}, SystemSecurityPlanId: sspID, + }).Error) + suite.Require().NoError(db.Omit(clause.Associations).Create(&relational.SystemComponent{ + Title: componentTitle, Type: "this-system", SystemImplementationId: implementationID, + DefinedComponentID: definedComponentID, + }).Error) + return sspID +} + +func (suite *SubjectsApiIntegrationSuite) list(query string) (int, svc.ListResponse[subjects.Summary]) { + token, err := suite.GetAuthToken() + suite.Require().NoError(err) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/subjects"+query, nil) + req.Header.Set(echo.HeaderAuthorization, fmt.Sprintf("Bearer %s", *token)) + suite.server.E().ServeHTTP(rec, req) + + var body svc.ListResponse[subjects.Summary] + if rec.Code == http.StatusOK { + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &body)) + } + return rec.Code, body +} + +func titles(items []subjects.Summary) []string { + out := make([]string, 0, len(items)) + for _, item := range items { + out = append(out, item.Title) + } + return out +} + +func (suite *SubjectsApiIntegrationSuite) TestListsEveryKindOrderedByTitle() { + code, body := suite.list("") + suite.Require().Equal(http.StatusOK, code) + + // The migrator's "Dummy User" is listed alongside the seeded active user. + suite.Equal([]string{ + "Avery Active", + "Data Warehouse", + "Dummy User", + "GitHub Organization: acme", + "Network Team", + "Perimeter Firewall", + }, titles(body.Data)) + suite.Equal(int64(6), body.Total) + + byTitle := map[string]subjects.Summary{} + for _, item := range body.Data { + byTitle[item.Title] = item + } + suite.Equal(subjects.KindDefinedComponent, byTitle["GitHub Organization: acme"].Kind) + suite.Equal("component", byTitle["GitHub Organization: acme"].Type) + suite.Equal("github-settings components", *byTitle["GitHub Organization: acme"].Context) + suite.Equal(subjects.KindSystemComponent, byTitle["Perimeter Firewall"].Kind) + suite.Equal("Payments Platform", *byTitle["Perimeter Firewall"].Context) + suite.Equal("party", byTitle["Network Team"].Type) + suite.Nil(byTitle["Network Team"].Context) + suite.Equal("user", byTitle["Avery Active"].Type) +} + +func (suite *SubjectsApiIntegrationSuite) TestFiltersBySearchKindAndSSP() { + _, search := suite.list("?search=PERIMETER") + suite.Equal([]string{"Perimeter Firewall"}, titles(search.Data)) + + _, percent := suite.list("?search=%25") + suite.Empty(percent.Data, "search treats % literally") + + _, kinds := suite.list("?kind=party,user") + suite.Equal([]string{"Avery Active", "Dummy User", "Network Team"}, titles(kinds.Data)) + + _, ssp := suite.list(fmt.Sprintf("?ssp=%s", suite.sspID)) + suite.Contains(titles(ssp.Data), "Perimeter Firewall") + suite.NotContains(titles(ssp.Data), "Data Warehouse", "ssp narrows system components") + suite.Contains(titles(ssp.Data), "Network Team", "ssp leaves other kinds alone") +} + +func (suite *SubjectsApiIntegrationSuite) TestPages() { + _, page := suite.list("?limit=2&page=2") + suite.Equal([]string{"Dummy User", "GitHub Organization: acme"}, titles(page.Data)) + suite.Equal(int64(6), page.Total) + suite.Equal(3, page.TotalPages) +} + +func (suite *SubjectsApiIntegrationSuite) TestRejectsBadParameters() { + code, _ := suite.list("?kind=resource") + suite.Equal(http.StatusBadRequest, code) + + code, _ = suite.list("?ssp=not-a-uuid") + suite.Equal(http.StatusBadRequest, code) +} + +func (suite *SubjectsApiIntegrationSuite) TestRequiresLogin() { + rec := httptest.NewRecorder() + suite.server.E().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/subjects", nil)) + suite.Equal(http.StatusUnauthorized, rec.Code) +} + +func (suite *SubjectsApiIntegrationSuite) TestLooksUpSubjectsByID() { + code, body := suite.list(fmt.Sprintf("?ids=%s,%s", suite.partyID, suite.definedComponentID)) + suite.Require().Equal(http.StatusOK, code) + suite.Equal([]string{"GitHub Organization: acme", "Network Team"}, titles(body.Data)) + + code, _ = suite.list("?ids=not-a-uuid") + suite.Equal(http.StatusBadRequest, code) + + tooMany := make([]string, 101) + for i := range tooMany { + tooMany[i] = uuid.NewString() + } + code, _ = suite.list("?ids=" + strings.Join(tooMany, ",")) + suite.Equal(http.StatusBadRequest, code) +} + +func (suite *SubjectsApiIntegrationSuite) TestDefinedComponentsCarryIdentityAndLinkedSSPs() { + _, body := suite.list("") + + byTitle := map[string]subjects.Summary{} + for _, item := range body.Data { + byTitle[item.Title] = item + } + + org := byTitle["GitHub Organization: acme"] + suite.Equal([]subjects.IdentityLabel{{Key: "organization", Value: "acme"}}, org.Identity) + suite.Require().Len(org.LinkedSSPs, 1) + suite.Equal(suite.sspID, org.LinkedSSPs[0].SSPID) + suite.Equal("Payments Platform", org.LinkedSSPs[0].SSPTitle) + suite.Equal("Perimeter Firewall", org.LinkedSSPs[0].ComponentTitle) + + suite.Empty(byTitle["Perimeter Firewall"].Identity, "only defined components carry identity") + suite.Empty(byTitle["Network Team"].LinkedSSPs) +} diff --git a/internal/api/handler/templates/subject_template.go b/internal/api/handler/templates/subject_template.go index 844c5be4..448fd87f 100644 --- a/internal/api/handler/templates/subject_template.go +++ b/internal/api/handler/templates/subject_template.go @@ -61,6 +61,8 @@ type upsertSubjectTemplateRequest struct { Props []relational.Prop `json:"props"` Links []relational.Link `json:"links"` SourceMode string `json:"source-mode" validate:"required"` + DisplayPriority int `json:"display-priority"` + ComponentType *string `json:"component-type"` SelectorLabels []subjectTemplateSelectorLabelRequest `json:"selector-labels" validate:"required"` LabelSchema []subjectTemplateLabelSchemaFieldRequest `json:"label-schema" validate:"required"` } @@ -89,6 +91,8 @@ type subjectTemplateResponse struct { Props []relational.Prop `json:"props"` Links []relational.Link `json:"links"` SourceMode string `json:"source-mode"` + DisplayPriority int `json:"display-priority"` + ComponentType *string `json:"component-type"` SelectorLabels []subjectTemplateSelectorLabelResponse `json:"selector-labels"` LabelSchema []subjectTemplateLabelSchemaFieldResponse `json:"label-schema"` } @@ -257,6 +261,8 @@ func mapSubjectTemplateRequestToPayload(req upsertSubjectTemplateRequest) templa Props: append([]relational.Prop{}, req.Props...), Links: append([]relational.Link{}, req.Links...), SourceMode: req.SourceMode, + DisplayPriority: req.DisplayPriority, + ComponentType: req.ComponentType, SelectorLabels: make([]templaterel.SubjectTemplateSelectorLabelInput, 0, len(req.SelectorLabels)), LabelSchema: make([]templaterel.SubjectTemplateLabelSchemaFieldInput, 0, len(req.LabelSchema)), } @@ -289,6 +295,8 @@ type batchSubjectTemplateItem struct { Props []relational.Prop `json:"props"` Links []relational.Link `json:"links"` SourceMode string `json:"source-mode"` + DisplayPriority int `json:"display-priority"` + ComponentType *string `json:"component-type"` SelectorLabels []subjectTemplateSelectorLabelRequest `json:"selector-labels"` LabelSchema []subjectTemplateLabelSchemaFieldRequest `json:"label-schema"` } @@ -303,6 +311,9 @@ type batchUpsertSubjectTemplatesData struct { Updated []subjectTemplateResponse `json:"updated"` Deleted []uuid.UUID `json:"deleted"` Unchanged []uuid.UUID `json:"unchanged"` + // Warnings lists non-component templates, which are accepted but produce no evidence + // subjects. + Warnings []string `json:"warnings"` } type batchUpsertSubjectTemplatesResponse struct { @@ -355,6 +366,8 @@ func (h *SubjectTemplateHandler) BatchUpsert(ctx echo.Context) error { Props: append([]relational.Prop{}, item.Props...), Links: append([]relational.Link{}, item.Links...), SourceMode: item.SourceMode, + DisplayPriority: item.DisplayPriority, + ComponentType: item.ComponentType, SelectorLabels: make([]templaterel.SubjectTemplateSelectorLabelInput, 0, len(item.SelectorLabels)), LabelSchema: make([]templaterel.SubjectTemplateLabelSchemaFieldInput, 0, len(item.LabelSchema)), } @@ -383,6 +396,7 @@ func (h *SubjectTemplateHandler) BatchUpsert(ctx echo.Context) error { Updated: make([]subjectTemplateResponse, 0, len(result.Updated)), Deleted: result.Deleted, Unchanged: result.Unchanged, + Warnings: result.Warnings, } for _, row := range result.Created { data.Created = append(data.Created, mapSubjectTemplateToResponse(row)) @@ -409,6 +423,8 @@ func mapSubjectTemplateToResponse(row templaterel.SubjectTemplate) subjectTempla Props: append([]relational.Prop{}, row.Props...), Links: append([]relational.Link{}, row.Links...), SourceMode: row.SourceMode, + DisplayPriority: row.DisplayPriority, + ComponentType: row.ComponentType, SelectorLabels: make([]subjectTemplateSelectorLabelResponse, 0, len(row.SelectorLabels)), LabelSchema: make([]subjectTemplateLabelSchemaFieldResponse, 0, len(row.LabelSchema)), } diff --git a/internal/api/handler/templates/subject_template_integration_test.go b/internal/api/handler/templates/subject_template_integration_test.go index 65d501dd..a59b376d 100644 --- a/internal/api/handler/templates/subject_template_integration_test.go +++ b/internal/api/handler/templates/subject_template_integration_test.go @@ -44,6 +44,8 @@ type subjectTemplateAPIResponse struct { Name string `json:"name"` Type string `json:"type"` SourceMode string `json:"source-mode"` + DisplayPriority int `json:"display-priority"` + ComponentType *string `json:"component-type"` IdentityLabelKeys []string `json:"identity-label-keys"` SelectorLabels []subjectTemplateSelectorLabelResponse `json:"selector-labels"` LabelSchema []subjectTemplateLabelSchemaFieldResponse `json:"label-schema"` @@ -250,9 +252,10 @@ func (suite *SubjectTemplateApiIntegrationSuite) TestSubjectTemplateRequiresAuth type batchSubjectTemplateResult struct { Data struct { - Created []subjectTemplateAPIResponse `json:"created"` - Updated []subjectTemplateAPIResponse `json:"updated"` - Deleted []uuid.UUID `json:"deleted"` + Created []subjectTemplateAPIResponse `json:"created"` + Updated []subjectTemplateAPIResponse `json:"updated"` + Deleted []uuid.UUID `json:"deleted"` + Warnings []string `json:"warnings"` } `json:"data"` } @@ -469,3 +472,61 @@ func (suite *SubjectTemplateApiIntegrationSuite) TestSubjectTemplateBatchUpsertR suite.server.E().ServeHTTP(rec, req) require.Equal(suite.T(), http.StatusUnauthorized, rec.Code) } + +func (suite *SubjectTemplateApiIntegrationSuite) TestSubjectTemplateBatchUpsertDisplayPriorityComponentTypeAndWarnings() { + componentID := uuid.New() + resourceID := uuid.New() + batchReq := map[string]any{ + "plugin-id": "fields-plugin", + "templates": []map[string]any{ + { + "id": componentID.String(), + "name": "GitHub Organization", + "type": "component", + "source-mode": "runtime-derived", + "display-priority": 7, + "component-type": "Software", + "identity-label-keys": []string{"organization"}, + "links": []map[string]any{ + {"href": "https://github.com/{{ .organization }}", "rel": "canonical"}, + }, + "selector-labels": []map[string]any{ + {"key": "_plugin", "value": "fields-plugin"}, + }, + "label-schema": []map[string]any{ + {"key": "organization"}, + }, + }, + { + "id": resourceID.String(), + "name": "Cloud resource", + "type": "resource", + "source-mode": "runtime-derived", + "identity-label-keys": []string{"resource_id"}, + "selector-labels": []map[string]any{ + {"key": "_plugin", "value": "fields-plugin"}, + }, + "label-schema": []map[string]any{ + {"key": "resource_id"}, + }, + }, + }, + } + + rec, req := suite.agentRequest(http.MethodPost, "/api/agent/subject-templates/batch", batchReq) + suite.server.E().ServeHTTP(rec, req) + require.Equal(suite.T(), http.StatusOK, rec.Code, rec.Body.String()) + + var result batchSubjectTemplateResult + require.NoError(suite.T(), json.Unmarshal(rec.Body.Bytes(), &result)) + require.Len(suite.T(), result.Data.Created, 2) + for _, row := range result.Data.Created { + if row.ID == componentID { + require.Equal(suite.T(), 7, row.DisplayPriority) + require.NotNil(suite.T(), row.ComponentType) + require.Equal(suite.T(), "software", *row.ComponentType) + } + } + require.Len(suite.T(), result.Data.Warnings, 1) + require.Contains(suite.T(), result.Data.Warnings[0], resourceID.String()) +} diff --git a/internal/api/handler/workflows/common_test.go b/internal/api/handler/workflows/common_test.go index bea5662f..98cb09f0 100644 --- a/internal/api/handler/workflows/common_test.go +++ b/internal/api/handler/workflows/common_test.go @@ -25,6 +25,7 @@ func setupTestDB(t *testing.T) *gorm.DB { &relational.BackMatterResource{}, &relational.BackMatter{}, &relational.Evidence{}, + &relational.EvidenceSubjectReference{}, &relational.Labels{}, &workflows.WorkflowDefinition{}, &workflows.WorkflowStepDefinition{}, diff --git a/internal/api/handler/workflows/step_execution_integration_test.go b/internal/api/handler/workflows/step_execution_integration_test.go index a0c0d939..b2173643 100644 --- a/internal/api/handler/workflows/step_execution_integration_test.go +++ b/internal/api/handler/workflows/step_execution_integration_test.go @@ -47,7 +47,12 @@ func setupStepExecutionTestHandler(t *testing.T) (*StepExecutionHandler, *gorm.D assignmentService := workflow.NewAssignmentService(roleAssignmentService, stepExecService, db, zap.NewNop().Sugar(), nil) privateKey, _, err := config.GenerateKeyPair(2048) require.NoError(t, err) - evidenceService := evidencesvc.NewEvidenceService(db, logger, &config.Config{JWTPrivateKey: privateKey}, nil) + // The manual subject requirement is on: step evidence is signed by the user completing + // the step, but follows workflow rules, so completing a step must still create evidence. + evidenceService := evidencesvc.NewEvidenceService(db, logger, &config.Config{ + JWTPrivateKey: privateKey, + EvidenceSubjects: &config.EvidenceSubjectConfig{ManualRequireSubject: true}, + }, nil) // Create executor for step transition coordination stdLogger := log.Default() diff --git a/internal/config/config.go b/internal/config/config.go index 908029ee..fa6f63d6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -48,6 +48,7 @@ type Config struct { Authz *AuthzConfig Playback *PlaybackConfig Artifact *ArtifactConfig + EvidenceSubjects *EvidenceSubjectConfig } func NewConfig(logger *zap.SugaredLogger) *Config { @@ -177,6 +178,11 @@ func NewConfig(logger *zap.SugaredLogger) *Config { evidenceDefaultExpiryMonths = 1 } + evidenceSubjectConfig, err := LoadEvidenceSubjectConfig() + if err != nil { + logger.Fatalw("Invalid evidence subject config", "error", err) + } + // Digest configuration digestEnabled := viper.GetBool("digest_enabled") digestSchedule := viper.GetString("digest_schedule") @@ -266,6 +272,7 @@ func NewConfig(logger *zap.SugaredLogger) *Config { Slack: slackConfig, Worker: workerConfig, EvidenceDefaultExpiryMonths: evidenceDefaultExpiryMonths, + EvidenceSubjects: evidenceSubjectConfig, DigestEnabled: digestEnabled, DigestSchedule: digestSchedule, Workflow: workflowConfig, diff --git a/internal/config/evidence.go b/internal/config/evidence.go new file mode 100644 index 00000000..0e0fb59f --- /dev/null +++ b/internal/config/evidence.go @@ -0,0 +1,47 @@ +package config + +import ( + "fmt" + "strings" + + "github.com/spf13/viper" +) + +// EvidenceRequireSubjectMode is how agent evidence without a derived subject is handled +// (CCF_EVIDENCE_REQUIRE_SUBJECT). +type EvidenceRequireSubjectMode string + +const ( + EvidenceRequireSubjectOff EvidenceRequireSubjectMode = "off" + EvidenceRequireSubjectWarn EvidenceRequireSubjectMode = "warn" + EvidenceRequireSubjectEnforce EvidenceRequireSubjectMode = "enforce" +) + +// EvidenceSubjectConfig holds the evidence subject requirements. +type EvidenceSubjectConfig struct { + // RequireSubject applies to agent evidence (CCF_EVIDENCE_REQUIRE_SUBJECT, default off). + RequireSubject EvidenceRequireSubjectMode + // ManualRequireSubject requires user-submitted evidence to name a subject + // (CCF_MANUAL_EVIDENCE_REQUIRE_SUBJECT, default false). + ManualRequireSubject bool +} + +func LoadEvidenceSubjectConfig() (*EvidenceSubjectConfig, error) { + return LoadEvidenceSubjectConfigFromViper(viper.GetViper()) +} + +func LoadEvidenceSubjectConfigFromViper(v *viper.Viper) (*EvidenceSubjectConfig, error) { + mode := EvidenceRequireSubjectMode(strings.ToLower(strings.TrimSpace(v.GetString("evidence_require_subject")))) + switch mode { + case "": + mode = EvidenceRequireSubjectOff + case EvidenceRequireSubjectOff, EvidenceRequireSubjectWarn, EvidenceRequireSubjectEnforce: + default: + return nil, fmt.Errorf("CCF_EVIDENCE_REQUIRE_SUBJECT must be one of off, warn or enforce, got %q", mode) + } + + return &EvidenceSubjectConfig{ + RequireSubject: mode, + ManualRequireSubject: v.GetBool("manual_evidence_require_subject"), + }, nil +} diff --git a/internal/config/evidence_test.go b/internal/config/evidence_test.go new file mode 100644 index 00000000..68e46f8c --- /dev/null +++ b/internal/config/evidence_test.go @@ -0,0 +1,39 @@ +package config + +import ( + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func TestLoadEvidenceSubjectConfigDefaults(t *testing.T) { + cfg, err := LoadEvidenceSubjectConfigFromViper(viper.New()) + + require.NoError(t, err) + require.Equal(t, EvidenceRequireSubjectOff, cfg.RequireSubject) + require.False(t, cfg.ManualRequireSubject) +} + +func TestLoadEvidenceSubjectConfigReadsFlags(t *testing.T) { + v := viper.New() + v.Set("evidence_require_subject", " Enforce ") + v.Set("manual_evidence_require_subject", "true") + + cfg, err := LoadEvidenceSubjectConfigFromViper(v) + + require.NoError(t, err) + require.Equal(t, EvidenceRequireSubjectEnforce, cfg.RequireSubject) + require.True(t, cfg.ManualRequireSubject) +} + +func TestLoadEvidenceSubjectConfigRejectsUnknownMode(t *testing.T) { + v := viper.New() + v.Set("evidence_require_subject", "strict") + + cfg, err := LoadEvidenceSubjectConfigFromViper(v) + + require.Nil(t, cfg) + require.Error(t, err) + require.Contains(t, err.Error(), "CCF_EVIDENCE_REQUIRE_SUBJECT") +} diff --git a/internal/service/export_test.go b/internal/service/export_test.go new file mode 100644 index 00000000..67e6b615 --- /dev/null +++ b/internal/service/export_test.go @@ -0,0 +1,5 @@ +package service + +// MigrateComponentDefinitionIdentityKey exposes the guarded identity-key migration to the +// external integration tests. +var MigrateComponentDefinitionIdentityKey = migrateComponentDefinitionIdentityKey diff --git a/internal/service/migrator.go b/internal/service/migrator.go index 2dcd5b24..d7d8ac1e 100644 --- a/internal/service/migrator.go +++ b/internal/service/migrator.go @@ -189,6 +189,7 @@ func MigrateUpWithConfig(db *gorm.DB, cfg *config.Config) error { &Heartbeat{}, &relational.Evidence{}, &relational.Artifact{}, + &relational.EvidenceSubjectReference{}, &relational.Labels{}, &relational.SelectSubjectById{}, &relational.Filter{}, @@ -502,6 +503,10 @@ func MigrateUpWithConfig(db *gorm.DB, cfg *config.Config) error { `).Error; err != nil { return err } + + if err := migrateComponentDefinitionIdentityKey(db); 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 @@ -936,6 +941,39 @@ func migrateBackfillOfferingItemStatementIDs(db *gorm.DB) error { return nil } +// migrateComponentDefinitionIdentityKey widens the component_definition_identities primary +// key from (entity_type, identity_hash) to (entity_type, component_definition_id, +// identity_hash), so two plugins reporting the same identity labels each get their own +// DefinedComponent (BCH-1364). AutoMigrate never changes an existing primary key. Rows are +// unique under the old key, so they stay unique under the wider one. +// +// Guarded so it is a no-op once the key includes component_definition_id: the ALTER takes +// an ACCESS EXCLUSIVE lock, which must not happen on every boot. Postgres only. +func migrateComponentDefinitionIdentityKey(db *gorm.DB) error { + return db.Exec(` + DO $$ + DECLARE + pk_name text; + BEGIN + SELECT c.conname INTO pk_name + FROM pg_constraint c + WHERE c.conrelid = to_regclass('component_definition_identities') + AND c.contype = 'p' + AND NOT EXISTS ( + SELECT 1 FROM pg_attribute a + WHERE a.attrelid = c.conrelid + AND a.attnum = ANY (c.conkey) + AND a.attname = 'component_definition_id' + ); + IF pk_name IS NOT NULL THEN + EXECUTE format('ALTER TABLE component_definition_identities DROP CONSTRAINT %I', pk_name); + ALTER TABLE component_definition_identities + ADD PRIMARY KEY (entity_type, component_definition_id, identity_hash); + END IF; + END $$; + `).Error +} + // 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. @@ -1135,6 +1173,7 @@ func MigrateDown(db *gorm.DB) error { &relational.SystemNotificationDestination{}, &Heartbeat{}, + &relational.EvidenceSubjectReference{}, &relational.Evidence{}, &relational.Artifact{}, "evidence_activities", diff --git a/internal/service/migrator_integration_test.go b/internal/service/migrator_integration_test.go new file mode 100644 index 00000000..2910580f --- /dev/null +++ b/internal/service/migrator_integration_test.go @@ -0,0 +1,76 @@ +//go:build integration + +package service_test + +import ( + "testing" + + "github.com/compliance-framework/api/internal/service" + "github.com/compliance-framework/api/internal/tests" + "github.com/google/uuid" + "github.com/stretchr/testify/suite" +) + +type MigratorIntegrationSuite struct { + tests.IntegrationTestSuite +} + +func TestMigratorIntegration(t *testing.T) { + suite.Run(t, new(MigratorIntegrationSuite)) +} + +func (s *MigratorIntegrationSuite) SetupTest() { + s.Require().NoError(s.Migrator.Refresh()) +} + +func (s *MigratorIntegrationSuite) primaryKeyColumns(table string) []string { + var columns []string + s.Require().NoError(s.DB.Raw(` + SELECT a.attname + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY (i.indkey) + WHERE i.indrelid = ?::regclass AND i.indisprimary + ORDER BY array_position(i.indkey::int2[], a.attnum) + `, table).Scan(&columns).Error) + return columns +} + +func (s *MigratorIntegrationSuite) TestComponentDefinitionIdentityKeyIsPerPlugin() { + perPluginKey := []string{"entity_type", "component_definition_id", "identity_hash"} + + // A freshly migrated database gets the per-plugin key from the model. + s.Equal(perPluginKey, s.primaryKeyColumns("component_definition_identities")) + + // An existing database still has the original (entity_type, identity_hash) key. + s.Require().NoError(s.DB.Exec(`DROP TABLE component_definition_identities`).Error) + s.Require().NoError(s.DB.Exec(` + CREATE TABLE component_definition_identities ( + entity_type text NOT NULL, + identity_hash char(64) NOT NULL, + component_definition_id uuid NOT NULL, + defined_component_id uuid NOT NULL, + PRIMARY KEY (entity_type, identity_hash) + ) + `).Error) + insert := `INSERT INTO component_definition_identities + (entity_type, identity_hash, component_definition_id, defined_component_id) VALUES (?, ?, ?, ?)` + hashA := "aa" + uuid.NewString()[:30] + uuid.NewString()[:32] + hashB := "bb" + uuid.NewString()[:30] + uuid.NewString()[:32] + pluginA := uuid.New() + s.Require().NoError(s.DB.Exec(insert, "component", hashA, pluginA, uuid.New()).Error) + s.Require().NoError(s.DB.Exec(insert, "component", hashB, pluginA, uuid.New()).Error) + + s.Require().NoError(service.MigrateComponentDefinitionIdentityKey(s.DB)) + + s.Equal(perPluginKey, s.primaryKeyColumns("component_definition_identities")) + var count int64 + s.Require().NoError(s.DB.Raw(`SELECT count(*) FROM component_definition_identities`).Scan(&count).Error) + s.Equal(int64(2), count, "existing identities are kept") + + // A second plugin can now record the same identity. + s.Require().NoError(s.DB.Exec(insert, "component", hashA, uuid.New(), uuid.New()).Error) + + // Running it again is a no-op. + s.Require().NoError(service.MigrateComponentDefinitionIdentityKey(s.DB)) + s.Equal(perPluginKey, s.primaryKeyColumns("component_definition_identities")) +} diff --git a/internal/service/relational/common.go b/internal/service/relational/common.go index a0a0f7bd..452bfee0 100644 --- a/internal/service/relational/common.go +++ b/internal/service/relational/common.go @@ -19,6 +19,10 @@ func (u *UUIDModel) BeforeCreate(tx *gorm.DB) (err error) { return } +// CCFOSCALNamespace is the namespace of CCF props that carry what OSCAL has no slot for +// (abbreviated "ccf:" in the design), such as a derived subject's identity labels. +const CCFOSCALNamespace = "https://compliance-framework.github.io/ns/oscal" + type Prop oscaltypes113.Property func (p *Prop) UnmarshalOscal(data oscaltypes113.Property) *Prop { diff --git a/internal/service/relational/evidence.go b/internal/service/relational/evidence.go index d14ca7dd..8acd4287 100644 --- a/internal/service/relational/evidence.go +++ b/internal/service/relational/evidence.go @@ -56,6 +56,9 @@ type Evidence struct { Components []SystemComponent `gorm:"many2many:evidence_components" json:"components,omitempty"` // Who or What are we providing evidence for. What's under test. Subjects []AssessmentSubject `gorm:"many2many:evidence_subjects;" json:"subjects,omitempty"` + // SubjectReferences are the evidence's subjects as OSCAL subject references: derived from + // subject templates, declared, or legacy plugin identifiers. + SubjectReferences []EvidenceSubjectReference `gorm:"foreignKey:EvidenceID;constraint:OnDelete:CASCADE" json:"subject-references,omitempty"` // Did we satisfy what was being tested for, or did we fail ? Status datatypes.JSONType[oscalTypes_1_1_3.ObjectiveStatus] `json:"status"` diff --git a/internal/service/relational/evidence/origin.go b/internal/service/relational/evidence/origin.go new file mode 100644 index 00000000..f723f426 --- /dev/null +++ b/internal/service/relational/evidence/origin.go @@ -0,0 +1,30 @@ +package evidence + +// EvidenceOrigin is where a piece of evidence came from. It decides which subject rules +// apply to it. +type EvidenceOrigin string + +const ( + OriginAgent EvidenceOrigin = "agent" + OriginUser EvidenceOrigin = "user" + OriginWorkflow EvidenceOrigin = "workflow" + OriginSystem EvidenceOrigin = "system" +) + +// OriginFromSigner derives the origin of evidence submitted through the API: a user signer +// means user, and an agent signer or no signer (anonymous public ingest) means agent. +func OriginFromSigner(signer *SignerContext) EvidenceOrigin { + if signer != nil && signer.User != nil { + return OriginUser + } + return OriginAgent +} + +// EffectiveOrigin is the origin the evidence is treated as: the one its creation path set +// explicitly (workflow and system paths do), otherwise the one its signer implies. +func (p CreateEvidenceParams) EffectiveOrigin() EvidenceOrigin { + if p.Origin != "" { + return p.Origin + } + return OriginFromSigner(p.Signer) +} diff --git a/internal/service/relational/evidence/origin_test.go b/internal/service/relational/evidence/origin_test.go new file mode 100644 index 00000000..02a4fd8d --- /dev/null +++ b/internal/service/relational/evidence/origin_test.go @@ -0,0 +1,35 @@ +package evidence + +import ( + "testing" + + "github.com/compliance-framework/api/internal/authn" + "github.com/compliance-framework/api/internal/config" + "github.com/stretchr/testify/require" +) + +func TestOriginFromSigner(t *testing.T) { + require.Equal(t, OriginUser, OriginFromSigner(NewUserSignerContextFromClaims(&authn.UserClaims{}))) + require.Equal(t, OriginAgent, OriginFromSigner(NewAgentSignerContext(&authn.AgentClaims{}, nil, nil))) + require.Equal(t, OriginAgent, OriginFromSigner(nil), "anonymous public ingest counts as agent") +} + +func TestCreateEvidenceParamsEffectiveOrigin(t *testing.T) { + userSigner := NewUserSignerContextFromClaims(&authn.UserClaims{}) + + require.Equal(t, OriginUser, CreateEvidenceParams{Signer: userSigner}.EffectiveOrigin()) + require.Equal(t, OriginWorkflow, CreateEvidenceParams{Signer: userSigner, Origin: OriginWorkflow}.EffectiveOrigin(), + "an origin set by the creation path wins over the signer") +} + +func TestEvidenceServiceManualEvidenceRequiresSubject(t *testing.T) { + var nilService *EvidenceService + require.False(t, nilService.ManualEvidenceRequiresSubject()) + require.False(t, NewEvidenceService(nil, nil, &config.Config{}, nil).ManualEvidenceRequiresSubject()) + require.False(t, NewEvidenceService(nil, nil, &config.Config{ + EvidenceSubjects: &config.EvidenceSubjectConfig{ManualRequireSubject: false}, + }, nil).ManualEvidenceRequiresSubject()) + require.True(t, NewEvidenceService(nil, nil, &config.Config{ + EvidenceSubjects: &config.EvidenceSubjectConfig{ManualRequireSubject: true}, + }, nil).ManualEvidenceRequiresSubject()) +} diff --git a/internal/service/relational/evidence/service.go b/internal/service/relational/evidence/service.go index bfdc605a..89c151d0 100644 --- a/internal/service/relational/evidence/service.go +++ b/internal/service/relational/evidence/service.go @@ -2,13 +2,16 @@ package evidence import ( "context" + "errors" "fmt" + "strconv" "strings" "time" "github.com/compliance-framework/api/internal/config" "github.com/compliance-framework/api/internal/converters/labelfilter" "github.com/compliance-framework/api/internal/service/relational" + "github.com/compliance-framework/api/internal/service/relational/subjects" "github.com/compliance-framework/api/internal/service/relational/templates" "github.com/google/uuid" "go.uber.org/zap" @@ -16,6 +19,15 @@ import ( "gorm.io/gorm/clause" ) +// ErrSubjectRequired is returned when evidence that must have a subject has none. +var ErrSubjectRequired = errors.New("evidence has no subject") + +// ErrUnknownSubject is returned when evidence names a subject that doesn't exist. +var ErrUnknownSubject = errors.New("unknown subject") + +// ErrTooManySubjects is returned when evidence names more subjects than one lookup allows. +var ErrTooManySubjects = errors.New("too many subjects") + // RiskJobEnqueuer interface to avoid circular imports type RiskJobEnqueuer interface { EnqueueRiskProcessEvidence(ctx context.Context, evidenceID uuid.UUID, evidenceEnd, status string) error @@ -49,9 +61,11 @@ const ( ) type SearchOptions struct { - Limit int - Offset int - Name string + Limit int + Offset int + Name string + // SubjectUUID limits results to evidence with this subject among its subjects. + SubjectUUID *uuid.UUID SortBy SearchSortBy SortDirection SearchSortDirection } @@ -95,6 +109,12 @@ type CreateEvidenceParams struct { Subjects []relational.AssessmentSubject Labels []relational.Labels Signer *SignerContext + // Origin is set by creation paths that aren't identified by their signer (workflow, + // system). Left empty, the signer decides; see EffectiveOrigin. + Origin EvidenceOrigin + // DeclaredSubjectUUIDs are subjects the submitter names explicitly: defined components, + // SSP system components, parties or users. + DeclaredSubjectUUIDs []uuid.UUID } func (s *EvidenceService) Create(ctx context.Context, params CreateEvidenceParams) (*relational.Evidence, error) { @@ -106,13 +126,37 @@ func (s *EvidenceService) Create(ctx context.Context, params CreateEvidenceParam status string } - // Resolve ComponentDefinitions from labels and merge any linked SystemComponents. + // Resolve ComponentDefinitions from labels: they are the evidence's template-derived + // subjects, and any SystemComponents linked to them are merged into its components. + var derivedSubjects []templates.ResolvedSubject + var resolveErr error if s.cdResolver != nil && len(params.Labels) > 0 { - params.Components = s.resolveAndMergeComponents(params.Labels, params.Components) + var resolved *templates.ResolveOrUpsertComponentDefinitionResult + resolved, resolveErr = s.resolveComponentDefinitions(params.Labels) + if resolved != nil { + derivedSubjects = resolved.Subjects + params.Components = s.mergeLinkedSystemComponents(resolved.DefinedComponentIDs, params.Components) + } + } + + declaredSubjects, err := s.resolveDeclaredSubjects(ctx, params.DeclaredSubjectUUIDs) + if err != nil { + return nil, err + } + + // Set on the evidence so they're saved with it and returned on the created evidence. + params.Evidence.SubjectReferences = buildSubjectReferences(derivedSubjects, declaredSubjects, params.Subjects) + if err := s.checkSubjectRequirement(params); err != nil { + // The subjects couldn't be derived, so the evidence isn't known to lack one: report + // the resolver failure, which the submitter can retry, not a missing subject. + if resolveErr != nil { + return nil, fmt.Errorf("resolve evidence subjects: %w", resolveErr) + } + return nil, err } // Use a complete database transaction - err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // Create all related entities first for i := range params.Components { if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(¶ms.Components[i]).Error; err != nil { @@ -249,6 +293,12 @@ func (s *EvidenceService) Create(ctx context.Context, params CreateEvidenceParam return evidence, nil } +// ManualEvidenceRequiresSubject reports whether user-submitted evidence must name a subject +// (CCF_MANUAL_EVIDENCE_REQUIRE_SUBJECT). +func (s *EvidenceService) ManualEvidenceRequiresSubject() bool { + return s != nil && s.cfg != nil && s.cfg.EvidenceSubjects != nil && s.cfg.EvidenceSubjects.ManualRequireSubject +} + func (s *EvidenceService) GetByID(id uuid.UUID) (*relational.Evidence, error) { var evidence relational.Evidence if err := s.evidenceQuery(s.db). @@ -310,7 +360,8 @@ func (s *EvidenceService) evidenceQuery(db *gorm.DB) *gorm.DB { Preload("Components"). Preload("Subjects"). Preload("Subjects.IncludeSubjects"). - Preload("Subjects.ExcludeSubjects") + Preload("Subjects.ExcludeSubjects"). + Preload("SubjectReferences") } func (s *EvidenceService) Search(filter labelfilter.Filter) ([]relational.Evidence, error) { @@ -337,6 +388,10 @@ func (s *EvidenceService) SearchPaginated(filter labelfilter.Filter, opts Search query = query.Where("l.title ILIKE ? ESCAPE '\\'", "%"+escapeILikePattern(name)+"%") } + if opts.SubjectUUID != nil { + query = query.Where("EXISTS (SELECT 1 FROM evidence_subject_references esr WHERE esr.evidence_id = l.id AND esr.subject_uuid = ?)", *opts.SubjectUUID) + } + var total int64 if err := query.Model(&relational.Evidence{}).Count(&total).Error; err != nil { return nil, 0, err @@ -345,6 +400,7 @@ func (s *EvidenceService) SearchPaginated(filter labelfilter.Filter, opts Search var results []relational.Evidence if err := query. Preload("Labels"). + Preload("SubjectReferences"). Scopes(applyEvidenceSearchOrder(opts.SortBy, opts.SortDirection)). Limit(opts.Limit). Offset(opts.Offset). @@ -500,11 +556,9 @@ func (s *EvidenceService) GetControlByID(id string) (*relational.Control, error) return &control, nil } -// resolveAndMergeComponents uses the ComponentDefinition resolver to discover -// SystemComponents from evidence labels and merges them into the existing list, -// deduplicating by ID. -func (s *EvidenceService) resolveAndMergeComponents(labels []relational.Labels, existing []relational.SystemComponent) []relational.SystemComponent { - definedComponentIDs := s.resolveDefinedComponentIDs(labels) +// mergeLinkedSystemComponents merges the SystemComponents linked to the resolved +// DefinedComponents into the existing list, deduplicating by ID. +func (s *EvidenceService) mergeLinkedSystemComponents(definedComponentIDs []uuid.UUID, existing []relational.SystemComponent) []relational.SystemComponent { if len(definedComponentIDs) == 0 { return existing } @@ -520,7 +574,11 @@ func (s *EvidenceService) resolveAndMergeComponents(labels []relational.Labels, return mergeSystemComponents(existing, discovered) } -func (s *EvidenceService) resolveDefinedComponentIDs(labels []relational.Labels) []uuid.UUID { +// resolveComponentDefinitions resolves the DefinedComponents (template-derived subjects) +// matching the evidence labels. A resolver error is logged and returned with no result: the +// evidence is still saved, without derived subjects, unless it then fails its subject +// requirement. +func (s *EvidenceService) resolveComponentDefinitions(labels []relational.Labels) (*templates.ResolveOrUpsertComponentDefinitionResult, error) { result, err := s.cdResolver.ResolveOrUpsertComponentDefinition(templates.ResolveOrUpsertComponentDefinitionInput{ EvidenceLabels: labels, }) @@ -528,12 +586,156 @@ func (s *EvidenceService) resolveDefinedComponentIDs(labels []relational.Labels) if s.logger != nil { s.logger.Warnw("Failed to resolve component definitions from evidence labels", "error", err) } - return nil + return nil, err + } + return result, nil +} + +// resolveDeclaredSubjects looks up the subjects the submitter named, in the order first named. +// A subject named more than once is returned once. +func (s *EvidenceService) resolveDeclaredSubjects(ctx context.Context, named []uuid.UUID) ([]subjects.Summary, error) { + ids := make([]uuid.UUID, 0, len(named)) + seen := make(map[uuid.UUID]struct{}, len(named)) + for _, id := range named { + if _, dup := seen[id]; dup { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if len(ids) == 0 { + return nil, nil + } + if len(ids) > subjects.MaxLookupIDs { + return nil, fmt.Errorf("%w: evidence may name at most %d subjects, got %d", ErrTooManySubjects, subjects.MaxLookupIDs, len(ids)) + } + found, err := subjects.NewService(s.db).Resolve(ctx, ids) + if err != nil { + return nil, err } - if result == nil { + declared := make([]subjects.Summary, 0, len(ids)) + for _, id := range ids { + subject, ok := found[id] + if !ok { + return nil, fmt.Errorf("%w %s: subject-uuid must be a defined component, SSP system component, party or user", ErrUnknownSubject, id) + } + declared = append(declared, subject) + } + return declared, nil +} + +// buildSubjectReferences turns the template-derived subjects, the declared subjects and the +// legacy identifier subjects into the evidence's subject references. +func buildSubjectReferences(derived []templates.ResolvedSubject, declared []subjects.Summary, legacy []relational.AssessmentSubject) []relational.EvidenceSubjectReference { + refs := make([]relational.EvidenceSubjectReference, 0, len(derived)+len(declared)+len(legacy)) + + for _, subject := range derived { + templateID := subject.TemplateID + refs = append(refs, relational.EvidenceSubjectReference{ + SubjectUUID: subject.DefinedComponentID, + Type: subject.Type, + Title: subject.Title, + Source: relational.EvidenceSubjectSourceTemplate, + TemplateID: &templateID, + Priority: subject.DisplayPriority, + Props: []relational.Prop{ + subjectProp(relational.EvidenceSubjectPropSource, relational.EvidenceSubjectSourceTemplate), + subjectProp(relational.EvidenceSubjectPropTemplate, subject.TemplateName), + subjectProp(relational.EvidenceSubjectPropDisplayPriority, strconv.Itoa(subject.DisplayPriority)), + }, + Links: append([]relational.Link{}, subject.Links...), + }) + } + + // Declared subjects take their type and title from the entity named. A subject a + // template already derived is kept once, as derived. + derivedIDs := make(map[uuid.UUID]struct{}, len(derived)) + for _, subject := range derived { + derivedIDs[subject.DefinedComponentID] = struct{}{} + } + for _, subject := range declared { + if _, ok := derivedIDs[subject.SubjectUUID]; ok { + continue + } + refs = append(refs, relational.EvidenceSubjectReference{ + SubjectUUID: subject.SubjectUUID, + Type: subject.Type, + Title: subject.Title, + Source: relational.EvidenceSubjectSourceDeclared, + Props: []relational.Prop{ + subjectProp(relational.EvidenceSubjectPropSource, relational.EvidenceSubjectSourceDeclared), + }, + }) + } + + // Legacy subjects keep what the plugin sent, marked as legacy. Their subject UUID is the + // one seeded from the plugin's identifier. + for _, subject := range legacy { + props := append([]relational.Prop{}, subject.Props...) + props = append(props, subjectProp(relational.EvidenceSubjectPropSource, relational.EvidenceSubjectSourceLegacy)) + var remarks *string + if subject.Remarks != nil && *subject.Remarks != "" { + remarks = subject.Remarks + } + for _, include := range subject.IncludeSubjects { + refs = append(refs, relational.EvidenceSubjectReference{ + SubjectUUID: include.SubjectUUID, + Type: subject.Type, + Source: relational.EvidenceSubjectSourceLegacy, + Props: props, + Links: append([]relational.Link{}, subject.Links...), + Remarks: remarks, + }) + } + } + + return refs +} + +func subjectProp(name, value string) relational.Prop { + return relational.Prop{Ns: relational.CCFOSCALNamespace, Name: name, Value: value} +} + +// checkSubjectRequirement applies the subject requirement for the evidence's origin: a +// subject that isn't legacy is needed by agent evidence under CCF_EVIDENCE_REQUIRE_SUBJECT +// and by user evidence under CCF_MANUAL_EVIDENCE_REQUIRE_SUBJECT. +func (s *EvidenceService) checkSubjectRequirement(params CreateEvidenceParams) error { + if hasAttributedSubject(params.Evidence.SubjectReferences) { return nil } - return result.DefinedComponentIDs + + switch params.EffectiveOrigin() { + case OriginAgent: + switch s.requireSubjectMode() { + case config.EvidenceRequireSubjectEnforce: + return fmt.Errorf("%w: its labels must match a registered component subject template", ErrSubjectRequired) + case config.EvidenceRequireSubjectWarn: + if s.logger != nil { + s.logger.Warnw("Agent evidence has no subject", "evidence_uuid", params.Evidence.UUID, "title", params.Evidence.Title) + } + } + case OriginUser: + if s.ManualEvidenceRequiresSubject() { + return fmt.Errorf("%w: name one in subjects[].subject-uuid", ErrSubjectRequired) + } + } + return nil +} + +func (s *EvidenceService) requireSubjectMode() config.EvidenceRequireSubjectMode { + if s.cfg == nil || s.cfg.EvidenceSubjects == nil { + return config.EvidenceRequireSubjectOff + } + return s.cfg.EvidenceSubjects.RequireSubject +} + +func hasAttributedSubject(refs []relational.EvidenceSubjectReference) bool { + for _, ref := range refs { + if ref.Source != relational.EvidenceSubjectSourceLegacy { + return true + } + } + return false } func mergeSystemComponents(existing, discovered []relational.SystemComponent) []relational.SystemComponent { diff --git a/internal/service/relational/evidence/service_test.go b/internal/service/relational/evidence/service_test.go index f1c5ec00..c0afba0f 100644 --- a/internal/service/relational/evidence/service_test.go +++ b/internal/service/relational/evidence/service_test.go @@ -27,6 +27,7 @@ func newEvidenceServiceTestDB(t *testing.T) *gorm.DB { require.NoError(t, err) require.NoError(t, db.AutoMigrate( &relational.Evidence{}, + &relational.EvidenceSubjectReference{}, &relational.Labels{}, &relational.BackMatter{}, &relational.BackMatterResource{}, @@ -603,12 +604,18 @@ func TestEvidenceService_GetHistoryPaginated(t *testing.T) { // mockCDResolver is a mock implementation of ComponentDefinitionResolver for testing. type mockCDResolver struct { definedComponentIDs []uuid.UUID + subjects []templates.ResolvedSubject systemComponents []relational.SystemComponent + err error } func (m *mockCDResolver) ResolveOrUpsertComponentDefinition(_ templates.ResolveOrUpsertComponentDefinitionInput) (*templates.ResolveOrUpsertComponentDefinitionResult, error) { + if m.err != nil { + return nil, m.err + } return &templates.ResolveOrUpsertComponentDefinitionResult{ DefinedComponentIDs: m.definedComponentIDs, + Subjects: m.subjects, }, nil } diff --git a/internal/service/relational/evidence/signing.go b/internal/service/relational/evidence/signing.go index 964808d5..01eba169 100644 --- a/internal/service/relational/evidence/signing.go +++ b/internal/service/relational/evidence/signing.go @@ -212,9 +212,11 @@ type canonicalEvidence struct { InventoryItems []oscalTypes_1_1_3.InventoryItem `json:"inventory_items,omitempty"` Components []oscalTypes_1_1_3.SystemComponent `json:"components,omitempty"` Subjects []oscalTypes_1_1_3.AssessmentSubject `json:"subjects,omitempty"` - Status oscalTypes_1_1_3.ObjectiveStatus `json:"status"` - BackMatter *oscalTypes_1_1_3.BackMatter `json:"back_matter,omitempty"` - Labels []canonicalLabel `json:"labels,omitempty"` + // SubjectReferences is omitted when empty, so evidence signed before it existed still verifies. + SubjectReferences []oscalTypes_1_1_3.SubjectReference `json:"subject_references,omitempty"` + Status oscalTypes_1_1_3.ObjectiveStatus `json:"status"` + BackMatter *oscalTypes_1_1_3.BackMatter `json:"back_matter,omitempty"` + Labels []canonicalLabel `json:"labels,omitempty"` } type canonicalLabel struct { @@ -280,6 +282,11 @@ func canonicalizeEvidence(params CreateEvidenceParams) (*canonicalEvidence, erro subjects = append(subjects, *osc) } + subjectReferences := make([]oscalTypes_1_1_3.SubjectReference, 0, len(evidence.SubjectReferences)) + for _, ref := range evidence.SubjectReferences { + subjectReferences = append(subjectReferences, ref.MarshalOscal()) + } + origins := make([]oscalTypes_1_1_3.Origin, 0, len(evidence.Origins)) for _, origin := range evidence.Origins { osc := oscalTypes_1_1_3.Origin(origin) @@ -325,23 +332,24 @@ func canonicalizeEvidence(params CreateEvidenceParams) (*canonicalEvidence, erro status := evidence.Status.Data() canonical := &canonicalEvidence{ - UUID: evidence.UUID.String(), - Title: evidence.Title, - Description: evidence.Description, - Remarks: evidence.Remarks, - Start: formatTime(evidence.Start), - End: formatTime(evidence.End), - Expires: formatTimePtr(evidence.Expires), - Props: sortByJSONValue(derefSlice(relational.ConvertPropsToOscal(evidence.Props))), - Links: sortByJSONValue(derefSlice(relational.ConvertLinksToOscal(evidence.Links))), - Origins: sortByJSONValue(origins), - Activities: sortByJSONValue(activities), - InventoryItems: sortByJSONValue(inventoryItems), - Components: sortByJSONValue(components), - Subjects: sortByJSONValue(subjects), - Status: normalizeObjectiveStatus(status), - BackMatter: backMatter, - Labels: sortByJSONValue(labels), + UUID: evidence.UUID.String(), + Title: evidence.Title, + Description: evidence.Description, + Remarks: evidence.Remarks, + Start: formatTime(evidence.Start), + End: formatTime(evidence.End), + Expires: formatTimePtr(evidence.Expires), + Props: sortByJSONValue(derefSlice(relational.ConvertPropsToOscal(evidence.Props))), + Links: sortByJSONValue(derefSlice(relational.ConvertLinksToOscal(evidence.Links))), + Origins: sortByJSONValue(origins), + Activities: sortByJSONValue(activities), + InventoryItems: sortByJSONValue(inventoryItems), + Components: sortByJSONValue(components), + Subjects: sortByJSONValue(subjects), + SubjectReferences: sortByJSONValue(subjectReferences), + Status: normalizeObjectiveStatus(status), + BackMatter: backMatter, + Labels: sortByJSONValue(labels), } return canonical, nil } diff --git a/internal/service/relational/evidence/subject_references_test.go b/internal/service/relational/evidence/subject_references_test.go new file mode 100644 index 00000000..81a8e87c --- /dev/null +++ b/internal/service/relational/evidence/subject_references_test.go @@ -0,0 +1,339 @@ +package evidence + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/compliance-framework/api/internal" + "github.com/compliance-framework/api/internal/authn" + "github.com/compliance-framework/api/internal/config" + "github.com/compliance-framework/api/internal/service/relational" + "github.com/compliance-framework/api/internal/service/relational/subjects" + "github.com/compliance-framework/api/internal/service/relational/templates" + oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "gorm.io/datatypes" +) + +func ccfProp(name, value string) relational.Prop { + return relational.Prop{Ns: relational.CCFOSCALNamespace, Name: name, Value: value} +} + +func orgSubject() templates.ResolvedSubject { + return templates.ResolvedSubject{ + DefinedComponentID: uuid.New(), + TemplateID: uuid.New(), + TemplateName: "github-organization", + DisplayPriority: 3, + Type: "component", + Title: "GitHub Organization: acme", + Props: []relational.Prop{ccfProp("identity", "acme")}, + Links: []relational.Link{{Href: "https://github.com/acme", Rel: "canonical"}}, + } +} + +func subjectTestParams() CreateEvidenceParams { + now := time.Now().UTC() + return CreateEvidenceParams{ + Evidence: relational.Evidence{ + UUID: uuid.New(), + Title: "All teams use closed visibility", + Start: now.Add(-time.Hour), + End: now.Add(-time.Minute), + Status: datatypes.NewJSONType(oscalTypes_1_1_3.ObjectiveStatus{State: relational.EvidenceStatusSatisfied}), + }, + Labels: []relational.Labels{ + {Name: "_plugin", Value: "github-settings"}, + {Name: "organization", Value: "acme"}, + }, + } +} + +func legacySubject(identifierUUID uuid.UUID) relational.AssessmentSubject { + return relational.AssessmentSubject{ + Type: "Component", + IncludeSubjects: []relational.SelectSubjectById{{SubjectUUID: identifierUUID}}, + Props: datatypes.NewJSONSlice([]relational.Prop{{Name: "repo", Value: "api"}}), + Remarks: internal.Pointer(""), + } +} + +func TestEvidenceService_Create_StoresTemplateDerivedSubjects(t *testing.T) { + db := newEvidenceServiceTestDB(t) + subject := orgSubject() + svc := NewEvidenceService(db, nil, nil, nil, WithComponentDefinitionResolver(&mockCDResolver{ + definedComponentIDs: []uuid.UUID{subject.DefinedComponentID}, + subjects: []templates.ResolvedSubject{subject}, + })) + + created, err := svc.Create(context.Background(), subjectTestParams()) + require.NoError(t, err) + require.Len(t, created.SubjectReferences, 1, "the created evidence carries its subjects") + + stored, err := svc.GetByID(*created.ID) + require.NoError(t, err) + require.Len(t, stored.SubjectReferences, 1) + + ref := stored.SubjectReferences[0] + require.Equal(t, *created.ID, ref.EvidenceID) + require.Equal(t, subject.DefinedComponentID, ref.SubjectUUID) + require.Equal(t, "component", ref.Type) + require.Equal(t, "GitHub Organization: acme", ref.Title) + require.Equal(t, relational.EvidenceSubjectSourceTemplate, ref.Source) + require.Equal(t, subject.TemplateID, *ref.TemplateID) + require.Equal(t, 3, ref.Priority) + require.Nil(t, ref.Group) + require.Equal(t, []relational.Prop{ + ccfProp("subject-source", "template"), + ccfProp("subject-template", "github-organization"), + ccfProp("display-priority", "3"), + }, []relational.Prop(ref.Props)) + require.Equal(t, subject.Links, []relational.Link(ref.Links)) +} + +func TestEvidenceService_Create_StoresLegacySubjectsAsLegacy(t *testing.T) { + db := newEvidenceServiceTestDB(t) + svc := NewEvidenceService(db, nil, nil, nil) + + identifierUUID := uuid.New() + params := subjectTestParams() + params.Subjects = []relational.AssessmentSubject{legacySubject(identifierUUID)} + + created, err := svc.Create(context.Background(), params) + require.NoError(t, err) + + stored, err := svc.GetByID(*created.ID) + require.NoError(t, err) + require.Len(t, stored.Subjects, 1, "the legacy assessment subject is still stored as before") + require.Len(t, stored.SubjectReferences, 1) + + ref := stored.SubjectReferences[0] + require.Equal(t, identifierUUID, ref.SubjectUUID) + require.Equal(t, "Component", ref.Type) + require.Empty(t, ref.Title) + require.Equal(t, relational.EvidenceSubjectSourceLegacy, ref.Source) + require.Nil(t, ref.TemplateID) + require.Nil(t, ref.Remarks) + require.Equal(t, []relational.Prop{ + {Name: "repo", Value: "api"}, + ccfProp("subject-source", "legacy"), + }, []relational.Prop(ref.Props)) +} + +var errResolverUnavailable = errors.New("resolver unavailable") + +func TestEvidenceService_Create_AppliesRequireSubjectToAgentEvidence(t *testing.T) { + userSigner := NewUserSignerContextFromClaims(&authn.UserClaims{}) + agentSigner := NewAgentSignerContext(&authn.AgentClaims{}, nil, nil) + + testCases := []struct { + name string + mode config.EvidenceRequireSubjectMode + signer *SignerContext + origin EvidenceOrigin + resolver *mockCDResolver + legacy bool + wantErr bool + // wantErrIs is the error a rejection wraps; nil means ErrSubjectRequired. + wantErrIs error + wantRefsLen int + }{ + {name: "off accepts unattributed agent evidence", mode: config.EvidenceRequireSubjectOff}, + {name: "warn accepts unattributed agent evidence", mode: config.EvidenceRequireSubjectWarn}, + {name: "enforce rejects unattributed agent evidence", mode: config.EvidenceRequireSubjectEnforce, wantErr: true}, + {name: "enforce rejects agent-signed evidence without a subject", mode: config.EvidenceRequireSubjectEnforce, signer: agentSigner, wantErr: true}, + {name: "enforce does not count legacy subjects", mode: config.EvidenceRequireSubjectEnforce, legacy: true, wantErr: true}, + { + name: "enforce accepts evidence with a template-derived subject", + mode: config.EvidenceRequireSubjectEnforce, + resolver: &mockCDResolver{subjects: []templates.ResolvedSubject{orgSubject()}}, + wantRefsLen: 1, + }, + { + name: "enforce reports a resolver error, not a missing subject", + mode: config.EvidenceRequireSubjectEnforce, + resolver: &mockCDResolver{err: errResolverUnavailable}, + wantErr: true, + wantErrIs: errResolverUnavailable, + }, + { + name: "off saves evidence when the resolver fails", + mode: config.EvidenceRequireSubjectOff, + resolver: &mockCDResolver{err: errResolverUnavailable}, + }, + {name: "enforce does not apply to user evidence", mode: config.EvidenceRequireSubjectEnforce, signer: userSigner}, + {name: "enforce does not apply to workflow evidence", mode: config.EvidenceRequireSubjectEnforce, signer: userSigner, origin: OriginWorkflow}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + db := newEvidenceServiceTestDB(t) + logger, err := zap.NewDevelopment() + require.NoError(t, err) + opts := []EvidenceServiceOption{} + if tc.resolver != nil { + opts = append(opts, WithComponentDefinitionResolver(tc.resolver)) + } + svc := NewEvidenceService(db, logger.Sugar(), &config.Config{ + EvidenceSubjects: &config.EvidenceSubjectConfig{RequireSubject: tc.mode}, + }, nil, opts...) + + params := subjectTestParams() + params.Signer = tc.signer + params.Origin = tc.origin + wantRefsLen := tc.wantRefsLen + if tc.legacy { + params.Subjects = []relational.AssessmentSubject{legacySubject(uuid.New())} + wantRefsLen++ + } + + created, err := svc.Create(context.Background(), params) + + var count int64 + require.NoError(t, db.Model(&relational.Evidence{}).Count(&count).Error) + if tc.wantErr { + wantErrIs := tc.wantErrIs + if wantErrIs == nil { + wantErrIs = ErrSubjectRequired + } + require.ErrorIs(t, err, wantErrIs) + if wantErrIs != ErrSubjectRequired { + require.NotErrorIs(t, err, ErrSubjectRequired, "a resolver failure is not reported as a missing subject") + } + require.Zero(t, count, "rejected evidence is not saved") + return + } + require.NoError(t, err) + require.Equal(t, int64(1), count) + require.Len(t, created.SubjectReferences, wantRefsLen) + }) + } +} + +func TestEvidenceService_Create_SignsSubjectReferences(t *testing.T) { + db := newEvidenceServiceTestDB(t) + logger, err := zap.NewDevelopment() + require.NoError(t, err) + privateKey, publicKey, err := config.GenerateKeyPair(2048) + require.NoError(t, err) + svc := NewEvidenceService(db, logger.Sugar(), &config.Config{ + JWTPrivateKey: privateKey, + JWTPublicKey: publicKey, + }, nil, WithComponentDefinitionResolver(&mockCDResolver{ + subjects: []templates.ResolvedSubject{orgSubject()}, + })) + + signedAt := time.Now().UTC() + created := createSignedEvidenceForVerification(t, svc, subjectTestParams(), signedAt, newVerificationSigner( + "signer@example.com", + signedAt.Add(-time.Hour), + signedAt.Add(-time.Hour), + signedAt.Add(time.Hour), + )) + + result, err := svc.VerifyByID(*created.ID) + require.NoError(t, err) + require.True(t, result.IsValid, result.Errors) + + require.NoError(t, db.Model(&relational.EvidenceSubjectReference{}). + Where("evidence_id = ?", *created.ID). + Update("title", "GitHub Organization: someone-else").Error) + + result, err = svc.VerifyByID(*created.ID) + require.NoError(t, err) + require.False(t, result.IsValid, "changing a subject reference breaks the signature") + require.False(t, result.Checks.HashMatch) +} + +func TestEvidenceService_Create_AppliesManualRequireSubjectToUserEvidence(t *testing.T) { + userSigner := NewUserSignerContextFromClaims(&authn.UserClaims{}) + agentSigner := NewAgentSignerContext(&authn.AgentClaims{}, nil, nil) + + testCases := []struct { + name string + required bool + signer *SignerContext + origin EvidenceOrigin + resolver *mockCDResolver + legacy bool + wantErr bool + }{ + {name: "not required accepts user evidence without a subject", signer: userSigner}, + {name: "required rejects user evidence without a subject", required: true, signer: userSigner, wantErr: true}, + {name: "required does not count legacy subjects", required: true, signer: userSigner, legacy: true, wantErr: true}, + { + name: "required accepts user evidence with a template-derived subject", + required: true, + signer: userSigner, + resolver: &mockCDResolver{subjects: []templates.ResolvedSubject{orgSubject()}}, + }, + {name: "required does not apply to workflow evidence", required: true, signer: userSigner, origin: OriginWorkflow}, + {name: "required does not apply to agent evidence", required: true, signer: agentSigner}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + db := newEvidenceServiceTestDB(t) + opts := []EvidenceServiceOption{} + if tc.resolver != nil { + opts = append(opts, WithComponentDefinitionResolver(tc.resolver)) + } + svc := NewEvidenceService(db, nil, &config.Config{ + EvidenceSubjects: &config.EvidenceSubjectConfig{ManualRequireSubject: tc.required}, + }, nil, opts...) + + params := subjectTestParams() + params.Signer = tc.signer + params.Origin = tc.origin + if tc.legacy { + params.Subjects = []relational.AssessmentSubject{legacySubject(uuid.New())} + } + + _, err := svc.Create(context.Background(), params) + if tc.wantErr { + require.ErrorIs(t, err, ErrSubjectRequired) + require.Contains(t, err.Error(), "subjects[].subject-uuid") + return + } + require.NoError(t, err) + }) + } +} + +func TestBuildSubjectReferences_KeepsADerivedSubjectOnce(t *testing.T) { + derived := orgSubject() + other := subjects.Summary{SubjectUUID: uuid.New(), Type: "party", Kind: subjects.KindParty, Title: "Platform team"} + declared := []subjects.Summary{ + {SubjectUUID: derived.DefinedComponentID, Type: "component", Kind: subjects.KindDefinedComponent, Title: derived.Title}, + other, + } + + refs := buildSubjectReferences([]templates.ResolvedSubject{derived}, declared, nil) + + require.Len(t, refs, 2) + require.Equal(t, derived.DefinedComponentID, refs[0].SubjectUUID) + require.Equal(t, relational.EvidenceSubjectSourceTemplate, refs[0].Source, "a subject both derived and declared stays derived") + require.Equal(t, other.SubjectUUID, refs[1].SubjectUUID) + require.Equal(t, relational.EvidenceSubjectSourceDeclared, refs[1].Source) +} + +func TestEvidenceService_Create_RejectsTooManyDeclaredSubjects(t *testing.T) { + db := newEvidenceServiceTestDB(t) + svc := NewEvidenceService(db, nil, nil, nil) + + params := subjectTestParams() + for range subjects.MaxLookupIDs + 1 { + params.DeclaredSubjectUUIDs = append(params.DeclaredSubjectUUIDs, uuid.New()) + } + + _, err := svc.Create(context.Background(), params) + + require.ErrorIs(t, err, ErrTooManySubjects) + var count int64 + require.NoError(t, db.Model(&relational.Evidence{}).Count(&count).Error) + require.Zero(t, count) +} diff --git a/internal/service/relational/evidence_subject_reference.go b/internal/service/relational/evidence_subject_reference.go new file mode 100644 index 00000000..51ff735b --- /dev/null +++ b/internal/service/relational/evidence_subject_reference.go @@ -0,0 +1,107 @@ +package relational + +import ( + "sort" + + oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" + "github.com/google/uuid" + "gorm.io/datatypes" +) + +// Where an evidence subject came from (ccf:subject-source). +const ( + // EvidenceSubjectSourceTemplate is a subject derived from a plugin's component subject + // template matching the evidence labels. + EvidenceSubjectSourceTemplate = "template" + // EvidenceSubjectSourceDeclared is a subject named explicitly by manual or workflow evidence. + EvidenceSubjectSourceDeclared = "declared" + // EvidenceSubjectSourceLegacy is an identifier subject sent by a plugin. It doesn't count + // towards the evidence being attributed. + EvidenceSubjectSourceLegacy = "legacy" +) + +// Prop names (in CCFOSCALNamespace) on an evidence subject reference. +const ( + EvidenceSubjectPropSource = "subject-source" + EvidenceSubjectPropTemplate = "subject-template" + EvidenceSubjectPropDisplayPriority = "display-priority" +) + +// EvidenceSubjectReference is one subject of a piece of evidence: the resource the evidence +// is about. Each row maps 1:1 to an OSCAL observation subject-reference. +type EvidenceSubjectReference struct { + UUIDModel + // EvidenceID is indexed on its own so an evidence's subjects load without the + // (subject_uuid, evidence_id) index, which serves lookups by subject. + EvidenceID uuid.UUID `json:"evidenceId" gorm:"type:uuid;not null;index;index:idx_evidence_subject_references_subject,priority:2"` + SubjectUUID uuid.UUID `json:"subjectUuid" gorm:"type:uuid;not null;index:idx_evidence_subject_references_subject,priority:1"` + // Type is the OSCAL subject type, e.g. component. + Type string `json:"type" gorm:"type:text;not null"` + Title string `json:"title" gorm:"type:text"` + // Source is one of the EvidenceSubjectSource* values. + Source string `json:"source" gorm:"type:text;not null"` + // TemplateID is the subject template a template-derived subject came from. + TemplateID *uuid.UUID `json:"templateId,omitempty" gorm:"type:uuid"` + // Priority is the template's display priority: higher subjects are shown first. + Priority int `json:"priority" gorm:"not null;default:0"` + Group *string `json:"group,omitempty" gorm:"column:group;type:text"` + + Props datatypes.JSONSlice[Prop] `json:"props"` + Links datatypes.JSONSlice[Link] `json:"links"` + Remarks *string `json:"remarks,omitempty"` +} + +func (r EvidenceSubjectReference) MarshalOscal() oscalTypes_1_1_3.SubjectReference { + ref := oscalTypes_1_1_3.SubjectReference{ + SubjectUuid: r.SubjectUUID.String(), + Type: r.Type, + Title: r.Title, + } + if len(r.Props) > 0 { + ref.Props = ConvertPropsToOscal(r.Props) + } + if len(r.Links) > 0 { + ref.Links = ConvertLinksToOscal(r.Links) + } + if r.Remarks != nil { + ref.Remarks = *r.Remarks + } + return ref +} + +// templateName is the name of the subject template a template-derived subject came from, as +// recorded on it (ccf:subject-template), or "" for other subjects. +func (r EvidenceSubjectReference) templateName() string { + for _, prop := range r.Props { + if prop.Ns == CCFOSCALNamespace && prop.Name == EvidenceSubjectPropTemplate { + return prop.Value + } + } + return "" +} + +// SortEvidenceSubjectReferencesForDisplay returns the subjects in display order: higher +// display priority first, then alphabetically by template name. Subjects without a template +// follow, and title then UUID break any remaining ties so the order is stable. The order is +// only for rendering; it never changes which subjects are attached. +func SortEvidenceSubjectReferencesForDisplay(refs []EvidenceSubjectReference) []EvidenceSubjectReference { + sorted := append([]EvidenceSubjectReference(nil), refs...) + sort.SliceStable(sorted, func(i, j int) bool { + a, b := sorted[i], sorted[j] + if a.Priority != b.Priority { + return a.Priority > b.Priority + } + aName, bName := a.templateName(), b.templateName() + if aName != bName { + if aName == "" || bName == "" { + return bName == "" + } + return aName < bName + } + if a.Title != b.Title { + return a.Title < b.Title + } + return a.SubjectUUID.String() < b.SubjectUUID.String() + }) + return sorted +} diff --git a/internal/service/relational/evidence_subject_reference_test.go b/internal/service/relational/evidence_subject_reference_test.go new file mode 100644 index 00000000..caa79641 --- /dev/null +++ b/internal/service/relational/evidence_subject_reference_test.go @@ -0,0 +1,38 @@ +package relational + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func templateRef(title, template string, priority int) EvidenceSubjectReference { + return EvidenceSubjectReference{ + SubjectUUID: uuid.New(), + Title: title, + Source: EvidenceSubjectSourceTemplate, + Priority: priority, + Props: []Prop{{Ns: CCFOSCALNamespace, Name: EvidenceSubjectPropTemplate, Value: template}}, + } +} + +func TestSortEvidenceSubjectReferencesForDisplay(t *testing.T) { + legacy := EvidenceSubjectReference{SubjectUUID: uuid.New(), Source: EvidenceSubjectSourceLegacy} + refs := []EvidenceSubjectReference{ + legacy, + templateRef("Org acme", "organization", 0), + templateRef("Branch main", "branch", 0), + templateRef("Repo api", "repository", 10), + } + + sorted := SortEvidenceSubjectReferencesForDisplay(refs) + + titles := make([]string, 0, len(sorted)) + for _, ref := range sorted { + titles = append(titles, ref.Title) + } + require.Equal(t, []string{"Repo api", "Branch main", "Org acme", ""}, titles, + "higher priority first, then template name, then subjects without a template") + require.Equal(t, legacy.SubjectUUID, refs[0].SubjectUUID, "the input is not reordered") +} diff --git a/internal/service/relational/subjects/service.go b/internal/service/relational/subjects/service.go new file mode 100644 index 00000000..82bb3ca2 --- /dev/null +++ b/internal/service/relational/subjects/service.go @@ -0,0 +1,272 @@ +// Package subjects lists the entities evidence can name as its subject: defined components, +// SSP system components, parties and users. +package subjects + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// Kind is the kind of entity a subject is. +type Kind string + +const ( + KindDefinedComponent Kind = "defined-component" + KindSystemComponent Kind = "system-component" + KindParty Kind = "party" + KindUser Kind = "user" +) + +// MaxLookupIDs is the most subjects one lookup by ID may name: one page at the largest page +// size. +const MaxLookupIDs = 100 + +// AllKinds lists every subject kind, in the order they're searched. +var AllKinds = []Kind{KindDefinedComponent, KindSystemComponent, KindParty, KindUser} + +// ParseKind returns the Kind named by value, if there is one. +func ParseKind(value string) (Kind, bool) { + for _, kind := range AllKinds { + if string(kind) == strings.ToLower(strings.TrimSpace(value)) { + return kind, true + } + } + return "", false +} + +// Summary is an entity evidence can name as its subject. +type Summary struct { + SubjectUUID uuid.UUID `json:"subject-uuid" gorm:"column:subject_uuid"` + // Type is the OSCAL subject type: component, party or user. + Type string `json:"type" gorm:"column:type"` + Kind Kind `json:"kind" gorm:"column:kind"` + Title string `json:"title" gorm:"column:title"` + // Context is what the subject belongs to: a defined component's component definition, or + // a system component's SSP. + Context *string `json:"context,omitempty" gorm:"column:context"` + // Identity is a defined component's identity labels: the evidence labels that identify it. + Identity []IdentityLabel `json:"identity,omitempty" gorm:"-"` + // LinkedSSPs are the SSP system components a defined component is linked to. + LinkedSSPs []LinkedSSP `json:"linked-ssps,omitempty" gorm:"-"` +} + +// IdentityLabel is one identity label of a defined component. +type IdentityLabel struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// LinkedSSP is an SSP system component linked to a defined component. +type LinkedSSP struct { + SSPID uuid.UUID `json:"ssp-id"` + SSPTitle string `json:"ssp-title"` + ComponentID uuid.UUID `json:"component-id"` + ComponentTitle string `json:"component-title"` +} + +type ListParams struct { + // Search matches titles case-insensitively. + Search string + // Kinds limits the kinds listed; empty means all. + Kinds []Kind + // SSPID limits system components to one SSP; other kinds are unaffected. + SSPID *uuid.UUID + // IDs limits the list to these subjects. + IDs []uuid.UUID + Limit int + Offset int +} + +type Service struct { + db *gorm.DB +} + +func NewService(db *gorm.DB) *Service { + return &Service{db: db} +} + +// List returns one page of subjects ordered by title, and the total matching. +func (s *Service) List(ctx context.Context, params ListParams) ([]Summary, int64, error) { + db := s.db.WithContext(ctx) + union, args := subjectsUnion(params) + var conditions []string + if search := strings.TrimSpace(params.Search); search != "" { + conditions = append(conditions, `LOWER(subjects.title) LIKE ? ESCAPE '\'`) + args = append(args, "%"+escapeLikePattern(strings.ToLower(search))+"%") + } + if len(params.IDs) > 0 { + conditions = append(conditions, "subjects.subject_uuid IN ?") + args = append(args, params.IDs) + } + where := "" + if len(conditions) > 0 { + where = " WHERE " + strings.Join(conditions, " AND ") + } + from := "FROM (" + union + ") subjects" + where + + var total int64 + if err := db.Raw("SELECT COUNT(*) "+from, args...).Scan(&total).Error; err != nil { + return nil, 0, err + } + + items := []Summary{} + pageArgs := append(append([]any{}, args...), params.Limit, params.Offset) + if err := db.Raw( + "SELECT subjects.subject_uuid, subjects.type, subjects.kind, subjects.title, subjects.context "+ + from+ + " ORDER BY LOWER(subjects.title), subjects.kind, subjects.subject_uuid LIMIT ? OFFSET ?", + pageArgs..., + ).Scan(&items).Error; err != nil { + return nil, 0, err + } + + if err := addDefinedComponentDetails(db, items); err != nil { + return nil, 0, err + } + + return items, total, nil +} + +// addDefinedComponentDetails fills in the identity labels and linked SSP system components +// of the defined components among items, with one query each. +func addDefinedComponentDetails(db *gorm.DB, items []Summary) error { + byID := map[uuid.UUID]*Summary{} + ids := []uuid.UUID{} + for i := range items { + if items[i].Kind == KindDefinedComponent { + byID[items[i].SubjectUUID] = &items[i] + ids = append(ids, items[i].SubjectUUID) + } + } + if len(ids) == 0 { + return nil + } + + var labels []struct { + DefinedComponentID uuid.UUID `gorm:"column:defined_component_id"` + Key string `gorm:"column:key"` + Value string `gorm:"column:value"` + } + if err := db.Raw(` + SELECT defined_component_id, key, value + FROM component_definition_labels + WHERE defined_component_id IN ? + ORDER BY defined_component_id, key, value`, ids, + ).Scan(&labels).Error; err != nil { + return err + } + for _, label := range labels { + item := byID[label.DefinedComponentID] + item.Identity = append(item.Identity, IdentityLabel{Key: label.Key, Value: label.Value}) + } + + var links []struct { + DefinedComponentID uuid.UUID `gorm:"column:defined_component_id"` + SSPID uuid.UUID `gorm:"column:ssp_id"` + SSPTitle string `gorm:"column:ssp_title"` + ComponentID uuid.UUID `gorm:"column:component_id"` + ComponentTitle string `gorm:"column:component_title"` + } + if err := db.Raw(` + SELECT sc.defined_component_id, si.system_security_plan_id AS ssp_id, COALESCE(m.title, '') AS ssp_title, + sc.id AS component_id, sc.title AS component_title + FROM system_components sc + JOIN system_implementations si ON si.id = sc.system_implementation_id + LEFT JOIN metadata m + ON m.parent_type = 'system_security_plans' AND m.parent_id = si.system_security_plan_id + WHERE sc.defined_component_id IN ? + ORDER BY LOWER(COALESCE(m.title, '')), LOWER(sc.title), sc.id`, ids, + ).Scan(&links).Error; err != nil { + return err + } + for _, link := range links { + item := byID[link.DefinedComponentID] + item.LinkedSSPs = append(item.LinkedSSPs, LinkedSSP{ + SSPID: link.SSPID, + SSPTitle: link.SSPTitle, + ComponentID: link.ComponentID, + ComponentTitle: link.ComponentTitle, + }) + } + + return nil +} + +// Resolve looks up subjects by ID, returning those found keyed by ID. +func (s *Service) Resolve(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]Summary, error) { + found := make(map[uuid.UUID]Summary, len(ids)) + if len(ids) == 0 { + return found, nil + } + items, _, err := s.List(ctx, ListParams{IDs: ids, Limit: len(ids) * len(AllKinds)}) + if err != nil { + return nil, err + } + for _, item := range items { + found[item.SubjectUUID] = item + } + return found, nil +} + +// subjectsUnion is one SELECT per requested kind, combined with UNION ALL. +func subjectsUnion(params ListParams) (string, []any) { + kinds := params.Kinds + if len(kinds) == 0 { + kinds = AllKinds + } + + selects := make([]string, 0, len(kinds)) + var args []any + for _, kind := range kinds { + switch kind { + case KindDefinedComponent: + selects = append(selects, fmt.Sprintf(` + SELECT dc.id AS subject_uuid, 'component' AS type, '%s' AS kind, dc.title AS title, m.title AS context + FROM defined_components dc + LEFT JOIN metadata m + ON m.parent_type = 'component_definitions' AND m.parent_id = dc.component_definition_id`, + KindDefinedComponent)) + case KindSystemComponent: + // The inner join keeps to SSP system components: components created from + // evidence have no system implementation. + sspFilter := "" + if params.SSPID != nil { + sspFilter = " WHERE si.system_security_plan_id = ?" + args = append(args, *params.SSPID) + } + selects = append(selects, fmt.Sprintf(` + SELECT sc.id AS subject_uuid, 'component' AS type, '%s' AS kind, sc.title AS title, m.title AS context + FROM system_components sc + JOIN system_implementations si ON si.id = sc.system_implementation_id + LEFT JOIN metadata m + ON m.parent_type = 'system_security_plans' AND m.parent_id = si.system_security_plan_id%s`, + KindSystemComponent, sspFilter)) + case KindParty: + selects = append(selects, fmt.Sprintf(` + SELECT p.id AS subject_uuid, 'party' AS type, '%s' AS kind, + COALESCE(NULLIF(p.name, ''), NULLIF(p.short_name, ''), CAST(p.id AS TEXT)) AS title, + NULL AS context + FROM parties p`, + KindParty)) + case KindUser: + // The same users, and the same display name, as /users/select. + selects = append(selects, fmt.Sprintf(` + SELECT u.id AS subject_uuid, 'user' AS type, '%s' AS kind, + COALESCE(NULLIF(TRIM(TRIM(COALESCE(u.first_name, '')) || ' ' || TRIM(COALESCE(u.last_name, ''))), ''), CAST(u.id AS TEXT)) AS title, + NULL AS context + FROM ccf_users u + WHERE u.deleted_at IS NULL AND u.is_active = TRUE AND u.is_locked = FALSE`, + KindUser)) + } + } + + return strings.Join(selects, " UNION ALL "), args +} + +func escapeLikePattern(value string) string { + return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(value) +} diff --git a/internal/service/relational/templates/models.go b/internal/service/relational/templates/models.go index 8b6b39d0..90b38a2a 100644 --- a/internal/service/relational/templates/models.go +++ b/internal/service/relational/templates/models.go @@ -111,6 +111,13 @@ type SubjectTemplate struct { SourceMode string `json:"sourceMode" gorm:"type:text;not null;index"` + // DisplayPriority orders the subjects this template produces on evidence: higher + // first, ties by template name. + DisplayPriority int `json:"displayPriority" gorm:"not null;default:0"` + // ComponentType is the OSCAL component type written to the DefinedComponents this + // template materialises. Nil means the default (service). + ComponentType *string `json:"componentType" gorm:"type:text"` + SelectorLabels []SubjectTemplateSelectorLabel `json:"selectorLabels,omitempty" gorm:"foreignKey:SubjectTemplateID;constraint:OnDelete:CASCADE"` LabelSchema []SubjectTemplateLabelSchemaField `json:"labelSchema,omitempty" gorm:"foreignKey:SubjectTemplateID;constraint:OnDelete:CASCADE"` } @@ -143,10 +150,15 @@ func (SubjectTemplateLabelSchemaField) TableName() string { return "subject_template_label_schema_fields" } +// ComponentDefinitionIdentity maps a template identity to the DefinedComponent it +// materialised. It is keyed per component definition (one per plugin), so two plugins +// reporting the same identity labels each get their own DefinedComponent. +// Existing databases are moved to this key by the raw SQL in service.MigrateUpWithConfig, +// because AutoMigrate never changes an existing primary key. type ComponentDefinitionIdentity struct { EntityType string `json:"entityType" gorm:"column:entity_type;type:text;primaryKey"` + ComponentDefinitionID uuid.UUID `json:"componentDefinitionId" gorm:"column:component_definition_id;type:uuid;primaryKey;index"` IdentityHash string `json:"identityHash" gorm:"column:identity_hash;type:char(64);primaryKey"` - ComponentDefinitionID uuid.UUID `json:"componentDefinitionId" gorm:"column:component_definition_id;type:uuid;not null;index"` DefinedComponentID uuid.UUID `json:"definedComponentId" gorm:"column:defined_component_id;type:uuid;not null;index"` } diff --git a/internal/service/relational/templates/subject_template_resolver_integration_test.go b/internal/service/relational/templates/subject_template_resolver_integration_test.go new file mode 100644 index 00000000..c0b2eba3 --- /dev/null +++ b/internal/service/relational/templates/subject_template_resolver_integration_test.go @@ -0,0 +1,100 @@ +//go:build integration + +package templates_test + +import ( + "testing" + + "github.com/compliance-framework/api/internal/service/relational" + templaterel "github.com/compliance-framework/api/internal/service/relational/templates" + "github.com/compliance-framework/api/internal/tests" + "github.com/stretchr/testify/suite" +) + +type SubjectTemplateResolverIntegrationSuite struct { + tests.IntegrationTestSuite +} + +func TestSubjectTemplateResolverIntegration(t *testing.T) { + suite.Run(t, new(SubjectTemplateResolverIntegrationSuite)) +} + +func (s *SubjectTemplateResolverIntegrationSuite) SetupTest() { + s.Require().NoError(s.Migrator.Refresh()) +} + +func orgTemplate(plugin string) templaterel.SubjectTemplatePayload { + title := "GitHub Organization: {{ .organization }}" + return templaterel.SubjectTemplatePayload{ + Name: "GitHub Organization", + Type: "component", + SourceMode: "runtime-derived", + TitleTemplate: &title, + IdentityLabelKeys: []string{"organization"}, + Links: []relational.Link{{Href: "https://github.com/{{ .organization }}", Rel: "canonical"}}, + SelectorLabels: []templaterel.SubjectTemplateSelectorLabelInput{{Key: "_plugin", Value: plugin}}, + LabelSchema: []templaterel.SubjectTemplateLabelSchemaFieldInput{ + {Key: "_plugin"}, + {Key: "organization"}, + {Key: "env"}, + }, + } +} + +func (s *SubjectTemplateResolverIntegrationSuite) TestUpsertsDefinedComponentPerPlugin() { + svc := templaterel.NewSubjectTemplateService(s.DB) + + payload := orgTemplate("github") + template, err := svc.Create(payload) + s.Require().NoError(err) + _, err = svc.Create(orgTemplate("github-enterprise")) + s.Require().NoError(err) + + labels := []relational.Labels{ + {Name: "_plugin", Value: "github"}, + {Name: "organization", Value: "acme"}, + {Name: "env", Value: "prod"}, + } + first, err := svc.ResolveOrUpsertComponentDefinition(templaterel.ResolveOrUpsertComponentDefinitionInput{EvidenceLabels: labels}) + s.Require().NoError(err) + s.Require().Len(first.Subjects, 1) + s.Equal("GitHub Organization: acme", first.Subjects[0].Title) + + // Resolving again takes the fast path and leaves the row as it is. + again, err := svc.ResolveOrUpsertComponentDefinition(templaterel.ResolveOrUpsertComponentDefinitionInput{EvidenceLabels: labels}) + s.Require().NoError(err) + s.Equal(first.DefinedComponentIDs, again.DefinedComponentIDs) + + // Template changes reach the existing DefinedComponent. + software := "software" + payload.ComponentType = &software + _, err = svc.Update(*template.ID, payload) + s.Require().NoError(err) + updated, err := svc.ResolveOrUpsertComponentDefinition(templaterel.ResolveOrUpsertComponentDefinitionInput{EvidenceLabels: labels}) + s.Require().NoError(err) + s.Equal(first.DefinedComponentIDs, updated.DefinedComponentIDs) + + var dc relational.DefinedComponent + s.Require().NoError(s.DB.First(&dc, "id = ?", first.DefinedComponentIDs[0]).Error) + s.Equal("software", dc.Type) + s.Equal("GitHub Organization: acme", dc.Title) + s.Require().Len(dc.Links, 1) + s.Equal("https://github.com/acme", dc.Links[0].Href) + s.Contains([]relational.Prop(dc.Props), relational.Prop{Ns: relational.CCFOSCALNamespace, Name: "identity", Class: "organization", Value: "acme"}) + s.Contains([]relational.Prop(dc.Props), relational.Prop{Ns: relational.CCFOSCALNamespace, Name: "label", Class: "env", Value: "prod"}) + + // The same identity from another plugin gets its own DefinedComponent. + other, err := svc.ResolveOrUpsertComponentDefinition(templaterel.ResolveOrUpsertComponentDefinitionInput{ + EvidenceLabels: []relational.Labels{ + {Name: "_plugin", Value: "github-enterprise"}, + {Name: "organization", Value: "acme"}, + }, + }) + s.Require().NoError(err) + s.Require().Len(other.DefinedComponentIDs, 1) + s.NotEqual(first.DefinedComponentIDs[0], other.DefinedComponentIDs[0]) + + var identities int64 + s.Require().NoError(s.DB.Model(&templaterel.ComponentDefinitionIdentity{}).Count(&identities).Error) + s.Equal(int64(2), identities) +} diff --git a/internal/service/relational/templates/subject_template_service.go b/internal/service/relational/templates/subject_template_service.go index 527f6937..5af84338 100644 --- a/internal/service/relational/templates/subject_template_service.go +++ b/internal/service/relational/templates/subject_template_service.go @@ -47,6 +47,26 @@ var allowedSubjectTemplateTypes = map[string]struct{}{ subjectTemplateTypeResource: {}, } +// defaultDefinedComponentType is written to DefinedComponents whose template sets no +// component type. +const defaultDefinedComponentType = "service" + +// allowedDefinedComponentTypes are the OSCAL component types valid on a component +// definition's defined-component (this-system only applies to SSP system components). +var allowedDefinedComponentTypes = map[string]struct{}{ + "interconnection": {}, + "software": {}, + "hardware": {}, + "service": {}, + "policy": {}, + "physical": {}, + "process-procedure": {}, + "plan": {}, + "guidance": {}, + "standard": {}, + "validation": {}, +} + var allowedSubjectTemplateSourceModes = map[string]struct{}{ subjectTemplateSourceModePolicyDerived: {}, subjectTemplateSourceModeRuntimeDerived: {}, @@ -112,6 +132,8 @@ type SubjectTemplatePayload struct { Props []relational.Prop Links []relational.Link SourceMode string + DisplayPriority int + ComponentType *string SelectorLabels []SubjectTemplateSelectorLabelInput LabelSchema []SubjectTemplateLabelSchemaFieldInput } @@ -138,6 +160,22 @@ type ResolveOrUpsertComponentDefinitionInput struct { type ResolveOrUpsertComponentDefinitionResult struct { DefinedComponentIDs []uuid.UUID + // Subjects are the evidence's template-derived subjects, one per DefinedComponent. + Subjects []ResolvedSubject +} + +// ResolvedSubject is a subject derived for a piece of evidence from a matching component +// template: the DefinedComponent it resolved to, and the template that produced it. +type ResolvedSubject struct { + DefinedComponentID uuid.UUID + TemplateID uuid.UUID + TemplateName string + DisplayPriority int + // Type is the OSCAL subject type; template-derived subjects are always components. + Type string + Title string + Props []relational.Prop + Links []relational.Link } type identityLabelPair struct { @@ -236,6 +274,8 @@ func (s *SubjectTemplateService) Create(payload SubjectTemplatePayload) (*Subjec Props: datatypes.NewJSONSlice(payload.Props), Links: datatypes.NewJSONSlice(payload.Links), SourceMode: payload.SourceMode, + DisplayPriority: payload.DisplayPriority, + ComponentType: payload.ComponentType, } if err := tx.Select( @@ -252,6 +292,8 @@ func (s *SubjectTemplateService) Create(payload SubjectTemplatePayload) (*Subjec "Props", "Links", "SourceMode", + "DisplayPriority", + "ComponentType", ).Create(&row).Error; err != nil { tx.Rollback() return nil, err @@ -306,6 +348,8 @@ func (s *SubjectTemplateService) Update(id uuid.UUID, payload SubjectTemplatePay existing.Props = datatypes.NewJSONSlice(payload.Props) existing.Links = datatypes.NewJSONSlice(payload.Links) existing.SourceMode = payload.SourceMode + existing.DisplayPriority = payload.DisplayPriority + existing.ComponentType = payload.ComponentType if err := tx.Omit("SelectorLabels", "LabelSchema").Save(&existing).Error; err != nil { tx.Rollback() @@ -732,6 +776,16 @@ func (row systemComponentRow) toSystemComponent() *relational.SystemComponent { // deterministic (v5-style) ComponentDefinition IDs seeded from identity hashes. var componentDefinitionNamespace = uuid.MustParse("a4e3c2d1-b0f9-4e8a-9c7d-6f5e4d3c2b1a") +// Prop names (in relational.CCFOSCALNamespace) written to DefinedComponents materialised +// from subject templates. The prop class carries the label key. +const ( + definedComponentIdentityPropName = "identity" + definedComponentLabelPropName = "label" +) + +// ResolveOrUpsertComponentDefinition materialises a DefinedComponent in the plugin's +// ComponentDefinition for every runtime-derived component template that matches the +// evidence labels, and returns them as the evidence's derived subjects. func (s *SubjectTemplateService) ResolveOrUpsertComponentDefinition(input ResolveOrUpsertComponentDefinitionInput) (*ResolveOrUpsertComponentDefinitionResult, error) { if len(input.EvidenceLabels) == 0 { return &ResolveOrUpsertComponentDefinitionResult{}, nil @@ -766,6 +820,11 @@ func (s *SubjectTemplateService) ResolveOrUpsertComponentDefinition(input Resolv ) } + // The ComponentDefinition groups every DefinedComponent a plugin produces, and scopes + // identity lookups so plugins never share DefinedComponents. + normalizedPlugin := strings.ToLower(strings.TrimSpace(pluginValue)) + cdID := componentDefinitionIDForPlugin(normalizedPlugin) + result := &ResolveOrUpsertComponentDefinitionResult{} seen := make(map[uuid.UUID]struct{}) @@ -796,83 +855,199 @@ func (s *SubjectTemplateService) ResolveOrUpsertComponentDefinition(input Resolv } identityHash := buildEntityIdentityHash(template.Type, identityPairs) + // An earlier template already resolved this identity and defines its + // DefinedComponent, so don't overwrite it with this template's rendering. + if _, exists := seen[uuid.NewSHA1(cdID, []byte(identityHash))]; exists { + continue + } - definedComponentID, err := s.resolveOrCreateComponentDefinition(template, pluginValue, identityPairs, schemaLabelPairs, identityHash) + rendered, err := renderDefinedComponent(template, identityPairs, schemaLabelPairs) if err != nil { return nil, err } - if definedComponentID == nil { - continue + + definedComponentID, err := s.upsertDefinedComponent(template, normalizedPlugin, cdID, identityPairs, identityHash, rendered) + if err != nil { + return nil, err } - if _, exists := seen[*definedComponentID]; exists { + if _, exists := seen[definedComponentID]; exists { continue } - seen[*definedComponentID] = struct{}{} - result.DefinedComponentIDs = append(result.DefinedComponentIDs, *definedComponentID) + seen[definedComponentID] = struct{}{} + result.DefinedComponentIDs = append(result.DefinedComponentIDs, definedComponentID) + result.Subjects = append(result.Subjects, ResolvedSubject{ + DefinedComponentID: definedComponentID, + TemplateID: *template.ID, + TemplateName: template.Name, + DisplayPriority: template.DisplayPriority, + Type: subjectTemplateTypeComponent, + Title: rendered.Title, + Props: rendered.Props, + Links: rendered.Links, + }) } return result, nil } -func (s *SubjectTemplateService) resolveOrCreateComponentDefinition(template SubjectTemplate, pluginValue string, identityPairs []identityLabelPair, schemaLabels []identityLabelPair, identityHash string) (*uuid.UUID, error) { - // Check if identity already exists. - var existingIdentity ComponentDefinitionIdentity - if err := s.db.Where("entity_type = ? AND identity_hash = ?", subjectTemplateTypeComponent, identityHash).First(&existingIdentity).Error; err == nil { - return &existingIdentity.DefinedComponentID, nil - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } +// componentDefinitionIDForPlugin is the deterministic ID of the ComponentDefinition that +// holds a plugin's template-derived DefinedComponents. +func componentDefinitionIDForPlugin(normalizedPlugin string) uuid.UUID { + return uuid.NewSHA1(componentDefinitionNamespace, []byte("plugin:"+normalizedPlugin)) +} - // Build label map for template rendering - labelMap := make(map[string]string) +// renderedDefinedComponent is a template rendered against one piece of evidence's labels: +// the values its DefinedComponent should hold. +type renderedDefinedComponent struct { + Type string + Title string + Description string + Purpose string + Remarks string + Props []relational.Prop + Links []relational.Link +} + +func renderDefinedComponent(template SubjectTemplate, identityPairs []identityLabelPair, schemaLabels []identityLabelPair) (renderedDefinedComponent, error) { + // Text templates may only reference label-schema keys (validateSubjectTemplatePayload), + // and identity keys are always in the schema, so the schema labels are the render set. + labelMap := make(map[string]string, len(schemaLabels)) for _, pair := range schemaLabels { labelMap[pair.Key] = pair.Value } - // Render template fields - title := template.Name + out := renderedDefinedComponent{ + Type: definedComponentTypeOrDefault(template.ComponentType), + Title: template.Name, + } + if template.TitleTemplate != nil { rendered, err := renderTemplate(*template.TitleTemplate, labelMap) if err != nil { - return nil, fmt.Errorf("failed to render title template: %w", err) + return out, fmt.Errorf("failed to render title template: %w", err) } if rendered != "" { - title = rendered + out.Title = rendered } } - description := "" - if template.DescriptionTemplate != nil { - rendered, err := renderTemplate(*template.DescriptionTemplate, labelMap) - if err != nil { - return nil, fmt.Errorf("failed to render description template: %w", err) - } - description = rendered + var err error + if out.Description, err = renderOptionalTemplate(template.DescriptionTemplate, labelMap); err != nil { + return out, fmt.Errorf("failed to render description template: %w", err) + } + if out.Purpose, err = renderOptionalTemplate(template.PurposeTemplate, labelMap); err != nil { + return out, fmt.Errorf("failed to render purpose template: %w", err) + } + if out.Remarks, err = renderOptionalTemplate(template.RemarksTemplate, labelMap); err != nil { + return out, fmt.Errorf("failed to render remarks template: %w", err) } - purpose := "" - if template.PurposeTemplate != nil { - rendered, err := renderTemplate(*template.PurposeTemplate, labelMap) + // Link hrefs are templates too; a static href renders to itself. + out.Links = make([]relational.Link, 0, len(template.Links)) + for i, link := range template.Links { + href, err := renderTemplate(link.Href, labelMap) if err != nil { - return nil, fmt.Errorf("failed to render purpose template: %w", err) + return out, fmt.Errorf("failed to render links[%d].href: %w", i, err) } - purpose = rendered + link.Href = href + out.Links = append(out.Links, link) } - remarks := "" - if template.RemarksTemplate != nil { - rendered, err := renderTemplate(*template.RemarksTemplate, labelMap) - if err != nil { - return nil, fmt.Errorf("failed to render remarks template: %w", err) + // Props: the template's props, one identity prop per identity label, and the schema + // labels the evidence carries. + out.Props = make([]relational.Prop, 0, len(template.Props)+len(identityPairs)+len(schemaLabels)) + out.Props = append(out.Props, template.Props...) + for _, pair := range identityPairs { + out.Props = append(out.Props, relational.Prop{ + Ns: relational.CCFOSCALNamespace, + Name: definedComponentIdentityPropName, + Class: pair.Key, + Value: pair.Value, + }) + } + for _, pair := range schemaLabels { + out.Props = append(out.Props, relational.Prop{ + Ns: relational.CCFOSCALNamespace, + Name: definedComponentLabelPropName, + Class: pair.Key, + Value: pair.Value, + }) + } + + return out, nil +} + +func renderOptionalTemplate(tmpl *string, labels map[string]string) (string, error) { + if tmpl == nil { + return "", nil + } + return renderTemplate(*tmpl, labels) +} + +func definedComponentTypeOrDefault(componentType *string) string { + if componentType == nil || *componentType == "" { + return defaultDefinedComponentType + } + return *componentType +} + +// definedComponentMatchesRendered reports whether a stored DefinedComponent already holds the +// rendered values. Empty and missing props or links count as equal. +func definedComponentMatchesRendered(current relational.DefinedComponent, rendered renderedDefinedComponent) bool { + return current.Type == rendered.Type && + current.Title == rendered.Title && + current.Description == rendered.Description && + current.Purpose == rendered.Purpose && + current.Remarks == rendered.Remarks && + sameJSONList([]relational.Prop(current.Props), rendered.Props) && + sameJSONList([]relational.Link(current.Links), rendered.Links) +} + +func sameJSONList[T any](a, b []T) bool { + if len(a) == 0 || len(b) == 0 { + return len(a) == len(b) + } + aJSON, aErr := json.Marshal(a) + bJSON, bErr := json.Marshal(b) + return aErr == nil && bErr == nil && string(aJSON) == string(bJSON) +} + +// upsertDefinedComponent makes the plugin's DefinedComponent for an identity hold the +// rendered values, creating it (and its identity record) on first sight, and returns its ID. +func (s *SubjectTemplateService) upsertDefinedComponent(template SubjectTemplate, normalizedPlugin string, cdID uuid.UUID, identityPairs []identityLabelPair, identityHash string, rendered renderedDefinedComponent) (uuid.UUID, error) { + // The identity is already materialised for this plugin: update its DefinedComponent so + // it tracks template and label changes. Most evidence renders it unchanged, so skip the + // write then. + var existingIdentity ComponentDefinitionIdentity + if err := s.db.Where("entity_type = ? AND component_definition_id = ? AND identity_hash = ?", subjectTemplateTypeComponent, cdID, identityHash).First(&existingIdentity).Error; err == nil { + var current relational.DefinedComponent + if err := s.db.Select("id", "type", "title", "description", "purpose", "remarks", "props", "links"). + First(¤t, "id = ?", existingIdentity.DefinedComponentID).Error; err == nil { + if definedComponentMatchesRendered(current, rendered) { + return existingIdentity.DefinedComponentID, nil + } + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return uuid.Nil, err + } + if err := s.db.Model(&relational.DefinedComponent{}).Where("id = ?", existingIdentity.DefinedComponentID).Updates(map[string]interface{}{ + "type": rendered.Type, + "title": rendered.Title, + "description": rendered.Description, + "purpose": rendered.Purpose, + "remarks": rendered.Remarks, + "props": datatypes.NewJSONSlice(rendered.Props), + "links": datatypes.NewJSONSlice(rendered.Links), + }).Error; err != nil { + return uuid.Nil, err } - remarks = rendered + return existingIdentity.DefinedComponentID, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return uuid.Nil, err } // Generate deterministic IDs: // - ComponentDefinition groups by plugin // - DefinedComponent is still identity-specific - normalizedPlugin := strings.ToLower(strings.TrimSpace(pluginValue)) - cdID := uuid.NewSHA1(componentDefinitionNamespace, []byte("plugin:"+normalizedPlugin)) dcID := uuid.NewSHA1(cdID, []byte(identityHash)) now := time.Now().UTC() componentDefinitionTitle := template.Name @@ -882,7 +1057,7 @@ func (s *SubjectTemplateService) resolveOrCreateComponentDefinition(template Sub tx := s.db.Begin() if tx.Error != nil { - return nil, tx.Error + return uuid.Nil, tx.Error } defer rollbackTxOnPanic(tx) @@ -892,7 +1067,7 @@ func (s *SubjectTemplateService) resolveOrCreateComponentDefinition(template Sub } if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Omit(clause.Associations).Create(&cd).Error; err != nil { tx.Rollback() - return nil, err + return uuid.Nil, err } // Upsert metadata separately so repeated calls do not create duplicate polymorphic metadata rows. @@ -904,7 +1079,7 @@ func (s *SubjectTemplateService) resolveOrCreateComponentDefinition(template Sub var lockedCD relational.ComponentDefinition if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&lockedCD, "id = ?", cdID).Error; err != nil { tx.Rollback() - return nil, err + return uuid.Nil, err } var existingMetadata relational.Metadata @@ -921,11 +1096,11 @@ func (s *SubjectTemplateService) resolveOrCreateComponentDefinition(template Sub } if err := tx.Omit(clause.Associations).Create(&md).Error; err != nil { tx.Rollback() - return nil, err + return uuid.Nil, err } } else { tx.Rollback() - return nil, err + return uuid.Nil, err } } else { if err := tx.Model(&relational.Metadata{}).Where("parent_id = ? AND parent_type = ?", parentID, parentType).Updates(map[string]interface{}{ @@ -935,23 +1110,29 @@ func (s *SubjectTemplateService) resolveOrCreateComponentDefinition(template Sub "last_modified": &now, }).Error; err != nil { tx.Rollback() - return nil, err + return uuid.Nil, err } } - // Upsert DefinedComponent with rendered template values. + // Upsert DefinedComponent with rendered template values. A row can already exist + // without an identity record (e.g. the identity was removed), so update it in place. dc := relational.DefinedComponent{ UUIDModel: relational.UUIDModel{ID: &dcID}, - Type: template.Type, - Title: title, - Description: description, - Purpose: purpose, - Remarks: remarks, + Type: rendered.Type, + Title: rendered.Title, + Description: rendered.Description, + Purpose: rendered.Purpose, + Remarks: rendered.Remarks, + Props: datatypes.NewJSONSlice(rendered.Props), + Links: datatypes.NewJSONSlice(rendered.Links), ComponentDefinitionID: &cdID, } - if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Omit(clause.Associations).Create(&dc).Error; err != nil { + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.AssignmentColumns([]string{"type", "title", "description", "purpose", "remarks", "props", "links"}), + }).Omit(clause.Associations).Create(&dc).Error; err != nil { tx.Rollback() - return nil, err + return uuid.Nil, err } // Upsert labels. @@ -967,27 +1148,27 @@ func (s *SubjectTemplateService) resolveOrCreateComponentDefinition(template Sub if len(labels) > 0 { if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&labels).Error; err != nil { tx.Rollback() - return nil, err + return uuid.Nil, err } } // Upsert identity record. identity := ComponentDefinitionIdentity{ EntityType: subjectTemplateTypeComponent, - IdentityHash: identityHash, ComponentDefinitionID: cdID, + IdentityHash: identityHash, DefinedComponentID: dcID, } if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&identity).Error; err != nil { tx.Rollback() - return nil, err + return uuid.Nil, err } if err := tx.Commit().Error; err != nil { - return nil, err + return uuid.Nil, err } - return &dcID, nil + return dcID, nil } func (s *SubjectTemplateService) FindSystemComponentsByDefinedComponentIDs(definedComponentIDs []uuid.UUID) ([]relational.SystemComponent, error) { @@ -1310,6 +1491,11 @@ func validateSubjectTemplatePayload(payload *SubjectTemplatePayload) error { if !IsValidSubjectTemplateSourceMode(payload.SourceMode) { return newValidationError("invalid sourceMode") } + if payload.ComponentType != nil { + if _, ok := allowedDefinedComponentTypes[*payload.ComponentType]; !ok { + return newValidationError(fmt.Sprintf("invalid componentType %q", *payload.ComponentType)) + } + } if err := validateSubjectTemplateIdentityLabelKeys(payload.IdentityLabelKeys); err != nil { return err @@ -1462,6 +1648,14 @@ func normalizeSubjectTemplatePayload(payload *SubjectTemplatePayload) { payload.Name = strings.TrimSpace(payload.Name) payload.Type = NormalizeSubjectTemplateType(payload.Type) payload.SourceMode = NormalizeSubjectTemplateSourceMode(payload.SourceMode) + if payload.ComponentType != nil { + normalized := strings.ToLower(strings.TrimSpace(*payload.ComponentType)) + if normalized == "" { + payload.ComponentType = nil + } else { + payload.ComponentType = &normalized + } + } for i := range payload.IdentityLabelKeys { payload.IdentityLabelKeys[i] = strings.ToLower(strings.TrimSpace(payload.IdentityLabelKeys[i])) @@ -1548,6 +1742,8 @@ type BatchSubjectTemplateItem struct { Props []relational.Prop Links []relational.Link SourceMode string + DisplayPriority int + ComponentType *string SelectorLabels []SubjectTemplateSelectorLabelInput LabelSchema []SubjectTemplateLabelSchemaFieldInput } @@ -1558,6 +1754,9 @@ type BatchUpsertSubjectTemplatesResult struct { Updated []SubjectTemplate Deleted []uuid.UUID Unchanged []uuid.UUID + // Warnings lists non-component templates, which are accepted but produce no evidence + // subjects, so the agent can log them. + Warnings []string } // BatchUpsert reconciles the full set of subject templates scoped to a given pluginID. @@ -1641,6 +1840,12 @@ func (s *SubjectTemplateService) BatchUpsert(pluginID string, items []BatchSubje Updated: make([]SubjectTemplate, 0), Deleted: make([]uuid.UUID, 0), Unchanged: make([]uuid.UUID, 0), + Warnings: make([]string, 0), + } + for _, r := range resolved { + if warning := subjectTemplateNoSubjectsWarning(r.item); warning != "" { + result.Warnings = append(result.Warnings, warning) + } } // Collect IDs that need to be created (not already in this scope), then check @@ -1715,6 +1920,15 @@ func (s *SubjectTemplateService) BatchUpsert(pluginID string, items []BatchSubje return result, nil } +// subjectTemplateNoSubjectsWarning returns a warning for a non-component template, which is +// accepted but produces no evidence subjects, or "" otherwise. +func subjectTemplateNoSubjectsWarning(item BatchSubjectTemplateItem) string { + if item.Type != subjectTemplateTypeComponent { + return fmt.Sprintf("subject template %q (id %s) has type %q and produces no evidence subjects; only %q templates do", item.Name, item.ID, item.Type, subjectTemplateTypeComponent) + } + return "" +} + // listSubjectTemplatesByPluginSelectorLabel returns all SubjectTemplates that carry a selector-label // with key=pluginSelectorLabelKey and value=pluginID. func listSubjectTemplatesByPluginSelectorLabel(db *gorm.DB, pluginID string) ([]SubjectTemplate, error) { @@ -1743,6 +1957,8 @@ func batchSubjectItemToPayload(item BatchSubjectTemplateItem) SubjectTemplatePay Props: append([]relational.Prop{}, item.Props...), Links: append([]relational.Link{}, item.Links...), SourceMode: item.SourceMode, + DisplayPriority: item.DisplayPriority, + ComponentType: item.ComponentType, SelectorLabels: append([]SubjectTemplateSelectorLabelInput{}, item.SelectorLabels...), LabelSchema: append([]SubjectTemplateLabelSchemaFieldInput{}, item.LabelSchema...), } @@ -1760,6 +1976,8 @@ func batchSubjectItemFromPayload(item BatchSubjectTemplateItem, payload SubjectT item.Props = payload.Props item.Links = payload.Links item.SourceMode = payload.SourceMode + item.DisplayPriority = payload.DisplayPriority + item.ComponentType = payload.ComponentType item.SelectorLabels = payload.SelectorLabels item.LabelSchema = payload.LabelSchema return item @@ -1778,6 +1996,8 @@ func createSubjectTemplateInTx(tx *gorm.DB, id uuid.UUID, payload SubjectTemplat Props: datatypes.NewJSONSlice(payload.Props), Links: datatypes.NewJSONSlice(payload.Links), SourceMode: payload.SourceMode, + DisplayPriority: payload.DisplayPriority, + ComponentType: payload.ComponentType, } row.ID = &id @@ -1795,6 +2015,8 @@ func createSubjectTemplateInTx(tx *gorm.DB, id uuid.UUID, payload SubjectTemplat "Props", "Links", "SourceMode", + "DisplayPriority", + "ComponentType", ).Create(&row).Error; err != nil { return nil, err } @@ -1826,6 +2048,8 @@ func updateSubjectTemplateInTx(tx *gorm.DB, id uuid.UUID, payload SubjectTemplat existing.Props = datatypes.NewJSONSlice(payload.Props) existing.Links = datatypes.NewJSONSlice(payload.Links) existing.SourceMode = payload.SourceMode + existing.DisplayPriority = payload.DisplayPriority + existing.ComponentType = payload.ComponentType if err := tx.Omit("SelectorLabels", "LabelSchema").Save(&existing).Error; err != nil { return nil, err @@ -1847,6 +2071,8 @@ type subjectTemplateFP struct { Name string `json:"n"` Type string `json:"ty"` SourceMode string `json:"sm"` + DisplayPriority int `json:"dp"` + ComponentType *string `json:"ct,omitempty"` TitleTemplate *string `json:"tt,omitempty"` DescriptionTemplate *string `json:"dt,omitempty"` PurposeTemplate *string `json:"pt,omitempty"` @@ -1902,6 +2128,8 @@ func subjectTemplateFPFromExisting(t SubjectTemplate) subjectTemplateFP { Name: t.Name, Type: t.Type, SourceMode: t.SourceMode, + DisplayPriority: t.DisplayPriority, + ComponentType: t.ComponentType, TitleTemplate: t.TitleTemplate, DescriptionTemplate: t.DescriptionTemplate, PurposeTemplate: t.PurposeTemplate, @@ -1944,6 +2172,8 @@ func subjectTemplateFPFromPayload(payload SubjectTemplatePayload) subjectTemplat Name: payload.Name, Type: payload.Type, SourceMode: payload.SourceMode, + DisplayPriority: payload.DisplayPriority, + ComponentType: payload.ComponentType, TitleTemplate: payload.TitleTemplate, DescriptionTemplate: payload.DescriptionTemplate, PurposeTemplate: payload.PurposeTemplate, diff --git a/internal/service/relational/templates/subject_template_service_test.go b/internal/service/relational/templates/subject_template_service_test.go index 94e11d06..45687e3d 100644 --- a/internal/service/relational/templates/subject_template_service_test.go +++ b/internal/service/relational/templates/subject_template_service_test.go @@ -1365,3 +1365,300 @@ func validSubjectTemplatePayload() SubjectTemplatePayload { }, } } + +func runtimeComponentTemplate(plugin, name string, identityKeys []string, schemaKeys ...string) SubjectTemplatePayload { + schema := []SubjectTemplateLabelSchemaFieldInput{{Key: "_plugin"}} + for _, key := range append(append([]string{}, identityKeys...), schemaKeys...) { + schema = append(schema, SubjectTemplateLabelSchemaFieldInput{Key: key}) + } + return SubjectTemplatePayload{ + Name: name, + Type: "component", + IdentityLabelKeys: identityKeys, + SourceMode: "runtime-derived", + SelectorLabels: []SubjectTemplateSelectorLabelInput{ + {Key: "_plugin", Value: plugin}, + }, + LabelSchema: schema, + } +} + +func TestSubjectTemplateService_DisplayPriorityAndComponentTypePersist(t *testing.T) { + db := newSubjectTemplateTestDB(t) + svc := NewSubjectTemplateService(db) + + payload := runtimeComponentTemplate("github", "GitHub Organization", []string{"organization"}) + payload.DisplayPriority = 5 + payload.ComponentType = strPtr(" Software ") + created, err := svc.Create(payload) + require.NoError(t, err) + require.Equal(t, 5, created.DisplayPriority) + require.NotNil(t, created.ComponentType) + require.Equal(t, "software", *created.ComponentType) + + update := runtimeComponentTemplate("github", "GitHub Organization", []string{"organization"}) + update.DisplayPriority = -1 + update.ComponentType = strPtr("") + updated, err := svc.Update(*created.ID, update) + require.NoError(t, err) + require.Equal(t, -1, updated.DisplayPriority) + require.Nil(t, updated.ComponentType, "an empty component type is stored as unset") +} + +func TestSubjectTemplateService_ComponentTypeValidation(t *testing.T) { + db := newSubjectTemplateTestDB(t) + svc := NewSubjectTemplateService(db) + + invalidType := runtimeComponentTemplate("github", "Invalid type", []string{"organization"}) + invalidType.ComponentType = strPtr("this-system") + _, err := svc.Create(invalidType) + require.Error(t, err) + require.True(t, IsValidationError(err)) + require.Contains(t, err.Error(), "invalid componentType") + + valid := runtimeComponentTemplate("github", "Valid type", []string{"organization"}) + valid.ComponentType = strPtr("software") + _, err = svc.Create(valid) + require.NoError(t, err) +} + +func TestSubjectTemplateService_BatchUpsertDetectsPriorityAndComponentTypeChanges(t *testing.T) { + db := newSubjectTemplateTestDB(t) + svc := NewSubjectTemplateService(db) + + pluginID := "priority-plugin" + id := uuid.New() + item := BatchSubjectTemplateItem{ + ID: id, + Name: "Repository", + Type: "component", + SourceMode: "runtime-derived", + IdentityLabelKeys: []string{"repository"}, + SelectorLabels: []SubjectTemplateSelectorLabelInput{{Key: "_plugin", Value: pluginID}}, + LabelSchema: []SubjectTemplateLabelSchemaFieldInput{{Key: "repository"}}, + } + + _, err := svc.BatchUpsert(pluginID, []BatchSubjectTemplateItem{item}) + require.NoError(t, err) + + unchanged, err := svc.BatchUpsert(pluginID, []BatchSubjectTemplateItem{item}) + require.NoError(t, err) + require.Equal(t, []uuid.UUID{id}, unchanged.Unchanged) + + item.DisplayPriority = 10 + priorityChanged, err := svc.BatchUpsert(pluginID, []BatchSubjectTemplateItem{item}) + require.NoError(t, err) + require.Len(t, priorityChanged.Updated, 1) + require.Equal(t, 10, priorityChanged.Updated[0].DisplayPriority) + + item.ComponentType = strPtr("software") + typeChanged, err := svc.BatchUpsert(pluginID, []BatchSubjectTemplateItem{item}) + require.NoError(t, err) + require.Len(t, typeChanged.Updated, 1) + require.Equal(t, "software", *typeChanged.Updated[0].ComponentType) + + unchangedAgain, err := svc.BatchUpsert(pluginID, []BatchSubjectTemplateItem{item}) + require.NoError(t, err) + require.Equal(t, []uuid.UUID{id}, unchangedAgain.Unchanged) +} + +func TestSubjectTemplateService_BatchUpsertWarnsForNonComponentTemplates(t *testing.T) { + db := newSubjectTemplateTestDB(t) + svc := NewSubjectTemplateService(db) + + pluginID := "warning-plugin" + makeItem := func(name, templateType, sourceMode string) BatchSubjectTemplateItem { + return BatchSubjectTemplateItem{ + ID: uuid.New(), + Name: name, + Type: templateType, + SourceMode: sourceMode, + IdentityLabelKeys: []string{"resource_id"}, + SelectorLabels: []SubjectTemplateSelectorLabelInput{{Key: "_plugin", Value: pluginID}}, + LabelSchema: []SubjectTemplateLabelSchemaFieldInput{{Key: "resource_id"}}, + } + } + + result, err := svc.BatchUpsert(pluginID, []BatchSubjectTemplateItem{ + makeItem("Component", "component", "runtime-derived"), + makeItem("Resource", "resource", "runtime-derived"), + }) + require.NoError(t, err) + require.Len(t, result.Created, 2, "non-component templates are still accepted") + require.Len(t, result.Warnings, 1) + require.Contains(t, result.Warnings[0], `"Resource"`) + require.Contains(t, result.Warnings[0], `type "resource"`) +} + +func TestSubjectTemplateService_ResolveOrUpsertComponentDefinitionReturnsSubjects(t *testing.T) { + db := newSubjectTemplateTestDB(t) + svc := NewSubjectTemplateService(db) + + payload := runtimeComponentTemplate("github", "GitHub Organization", []string{"organization"}, "env") + payload.TitleTemplate = strPtr("GitHub Organization: {{ .organization }}") + payload.DisplayPriority = 3 + payload.ComponentType = strPtr("software") + payload.Props = []relational.Prop{{Name: "vendor", Value: "GitHub"}} + payload.Links = []relational.Link{ + {Href: "https://github.com/{{ .organization }}", Rel: "canonical"}, + {Href: "https://github.com/{{ .env }}", Rel: "alternate"}, + } + template, err := svc.Create(payload) + require.NoError(t, err) + + result, err := svc.ResolveOrUpsertComponentDefinition(ResolveOrUpsertComponentDefinitionInput{ + EvidenceLabels: []relational.Labels{ + {Name: "_plugin", Value: "github"}, + {Name: "organization", Value: "acme"}, + }, + }) + require.NoError(t, err) + require.Len(t, result.DefinedComponentIDs, 1) + require.Len(t, result.Subjects, 1) + + subject := result.Subjects[0] + require.Equal(t, result.DefinedComponentIDs[0], subject.DefinedComponentID) + require.Equal(t, *template.ID, subject.TemplateID) + require.Equal(t, "GitHub Organization", subject.TemplateName) + require.Equal(t, 3, subject.DisplayPriority) + require.Equal(t, "component", subject.Type) + require.Equal(t, "GitHub Organization: acme", subject.Title) + require.Equal(t, []relational.Link{ + {Href: "https://github.com/acme", Rel: "canonical"}, + {Href: "https://github.com/", Rel: "alternate"}, + }, subject.Links, "every template link is kept, with its href rendered from the evidence labels") + + require.Equal(t, []relational.Prop{ + {Name: "vendor", Value: "GitHub"}, + {Ns: relational.CCFOSCALNamespace, Name: "identity", Class: "organization", Value: "acme"}, + {Ns: relational.CCFOSCALNamespace, Name: "label", Class: "_plugin", Value: "github"}, + {Ns: relational.CCFOSCALNamespace, Name: "label", Class: "organization", Value: "acme"}, + }, subject.Props, "template props, identity props, then the schema labels on the evidence") + + var dc relational.DefinedComponent + require.NoError(t, db.First(&dc, "id = ?", subject.DefinedComponentID).Error) + require.Equal(t, "software", dc.Type) + require.Equal(t, "GitHub Organization: acme", dc.Title) + require.Equal(t, subject.Props, []relational.Prop(dc.Props)) + require.Equal(t, subject.Links, []relational.Link(dc.Links)) +} + +func TestSubjectTemplateService_ResolveOrUpsertComponentDefinitionDefaultsComponentType(t *testing.T) { + db := newSubjectTemplateTestDB(t) + svc := NewSubjectTemplateService(db) + + _, err := svc.Create(runtimeComponentTemplate("github", "GitHub Organization", []string{"organization"})) + require.NoError(t, err) + + result, err := svc.ResolveOrUpsertComponentDefinition(ResolveOrUpsertComponentDefinitionInput{ + EvidenceLabels: []relational.Labels{ + {Name: "_plugin", Value: "github"}, + {Name: "organization", Value: "acme"}, + }, + }) + require.NoError(t, err) + require.Len(t, result.DefinedComponentIDs, 1) + + var dc relational.DefinedComponent + require.NoError(t, db.First(&dc, "id = ?", result.DefinedComponentIDs[0]).Error) + require.Equal(t, "service", dc.Type) +} + +func TestSubjectTemplateService_ResolveOrUpsertComponentDefinitionUpdatesDefinedComponent(t *testing.T) { + db := newSubjectTemplateTestDB(t) + svc := NewSubjectTemplateService(db) + + payload := runtimeComponentTemplate("github", "GitHub Organization", []string{"organization"}) + payload.TitleTemplate = strPtr("Org {{ .organization }}") + template, err := svc.Create(payload) + require.NoError(t, err) + + labels := []relational.Labels{ + {Name: "_plugin", Value: "github"}, + {Name: "organization", Value: "acme"}, + } + first, err := svc.ResolveOrUpsertComponentDefinition(ResolveOrUpsertComponentDefinitionInput{EvidenceLabels: labels}) + require.NoError(t, err) + require.Len(t, first.DefinedComponentIDs, 1) + + // A DefinedComponent created before templates set a component type holds the template + // type ("component"), which isn't an OSCAL component type. The next match corrects it. + require.NoError(t, db.Model(&relational.DefinedComponent{}).Where("id = ?", first.DefinedComponentIDs[0]).Update("type", "component").Error) + + payload.TitleTemplate = strPtr("GitHub Organization: {{ .organization }}") + payload.ComponentType = strPtr("software") + _, err = svc.Update(*template.ID, payload) + require.NoError(t, err) + + second, err := svc.ResolveOrUpsertComponentDefinition(ResolveOrUpsertComponentDefinitionInput{EvidenceLabels: labels}) + require.NoError(t, err) + require.Equal(t, first.DefinedComponentIDs, second.DefinedComponentIDs) + require.Equal(t, "GitHub Organization: acme", second.Subjects[0].Title) + + var dcs []relational.DefinedComponent + require.NoError(t, db.Find(&dcs).Error) + require.Len(t, dcs, 1) + require.Equal(t, "GitHub Organization: acme", dcs[0].Title) + require.Equal(t, "software", dcs[0].Type) +} + +func TestSubjectTemplateService_ResolveOrUpsertComponentDefinitionScopesIdentityByPlugin(t *testing.T) { + db := newSubjectTemplateTestDB(t) + svc := NewSubjectTemplateService(db) + + _, err := svc.Create(runtimeComponentTemplate("github", "GitHub Asset", []string{"asset_id"})) + require.NoError(t, err) + _, err = svc.Create(runtimeComponentTemplate("gitlab", "GitLab Asset", []string{"asset_id"})) + require.NoError(t, err) + + fromGitHub, err := svc.ResolveOrUpsertComponentDefinition(ResolveOrUpsertComponentDefinitionInput{ + EvidenceLabels: []relational.Labels{{Name: "_plugin", Value: "github"}, {Name: "asset_id", Value: "shared"}}, + }) + require.NoError(t, err) + fromGitLab, err := svc.ResolveOrUpsertComponentDefinition(ResolveOrUpsertComponentDefinitionInput{ + EvidenceLabels: []relational.Labels{{Name: "_plugin", Value: "gitlab"}, {Name: "asset_id", Value: "shared"}}, + }) + require.NoError(t, err) + + require.Len(t, fromGitHub.DefinedComponentIDs, 1) + require.Len(t, fromGitLab.DefinedComponentIDs, 1) + require.NotEqual(t, fromGitHub.DefinedComponentIDs[0], fromGitLab.DefinedComponentIDs[0], + "the same identity from two plugins resolves to two DefinedComponents") + require.Equal(t, "GitLab Asset", fromGitLab.Subjects[0].Title) + + var identityCount int64 + require.NoError(t, db.Model(&ComponentDefinitionIdentity{}).Count(&identityCount).Error) + require.Equal(t, int64(2), identityCount) + + var cdCount int64 + require.NoError(t, db.Table("component_definitions").Count(&cdCount).Error) + require.Equal(t, int64(2), cdCount) +} + +func TestDefinedComponentMatchesRendered(t *testing.T) { + rendered := renderedDefinedComponent{ + Type: "service", + Title: "GitHub Organization: acme", + Props: []relational.Prop{{Ns: relational.CCFOSCALNamespace, Name: "identity", Class: "organization", Value: "acme"}}, + Links: []relational.Link{}, + } + stored := relational.DefinedComponent{ + Type: rendered.Type, + Title: rendered.Title, + Props: datatypes.NewJSONSlice(append([]relational.Prop{}, rendered.Props...)), + // Links stored as null match no rendered links. + } + require.True(t, definedComponentMatchesRendered(stored, rendered)) + + changedTitle := stored + changedTitle.Title = "GitHub Organization: other" + require.False(t, definedComponentMatchesRendered(changedTitle, rendered)) + + changedProps := stored + changedProps.Props = datatypes.NewJSONSlice([]relational.Prop{{Ns: relational.CCFOSCALNamespace, Name: "identity", Class: "organization", Value: "other"}}) + require.False(t, definedComponentMatchesRendered(changedProps, rendered)) + + withLink := rendered + withLink.Links = []relational.Link{{Href: "https://github.com/acme"}} + require.False(t, definedComponentMatchesRendered(stored, withLink)) +} diff --git a/internal/service/worker/risk_evidence_worker_test.go b/internal/service/worker/risk_evidence_worker_test.go index 8ea56f9d..1372b025 100644 --- a/internal/service/worker/risk_evidence_worker_test.go +++ b/internal/service/worker/risk_evidence_worker_test.go @@ -32,6 +32,7 @@ func newRiskEvidenceWorkerTestDB(t *testing.T) *gorm.DB { // Migrate all required models require.NoError(t, db.AutoMigrate( &relational.Evidence{}, + &relational.EvidenceSubjectReference{}, &relational.Labels{}, &relational.AssessmentSubject{}, &relational.SelectSubjectById{}, diff --git a/internal/service/worker/risk_workers_test.go b/internal/service/worker/risk_workers_test.go index 8f683d9b..6d829a22 100644 --- a/internal/service/worker/risk_workers_test.go +++ b/internal/service/worker/risk_workers_test.go @@ -59,6 +59,7 @@ func newRiskWorkersTestDB(t *testing.T) *gorm.DB { &relational.SystemSecurityPlan{}, &relational.SystemCharacteristics{}, &relational.Evidence{}, + &relational.EvidenceSubjectReference{}, &relational.Labels{}, &relational.AssessmentSubject{}, &relational.SystemComponent{}, diff --git a/internal/tests/migrate.go b/internal/tests/migrate.go index d3c258c6..01449d38 100644 --- a/internal/tests/migrate.go +++ b/internal/tests/migrate.go @@ -193,6 +193,7 @@ func (t *TestMigrator) Up() error { &poamrel.PoamItemFindingLink{}, &relational.Evidence{}, &relational.Artifact{}, + &relational.EvidenceSubjectReference{}, &relational.Labels{}, &relational.SelectSubjectById{}, &relational.Filter{}, @@ -564,6 +565,7 @@ func (t *TestMigrator) Down() error { &relational.SystemNotificationDestination{}, &service.Heartbeat{}, + &relational.EvidenceSubjectReference{}, &relational.Evidence{}, &relational.Artifact{}, "evidence_activities", diff --git a/internal/workflow/evidence_test.go b/internal/workflow/evidence_test.go index dc307e6e..c4cbf615 100644 --- a/internal/workflow/evidence_test.go +++ b/internal/workflow/evidence_test.go @@ -33,6 +33,7 @@ func setupEvidenceTestDB(t *testing.T) *gorm.DB { &workflows.StepReassignmentHistory{}, &workflows.ControlRelationship{}, &relational.Evidence{}, + &relational.EvidenceSubjectReference{}, &relational.Labels{}, ) require.NoError(t, err) diff --git a/internal/workflow/executor_integration_test.go b/internal/workflow/executor_integration_test.go index d7421920..7ad68137 100644 --- a/internal/workflow/executor_integration_test.go +++ b/internal/workflow/executor_integration_test.go @@ -29,6 +29,7 @@ func setupTestDB(t *testing.T) *gorm.DB { // Auto migrate all tables err = db.AutoMigrate( &relational.Evidence{}, + &relational.EvidenceSubjectReference{}, &workflows.WorkflowDefinition{}, &workflows.WorkflowStepDefinition{}, &workflows.StepDependency{}, diff --git a/internal/workflow/step_transition.go b/internal/workflow/step_transition.go index be8a5356..da783b00 100644 --- a/internal/workflow/step_transition.go +++ b/internal/workflow/step_transition.go @@ -377,6 +377,8 @@ func (s *StepTransitionService) storeStepEvidence( Evidence: evidence, Labels: labels, Signer: signer, + // Signed by the user completing the step, but workflow evidence follows workflow rules. + Origin: evidencesvc.OriginWorkflow, }); err != nil { return fmt.Errorf("failed to create step evidence: %w", err) } diff --git a/sdk/evidence_test.go b/sdk/evidence_test.go index 61bf0849..ff8c6482 100644 --- a/sdk/evidence_test.go +++ b/sdk/evidence_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/compliance-framework/api/internal" + "github.com/compliance-framework/api/internal/service/relational" "github.com/compliance-framework/api/sdk/types" "github.com/google/uuid" "github.com/stretchr/testify/suite" @@ -144,3 +145,38 @@ func (suite *EvidenceSDKIntegrationSuite) TestCreate() { suite.NoError(err) }) } + +func (suite *EvidenceSDKIntegrationSuite) TestCreateWithDeclaredSubject() { + suite.Require().NoError(suite.Migrator.Refresh()) + + partyID := uuid.New() + partyName := "Network Team" + suite.Require().NoError(suite.DB.Omit("Locations", "MemberOfOrganizations").Create(&relational.Party{ + UUIDModel: relational.UUIDModel{ID: &partyID}, + Type: relational.PartyTypeOrganization, + Name: &partyName, + }).Error) + + client, err := suite.GetAuthenticatedSDKTestClient() + suite.Require().NoError(err) + + streamUUID := uuid.New() + err = client.Evidence.Create(context.Background(), types.Evidence{ + UUID: streamUUID, + Title: "Quarterly firewall rule review", + Start: time.Now().Add(-time.Hour), + End: time.Now().Add(-time.Minute), + Status: types.ObjectiveStatus{State: "satisfied"}, + Subjects: []types.Subject{{SubjectUUID: &partyID}}, + }) + suite.Require().NoError(err) + + var evidence relational.Evidence + suite.Require().NoError(suite.DB.Preload("SubjectReferences").First(&evidence, "uuid = ?", streamUUID).Error) + suite.Require().Len(evidence.SubjectReferences, 1) + ref := evidence.SubjectReferences[0] + suite.Equal(partyID, ref.SubjectUUID) + suite.Equal("party", ref.Type) + suite.Equal("Network Team", ref.Title) + suite.Equal(relational.EvidenceSubjectSourceDeclared, ref.Source) +} diff --git a/sdk/integration_base_test.go b/sdk/integration_base_test.go index a2fb68fa..5aab240e 100644 --- a/sdk/integration_base_test.go +++ b/sdk/integration_base_test.go @@ -49,6 +49,12 @@ func (suite *IntegrationBaseTestSuite) GetSDKTestClient() *sdk.Client { } func (suite *IntegrationBaseTestSuite) GetAuthenticatedSDKTestClient() (*sdk.Client, error) { + return suite.GetAuthenticatedSDKTestClientWithLogger(nil) +} + +// GetAuthenticatedSDKTestClientWithLogger is GetAuthenticatedSDKTestClient with the SDK's +// logger set, for tests that check what the SDK logs. +func (suite *IntegrationBaseTestSuite) GetAuthenticatedSDKTestClientWithLogger(logger *zap.SugaredLogger) (*sdk.Client, error) { agent := &relational.Agent{ Name: fmt.Sprintf("sdk-agent-%d", time.Now().UnixNano()), IsActive: true, @@ -71,6 +77,7 @@ func (suite *IntegrationBaseTestSuite) GetAuthenticatedSDKTestClient() (*sdk.Cli return sdk.NewClient(http.DefaultClient, &sdk.Config{ BaseURL: "http://" + suite.Server.E().ListenerAddr().String(), + Logger: logger, AgentAuth: &sdk.AgentAuthConfig{ ClientID: key.ClientID, ClientSecret: clientSecret, diff --git a/sdk/subject_template.go b/sdk/subject_template.go index 29b4414e..2c04149f 100644 --- a/sdk/subject_template.go +++ b/sdk/subject_template.go @@ -2,6 +2,7 @@ package sdk import ( "context" + "encoding/json" "fmt" "net/http" @@ -17,6 +18,14 @@ type upsertSubjectTemplatesRequest struct { Templates []types.SubjectTemplate `json:"templates"` } +type upsertSubjectTemplatesResponse struct { + Data struct { + // Warnings name accepted templates that produce no evidence subjects (non-component + // templates). + Warnings []string `json:"warnings"` + } `json:"data"` +} + func (r *subjectTemplateClient) Upsert(ctx context.Context, pluginID string, subjectTemplates ...types.SubjectTemplate) error { if len(subjectTemplates) == 0 { subjectTemplates = []types.SubjectTemplate{} @@ -31,11 +40,30 @@ func (r *subjectTemplateClient) Upsert(ctx context.Context, pluginID string, sub if err != nil { return err } - closeResponseBody(response, r.client.config.Logger) + defer closeResponseBody(response, r.client.config.Logger) if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusOK { return fmt.Errorf("unexpected api response status code: %d", response.StatusCode) } + r.logWarnings(pluginID, response) return nil } + +// logWarnings logs the warnings the API returns for the upserted templates, so agents +// report them without handling the response themselves. Warnings are informational: a body +// that can't be read doesn't fail the upsert. +func (r *subjectTemplateClient) logWarnings(pluginID string, response *http.Response) { + logger := r.client.config.Logger + if logger == nil { + return + } + + var body upsertSubjectTemplatesResponse + if err := json.NewDecoder(response.Body).Decode(&body); err != nil { + return + } + for _, warning := range body.Data.Warnings { + logger.Warnw("Subject template warning", "plugin_id", pluginID, "warning", warning) + } +} diff --git a/sdk/subject_template_integration_test.go b/sdk/subject_template_integration_test.go index 1239407a..4b1d1393 100644 --- a/sdk/subject_template_integration_test.go +++ b/sdk/subject_template_integration_test.go @@ -10,6 +10,9 @@ import ( "github.com/compliance-framework/api/sdk/types" "github.com/google/uuid" "github.com/stretchr/testify/suite" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" ) func TestSubjectTemplateSDK(t *testing.T) { @@ -46,3 +49,48 @@ func (suite *SubjectTemplateSDKIntegrationSuite) TestUpsertWithAgentAuth() { suite.Require().NoError(suite.DB.Model(&templaterel.SubjectTemplate{}).Where("id = ?", templateID).Count(&count).Error) suite.Equal(int64(1), count) } + +func (suite *SubjectTemplateSDKIntegrationSuite) TestUpsertSendsDisplayPriorityAndComponentTypeAndLogsWarnings() { + suite.Require().NoError(suite.Migrator.Refresh()) + + core, logs := observer.New(zapcore.WarnLevel) + client, err := suite.GetAuthenticatedSDKTestClientWithLogger(zap.New(core).Sugar()) + suite.Require().NoError(err) + + componentID := uuid.NewString() + resourceID := uuid.NewString() + selector := []types.SubjectTemplateSelectorLabel{{Key: "_plugin", Value: "plugin-b"}} + err = client.SubjectTemplate.Upsert(context.Background(), "plugin-b", + types.SubjectTemplate{ + ID: componentID, + Name: "Repository", + Type: "component", + IdentityLabelKeys: []string{"repository"}, + SourceMode: "runtime-derived", + DisplayPriority: 10, + ComponentType: "software", + SelectorLabels: selector, + LabelSchema: []types.SubjectTemplateLabelSchema{{Key: "repository"}}, + }, + types.SubjectTemplate{ + ID: resourceID, + Name: "Cloud resource", + Type: "resource", + IdentityLabelKeys: []string{"resource_id"}, + SourceMode: "runtime-derived", + SelectorLabels: selector, + LabelSchema: []types.SubjectTemplateLabelSchema{{Key: "resource_id"}}, + }, + ) + suite.Require().NoError(err) + + var stored templaterel.SubjectTemplate + suite.Require().NoError(suite.DB.First(&stored, "id = ?", componentID).Error) + suite.Equal(10, stored.DisplayPriority) + suite.Require().NotNil(stored.ComponentType) + suite.Equal("software", *stored.ComponentType) + + warnings := logs.FilterMessage("Subject template warning").All() + suite.Require().Len(warnings, 1, "the non-component template is reported") + suite.Contains(warnings[0].ContextMap()["warning"], resourceID) +} diff --git a/sdk/subject_template_test.go b/sdk/subject_template_test.go index dee52381..0540afc8 100644 --- a/sdk/subject_template_test.go +++ b/sdk/subject_template_test.go @@ -9,6 +9,9 @@ import ( "testing" "github.com/compliance-framework/api/sdk/types" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" ) func newSubjectTemplateTestClient(handler roundTripFunc) *Client { @@ -287,3 +290,53 @@ func TestSubjectTemplateUpsertReturnsErrorOnUnexpectedStatus(t *testing.T) { t.Fatalf("expected error to mention status code 418, got %q", err.Error()) } } + +func TestSubjectTemplateUpsertLogsWarnings(t *testing.T) { + core, logs := observer.New(zapcore.WarnLevel) + client := NewClient(&http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"created":[],"warnings":["subject template \"Cloud resource\" has type \"resource\" and produces no evidence subjects"]}}`)), + Header: make(http.Header), + }, nil + })}, &Config{BaseURL: "http://example.test", Logger: zap.New(core).Sugar()}) + + err := client.SubjectTemplate.Upsert(context.Background(), "plugin-a", types.SubjectTemplate{ + ID: "template-a", + Name: "Cloud resource", + Type: "resource", + SourceMode: "runtime-derived", + }) + if err != nil { + t.Fatalf("upsert subject templates: %v", err) + } + + entries := logs.All() + if len(entries) != 1 { + t.Fatalf("expected 1 warning logged, got %d", len(entries)) + } + fields := entries[0].ContextMap() + if entries[0].Message != "Subject template warning" || fields["plugin_id"] != "plugin-a" || + !strings.Contains(fields["warning"].(string), `"Cloud resource"`) { + t.Fatalf("unexpected warning log: %q %v", entries[0].Message, fields) + } +} + +func TestSubjectTemplateUpsertIgnoresUnreadableResponseBody(t *testing.T) { + core, logs := observer.New(zapcore.WarnLevel) + client := NewClient(&http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("not json")), + Header: make(http.Header), + }, nil + })}, &Config{BaseURL: "http://example.test", Logger: zap.New(core).Sugar()}) + + err := client.SubjectTemplate.Upsert(context.Background(), "plugin-a", types.SubjectTemplate{ID: "template-a"}) + if err != nil { + t.Fatalf("expected an unreadable body not to fail the upsert, got %v", err) + } + if logs.Len() != 0 { + t.Fatalf("expected no warnings logged, got %d", logs.Len()) + } +} diff --git a/sdk/types/types.go b/sdk/types/types.go index 473e7c69..dd77575e 100644 --- a/sdk/types/types.go +++ b/sdk/types/types.go @@ -107,6 +107,15 @@ type Component struct { } type Subject struct { + // SubjectUUID names an existing subject: a defined component, SSP system component, party + // or user (GET /api/subjects). The API takes the subject's type and title from it, and + // ignores Identifier. + SubjectUUID *uuid.UUID `json:"subject-uuid,omitempty"` + // Title is the subject's display title. The API doesn't use it yet: a SubjectUUID + // subject takes its title from the subject named. + Title string `json:"title,omitempty"` + + // Identifier is the legacy way to name a subject, used when SubjectUUID isn't set. Identifier string `json:"identifier,omitempty"` // InventoryItem @@ -221,19 +230,25 @@ type SubjectProp = Property type SubjectLink = Link type SubjectTemplate struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - TitleTemplate *string `json:"title-template,omitempty"` - DescriptionTemplate *string `json:"description-template,omitempty"` - PurposeTemplate *string `json:"purpose-template,omitempty"` - RemarksTemplate *string `json:"remarks-template,omitempty"` - IdentityLabelKeys []string `json:"identity-label-keys"` - Props []SubjectProp `json:"props"` - Links []SubjectLink `json:"links"` - SourceMode string `json:"source-mode"` - SelectorLabels []SubjectTemplateSelectorLabel `json:"selector-labels"` - LabelSchema []SubjectTemplateLabelSchema `json:"label-schema"` + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + TitleTemplate *string `json:"title-template,omitempty"` + DescriptionTemplate *string `json:"description-template,omitempty"` + PurposeTemplate *string `json:"purpose-template,omitempty"` + RemarksTemplate *string `json:"remarks-template,omitempty"` + IdentityLabelKeys []string `json:"identity-label-keys"` + Props []SubjectProp `json:"props"` + Links []SubjectLink `json:"links"` + SourceMode string `json:"source-mode"` + // DisplayPriority orders the subjects this template produces on evidence: higher first, + // ties by template name. Unset means 0. + DisplayPriority int `json:"display-priority,omitempty"` + // ComponentType is the OSCAL component type of the components this template creates + // (e.g. software, service). Unset means service. + ComponentType string `json:"component-type,omitempty"` + SelectorLabels []SubjectTemplateSelectorLabel `json:"selector-labels"` + LabelSchema []SubjectTemplateLabelSchema `json:"label-schema"` } type Heartbeat struct { diff --git a/sdk/types/types_test.go b/sdk/types/types_test.go index 6dbc6ca6..cbab595b 100644 --- a/sdk/types/types_test.go +++ b/sdk/types/types_test.go @@ -5,6 +5,7 @@ import ( "testing" oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -36,3 +37,27 @@ func TestEvidenceOmitsEmptyBackMatter(t *testing.T) { require.NoError(t, err) assert.NotContains(t, string(raw), `"back-matter"`) } + +func TestSubjectCarriesSubjectUUIDAndTitle(t *testing.T) { + id := uuid.MustParse("5b1c4b1e-2f0f-4b8e-9a44-0d6a2f7e1c11") + raw, err := json.Marshal(Subject{SubjectUUID: &id, Title: "Perimeter Firewall"}) + require.NoError(t, err) + assert.JSONEq(t, `{"subject-uuid":"5b1c4b1e-2f0f-4b8e-9a44-0d6a2f7e1c11","title":"Perimeter Firewall"}`, string(raw)) + + legacy, err := json.Marshal(Subject{Identifier: "github/acme", Type: "Component"}) + require.NoError(t, err) + assert.JSONEq(t, `{"identifier":"github/acme","type":"Component"}`, string(legacy), + "unset subject-uuid and title are omitted") +} + +func TestSubjectTemplateCarriesDisplayPriorityAndComponentType(t *testing.T) { + raw, err := json.Marshal(SubjectTemplate{DisplayPriority: 10, ComponentType: "software"}) + require.NoError(t, err) + assert.Contains(t, string(raw), `"display-priority":10`) + assert.Contains(t, string(raw), `"component-type":"software"`) + + unset, err := json.Marshal(SubjectTemplate{}) + require.NoError(t, err) + assert.NotContains(t, string(unset), "display-priority", "the API default (0) applies when unset") + assert.NotContains(t, string(unset), "component-type", "the API default (service) applies when unset") +}