diff --git a/docs/docs.go b/docs/docs.go index 63ffccea..0ee207e6 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -17039,45 +17039,50 @@ const docTemplate = `{ } } }, - "/risk-templates": { + "/poam-items": { "get": { - "description": "List risk templates with optional filters and pagination.", "produces": [ "application/json" ], "tags": [ - "Risk Templates" + "POAM Items" ], - "summary": "List risk templates", + "summary": "List POAM items", "parameters": [ { "type": "string", - "description": "Plugin ID", - "name": "pluginId", + "description": "Filter by status (open|in-progress|completed|overdue)", + "name": "status", "in": "query" }, { "type": "string", - "description": "Policy package", - "name": "policyPackage", + "description": "Filter by SSP UUID", + "name": "sspId", "in": "query" }, { - "type": "boolean", - "description": "Active flag", - "name": "isActive", + "type": "string", + "description": "Filter by linked risk UUID", + "name": "riskId", "in": "query" }, { - "type": "integer", - "description": "Page number", - "name": "page", + "type": "string", + "description": "Filter by planned_completion_date before (RFC3339)", + "name": "deadlineBefore", "in": "query" }, { - "type": "integer", - "description": "Page size", - "name": "limit", + "type": "boolean", + "description": "Return only overdue items", + "name": "overdueOnly", + "in": "query" + }, + { + "type": "string", + "description": "Filter by primary_owner_user_id UUID", + "name": "ownerRef", "in": "query" } ], @@ -17085,7 +17090,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/service.ListResponse-templates_riskTemplateResponse" + "$ref": "#/definitions/handler.GenericDataListResponse-handler_poamItemResponse" } }, "400": { @@ -17108,7 +17113,6 @@ const docTemplate = `{ ] }, "post": { - "description": "Create a risk template with threat references and remediation template/tasks.", "consumes": [ "application/json" ], @@ -17116,17 +17120,17 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Risk Templates" + "POAM Items" ], - "summary": "Create risk template", + "summary": "Create a POAM item", "parameters": [ { - "description": "Risk template payload", - "name": "template", + "description": "POAM item payload", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/templates.upsertRiskTemplateRequest" + "$ref": "#/definitions/handler.createPoamItemRequest" } } ], @@ -17134,7 +17138,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/templates.riskTemplateDataResponse" + "$ref": "#/definitions/handler.GenericDataResponse-handler_poamItemResponse" } }, "400": { @@ -17143,6 +17147,12 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -17157,20 +17167,19 @@ const docTemplate = `{ ] } }, - "/risk-templates/{id}": { + "/poam-items/{id}": { "get": { - "description": "Get a risk template by ID.", "produces": [ "application/json" ], "tags": [ - "Risk Templates" + "POAM Items" ], - "summary": "Get risk template", + "summary": "Get a POAM item", "parameters": [ { "type": "string", - "description": "Risk Template ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true @@ -17180,7 +17189,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/templates.riskTemplateDataResponse" + "$ref": "#/definitions/handler.GenericDataResponse-handler_poamItemResponse" } }, "400": { @@ -17209,7 +17218,6 @@ const docTemplate = `{ ] }, "put": { - "description": "Update a risk template and atomically replace threat refs and remediation tasks.", "consumes": [ "application/json" ], @@ -17217,24 +17225,24 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Risk Templates" + "POAM Items" ], - "summary": "Update risk template", + "summary": "Update a POAM item", "parameters": [ { "type": "string", - "description": "Risk Template ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true }, { - "description": "Risk template payload", - "name": "template", + "description": "Update payload", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/templates.upsertRiskTemplateRequest" + "$ref": "#/definitions/handler.updatePoamItemRequest" } } ], @@ -17242,7 +17250,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/templates.riskTemplateDataResponse" + "$ref": "#/definitions/handler.GenericDataResponse-handler_poamItemResponse" } }, "400": { @@ -17271,18 +17279,14 @@ const docTemplate = `{ ] }, "delete": { - "description": "Delete a risk template and its associated threat references and remediation data.", - "produces": [ - "application/json" - ], "tags": [ - "Risk Templates" + "POAM Items" ], - "summary": "Delete risk template", + "summary": "Delete a POAM item", "parameters": [ { "type": "string", - "description": "Risk Template ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true @@ -17318,101 +17322,29 @@ const docTemplate = `{ ] } }, - "/risks": { + "/poam-items/{id}/controls": { "get": { - "description": "Lists risk register entries with filtering, sorting, and pagination.", "produces": [ "application/json" ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "List risks", + "summary": "List linked controls", "parameters": [ { "type": "string", - "description": "Risk status", - "name": "status", - "in": "query" - }, - { - "type": "string", - "description": "Risk likelihood", - "name": "likelihood", - "in": "query" - }, - { - "type": "string", - "description": "Risk impact", - "name": "impact", - "in": "query" - }, - { - "type": "string", - "description": "SSP ID", - "name": "sspId", - "in": "query" - }, - { - "type": "string", - "description": "Control ID", - "name": "controlId", - "in": "query" - }, - { - "type": "string", - "description": "Evidence ID", - "name": "evidenceId", - "in": "query" - }, - { - "type": "string", - "description": "Owner kind", - "name": "ownerKind", - "in": "query" - }, - { - "type": "string", - "description": "Owner reference", - "name": "ownerRef", - "in": "query" - }, - { - "type": "string", - "description": "Review deadline upper bound (RFC3339)", - "name": "reviewDeadlineBefore", - "in": "query" - }, - { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Page size", - "name": "limit", - "in": "query" - }, - { - "type": "string", - "description": "Sort field", - "name": "sort", - "in": "query" - }, - { - "type": "string", - "description": "Sort order (asc|desc)", - "name": "order", - "in": "query" + "description": "POAM item ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/service.ListResponse-handler_riskResponse" + "$ref": "#/definitions/handler.GenericDataListResponse-poam_PoamItemControlLink" } }, "400": { @@ -17421,6 +17353,12 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -17435,7 +17373,6 @@ const docTemplate = `{ ] }, "post": { - "description": "Creates a risk register entry.", "consumes": [ "application/json" ], @@ -17443,17 +17380,24 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "Create risk", + "summary": "Add a control link", "parameters": [ { - "description": "Risk payload", - "name": "risk", + "type": "string", + "description": "POAM item ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Control ref payload", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handler.createRiskRequest" + "$ref": "#/definitions/handler.poamControlRefRequest" } } ], @@ -17461,7 +17405,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + "$ref": "#/definitions/handler.GenericDataResponse-poam_PoamItemControlLink" } }, "400": { @@ -17470,8 +17414,8 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, - "401": { - "description": "Unauthorized", + "404": { + "description": "Not Found", "schema": { "$ref": "#/definitions/api.Error" } @@ -17490,31 +17434,38 @@ const docTemplate = `{ ] } }, - "/risks/{id}": { - "get": { - "description": "Retrieves a risk register entry by ID.", - "produces": [ - "application/json" - ], + "/poam-items/{id}/controls/{catalogId}/{controlId}": { + "delete": { "tags": [ - "Risks" + "POAM Items" ], - "summary": "Get risk", + "summary": "Delete a control link", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true + }, + { + "type": "string", + "description": "Catalog ID", + "name": "catalogId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Control ID", + "name": "controlId", + "in": "path", + "required": true } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" - } + "204": { + "description": "No Content" }, "400": { "description": "Bad Request", @@ -17540,42 +17491,31 @@ const docTemplate = `{ "OAuth2Password": [] } ] - }, - "put": { - "description": "Updates a risk register entry by ID.", - "consumes": [ - "application/json" - ], + } + }, + "/poam-items/{id}/evidence": { + "get": { "produces": [ "application/json" ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "Update risk", + "summary": "List linked evidence", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true - }, - { - "description": "Risk payload", - "name": "risk", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handler.updateRiskRequest" - } } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + "$ref": "#/definitions/handler.GenericDataListResponse-poam_PoamItemEvidenceLink" } }, "400": { @@ -17603,24 +17543,41 @@ const docTemplate = `{ } ] }, - "delete": { - "description": "Deletes a risk register entry and link rows by ID.", + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "Delete risk", + "summary": "Add an evidence link", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true + }, + { + "description": "Evidence ID payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.addLinkRequest" + } } ], "responses": { - "204": { - "description": "No Content" + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-poam_PoamItemEvidenceLink" + } }, "400": { "description": "Bad Request", @@ -17648,43 +17605,31 @@ const docTemplate = `{ ] } }, - "/risks/{id}/accept": { - "post": { - "description": "Accepts a risk with required justification and a future review deadline.", - "consumes": [ - "application/json" + "/poam-items/{id}/evidence/{evidenceId}": { + "delete": { + "tags": [ + "POAM Items" ], - "produces": [ - "application/json" - ], - "tags": [ - "Risks" - ], - "summary": "Accept risk", + "summary": "Delete an evidence link", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true }, { - "description": "Accept payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handler.acceptRiskRequest" - } + "type": "string", + "description": "Evidence ID", + "name": "evidenceId", + "in": "path", + "required": true } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" - } + "204": { + "description": "No Content" }, "400": { "description": "Bad Request", @@ -17712,42 +17657,29 @@ const docTemplate = `{ ] } }, - "/risks/{id}/components": { + "/poam-items/{id}/findings": { "get": { - "description": "Lists components linked to a risk.", "produces": [ "application/json" ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "List risk component links", + "summary": "List linked findings", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true - }, - { - "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-risks_RiskComponentLink" + "$ref": "#/definitions/handler.GenericDataListResponse-poam_PoamItemFindingLink" } }, "400": { @@ -17776,7 +17708,6 @@ const docTemplate = `{ ] }, "post": { - "description": "Idempotently links a component to a risk.", "consumes": [ "application/json" ], @@ -17784,24 +17715,24 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "Link component to risk", + "summary": "Add a finding link", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true }, { - "description": "Component link payload", - "name": "link", + "description": "Finding ID payload", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handler.addComponentLinkRequest" + "$ref": "#/definitions/handler.addLinkRequest" } } ], @@ -17809,7 +17740,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-risks_RiskComponentLink" + "$ref": "#/definitions/handler.GenericDataResponse-poam_PoamItemFindingLink" } }, "400": { @@ -17838,42 +17769,81 @@ const docTemplate = `{ ] } }, - "/risks/{id}/controls": { - "get": { - "description": "Lists controls linked to a risk.", - "produces": [ - "application/json" - ], + "/poam-items/{id}/findings/{findingId}": { + "delete": { "tags": [ - "Risks" + "POAM Items" ], - "summary": "List risk control links", + "summary": "Delete a finding link", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true }, { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" + "type": "string", + "description": "Finding ID", + "name": "findingId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ { - "type": "integer", - "description": "Page size", - "name": "limit", - "in": "query" + "OAuth2Password": [] + } + ] + } + }, + "/poam-items/{id}/milestones": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "POAM Items" + ], + "summary": "List milestones for a POAM item", + "parameters": [ + { + "type": "string", + "description": "POAM item ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/service.ListResponse-risks_RiskControlLink" + "$ref": "#/definitions/handler.GenericDataListResponse-handler_milestoneResponse" } }, "400": { @@ -17902,7 +17872,6 @@ const docTemplate = `{ ] }, "post": { - "description": "Idempotently links a control to a risk.", "consumes": [ "application/json" ], @@ -17910,24 +17879,24 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "Link control to risk", + "summary": "Add a milestone to a POAM item", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true }, { - "description": "Control link payload", - "name": "link", + "description": "Milestone payload", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handler.addControlLinkRequest" + "$ref": "#/definitions/handler.createMilestoneRequest" } } ], @@ -17935,7 +17904,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-risks_RiskControlLink" + "$ref": "#/definitions/handler.GenericDataResponse-handler_milestoneResponse" } }, "400": { @@ -17964,42 +17933,48 @@ const docTemplate = `{ ] } }, - "/risks/{id}/evidence": { - "get": { - "description": "Lists evidence IDs linked to a risk.", + "/poam-items/{id}/milestones/{milestoneId}": { + "put": { + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "List risk evidence links", + "summary": "Update a milestone", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true }, { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" + "type": "string", + "description": "Milestone ID", + "name": "milestoneId", + "in": "path", + "required": true }, { - "type": "integer", - "description": "Page size", - "name": "limit", - "in": "query" + "description": "Milestone update payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.updateMilestoneRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/service.ListResponse-uuid_UUID" + "$ref": "#/definitions/handler.GenericDataResponse-handler_milestoneResponse" } }, "400": { @@ -18027,42 +18002,30 @@ const docTemplate = `{ } ] }, - "post": { - "description": "Idempotently links an evidence item to a risk.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], + "delete": { "tags": [ - "Risks" + "POAM Items" ], - "summary": "Link evidence to risk", + "summary": "Delete a milestone", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true }, { - "description": "Evidence link payload", - "name": "link", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handler.addEvidenceLinkRequest" - } + "type": "string", + "description": "Milestone ID", + "name": "milestoneId", + "in": "path", + "required": true } ], "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-risks_RiskEvidenceLink" - } + "204": { + "description": "No Content" }, "400": { "description": "Bad Request", @@ -18090,32 +18053,30 @@ const docTemplate = `{ ] } }, - "/risks/{id}/evidence/{evidenceId}": { - "delete": { - "description": "Deletes the link between a risk and evidence item.", + "/poam-items/{id}/risks": { + "get": { + "produces": [ + "application/json" + ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "Delete risk evidence link", + "summary": "List linked risks", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true - }, - { - "type": "string", - "description": "Evidence ID", - "name": "evidenceId", - "in": "path", - "required": true } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-poam_PoamItemRiskLink" + } }, "400": { "description": "Bad Request", @@ -18141,11 +18102,8 @@ const docTemplate = `{ "OAuth2Password": [] } ] - } - }, - "/risks/{id}/review": { + }, "post": { - "description": "Records a structured review for an accepted risk. nextReviewDeadline is required for decision=extend and must be omitted for decision=reopen.", "consumes": [ "application/json" ], @@ -18153,32 +18111,32 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "Review risk", + "summary": "Add a risk link", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true }, { - "description": "Review payload", + "description": "Risk ID payload", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handler.reviewRiskRequest" + "$ref": "#/definitions/handler.addLinkRequest" } } ], "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + "$ref": "#/definitions/handler.GenericDataResponse-poam_PoamItemRiskLink" } }, "400": { @@ -18207,43 +18165,31 @@ const docTemplate = `{ ] } }, - "/risks/{id}/subjects": { - "get": { - "description": "Lists subjects linked to a risk.", - "produces": [ - "application/json" - ], + "/poam-items/{id}/risks/{riskId}": { + "delete": { "tags": [ - "Risks" + "POAM Items" ], - "summary": "List risk subject links", + "summary": "Delete a risk link", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true }, { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Page size", - "name": "limit", - "in": "query" + "type": "string", + "description": "Risk ID", + "name": "riskId", + "in": "path", + "required": true } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/service.ListResponse-risks_RiskSubjectLink" - } + "204": { + "description": "No Content" }, "400": { "description": "Bad Request", @@ -18269,7 +18215,1239 @@ const docTemplate = `{ "OAuth2Password": [] } ] - }, + } + }, + "/risk-templates": { + "get": { + "description": "List risk templates with optional filters and pagination.", + "produces": [ + "application/json" + ], + "tags": [ + "Risk Templates" + ], + "summary": "List risk templates", + "parameters": [ + { + "type": "string", + "description": "Plugin ID", + "name": "pluginId", + "in": "query" + }, + { + "type": "string", + "description": "Policy package", + "name": "policyPackage", + "in": "query" + }, + { + "type": "boolean", + "description": "Active flag", + "name": "isActive", + "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-templates_riskTemplateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "post": { + "description": "Create a risk template with threat references and remediation template/tasks.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risk Templates" + ], + "summary": "Create risk template", + "parameters": [ + { + "description": "Risk template payload", + "name": "template", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/templates.upsertRiskTemplateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/templates.riskTemplateDataResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/risk-templates/{id}": { + "get": { + "description": "Get a risk template by ID.", + "produces": [ + "application/json" + ], + "tags": [ + "Risk Templates" + ], + "summary": "Get risk template", + "parameters": [ + { + "type": "string", + "description": "Risk Template ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/templates.riskTemplateDataResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "put": { + "description": "Update a risk template and atomically replace threat refs and remediation tasks.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risk Templates" + ], + "summary": "Update risk template", + "parameters": [ + { + "type": "string", + "description": "Risk Template ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Risk template payload", + "name": "template", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/templates.upsertRiskTemplateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/templates.riskTemplateDataResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "delete": { + "description": "Delete a risk template and its associated threat references and remediation data.", + "produces": [ + "application/json" + ], + "tags": [ + "Risk Templates" + ], + "summary": "Delete risk template", + "parameters": [ + { + "type": "string", + "description": "Risk Template ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/risks": { + "get": { + "description": "Lists risk register entries with filtering, sorting, and pagination.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "List risks", + "parameters": [ + { + "type": "string", + "description": "Risk status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Risk likelihood", + "name": "likelihood", + "in": "query" + }, + { + "type": "string", + "description": "Risk impact", + "name": "impact", + "in": "query" + }, + { + "type": "string", + "description": "SSP ID", + "name": "sspId", + "in": "query" + }, + { + "type": "string", + "description": "Control ID", + "name": "controlId", + "in": "query" + }, + { + "type": "string", + "description": "Evidence ID", + "name": "evidenceId", + "in": "query" + }, + { + "type": "string", + "description": "Owner kind", + "name": "ownerKind", + "in": "query" + }, + { + "type": "string", + "description": "Owner reference", + "name": "ownerRef", + "in": "query" + }, + { + "type": "string", + "description": "Review deadline upper bound (RFC3339)", + "name": "reviewDeadlineBefore", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Sort field", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "description": "Sort order (asc|desc)", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/service.ListResponse-handler_riskResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "post": { + "description": "Creates a risk register entry.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Create risk", + "parameters": [ + { + "description": "Risk payload", + "name": "risk", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.createRiskRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "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": [] + } + ] + } + }, + "/risks/{id}": { + "get": { + "description": "Retrieves a risk register entry by ID.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Get risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "put": { + "description": "Updates a risk register entry by ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Update risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Risk payload", + "name": "risk", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.updateRiskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "delete": { + "description": "Deletes a risk register entry and link rows by ID.", + "tags": [ + "Risks" + ], + "summary": "Delete risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/risks/{id}/accept": { + "post": { + "description": "Accepts a risk with required justification and a future review deadline.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Accept risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Accept payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.acceptRiskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/risks/{id}/components": { + "get": { + "description": "Lists components linked to a risk.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "List risk component links", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "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-risks_RiskComponentLink" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "post": { + "description": "Idempotently links a component to a risk.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Link component to risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Component link payload", + "name": "link", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.addComponentLinkRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-risks_RiskComponentLink" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/risks/{id}/controls": { + "get": { + "description": "Lists controls linked to a risk.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "List risk control links", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "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-risks_RiskControlLink" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "post": { + "description": "Idempotently links a control to a risk.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Link control to risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Control link payload", + "name": "link", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.addControlLinkRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-risks_RiskControlLink" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/risks/{id}/evidence": { + "get": { + "description": "Lists evidence IDs linked to a risk.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "List risk evidence links", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "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-uuid_UUID" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "post": { + "description": "Idempotently links an evidence item to a risk.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Link evidence to risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Evidence link payload", + "name": "link", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.addEvidenceLinkRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-risks_RiskEvidenceLink" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/risks/{id}/evidence/{evidenceId}": { + "delete": { + "description": "Deletes the link between a risk and evidence item.", + "tags": [ + "Risks" + ], + "summary": "Delete risk evidence link", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Evidence ID", + "name": "evidenceId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/risks/{id}/review": { + "post": { + "description": "Records a structured review for an accepted risk. nextReviewDeadline is required for decision=extend and must be omitted for decision=reopen.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Review risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Review payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.reviewRiskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/risks/{id}/subjects": { + "get": { + "description": "Lists subjects linked to a risk.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "List risk subject links", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "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-risks_RiskSubjectLink" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, "post": { "description": "Idempotently links a subject to a risk.", "consumes": [ @@ -22793,6 +23971,30 @@ const docTemplate = `{ } } }, + "handler.GenericDataListResponse-handler_milestoneResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/handler.milestoneResponse" + } + } + } + }, + "handler.GenericDataListResponse-handler_poamItemResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/handler.poamItemResponse" + } + } + } + }, "handler.GenericDataListResponse-oscalTypes_1_1_3_AssessmentPlan": { "type": "object", "properties": { @@ -23189,6 +24391,54 @@ const docTemplate = `{ } } }, + "handler.GenericDataListResponse-poam_PoamItemControlLink": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/poam.PoamItemControlLink" + } + } + } + }, + "handler.GenericDataListResponse-poam_PoamItemEvidenceLink": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/poam.PoamItemEvidenceLink" + } + } + } + }, + "handler.GenericDataListResponse-poam_PoamItemFindingLink": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/poam.PoamItemFindingLink" + } + } + } + }, + "handler.GenericDataListResponse-poam_PoamItemRiskLink": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/poam.PoamItemRiskLink" + } + } + } + }, "handler.GenericDataListResponse-relational_Evidence": { "type": "object", "properties": { @@ -23339,6 +24589,32 @@ const docTemplate = `{ } } }, + "handler.GenericDataResponse-handler_milestoneResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/handler.milestoneResponse" + } + ] + } + } + }, + "handler.GenericDataResponse-handler_poamItemResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/handler.poamItemResponse" + } + ] + } + } + }, "handler.GenericDataResponse-handler_riskResponse": { "type": "object", "properties": { @@ -24022,59 +25298,111 @@ const docTemplate = `{ "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/oscal.BuildByPropsResponse" + "$ref": "#/definitions/oscal.BuildByPropsResponse" + } + ] + } + } + }, + "handler.GenericDataResponse-oscal_ImportResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/oscal.ImportResponse" + } + ] + } + } + }, + "handler.GenericDataResponse-oscal_InventoryItemWithSource": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/oscal.InventoryItemWithSource" + } + ] + } + } + }, + "handler.GenericDataResponse-oscal_ProfileComplianceProgress": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/oscal.ProfileComplianceProgress" + } + ] + } + } + }, + "handler.GenericDataResponse-oscal_ProfileHandler": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/oscal.ProfileHandler" } ] } } }, - "handler.GenericDataResponse-oscal_ImportResponse": { + "handler.GenericDataResponse-poam_PoamItemControlLink": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/oscal.ImportResponse" + "$ref": "#/definitions/poam.PoamItemControlLink" } ] } } }, - "handler.GenericDataResponse-oscal_InventoryItemWithSource": { + "handler.GenericDataResponse-poam_PoamItemEvidenceLink": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/oscal.InventoryItemWithSource" + "$ref": "#/definitions/poam.PoamItemEvidenceLink" } ] } } }, - "handler.GenericDataResponse-oscal_ProfileComplianceProgress": { + "handler.GenericDataResponse-poam_PoamItemFindingLink": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/oscal.ProfileComplianceProgress" + "$ref": "#/definitions/poam.PoamItemFindingLink" } ] } } }, - "handler.GenericDataResponse-oscal_ProfileHandler": { + "handler.GenericDataResponse-poam_PoamItemRiskLink": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/oscal.ProfileHandler" + "$ref": "#/definitions/poam.PoamItemRiskLink" } ] } @@ -24362,8 +25690,291 @@ const docTemplate = `{ } } }, - "handler.addControlLinkRequest": { + "handler.addControlLinkRequest": { + "type": "object", + "properties": { + "catalogId": { + "type": "string" + }, + "controlId": { + "type": "string" + } + } + }, + "handler.addEvidenceLinkRequest": { + "type": "object", + "properties": { + "evidenceId": { + "type": "string" + } + } + }, + "handler.addLinkRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, + "handler.addSubjectLinkRequest": { + "type": "object", + "properties": { + "subjectId": { + "type": "string" + } + } + }, + "handler.controlLinkResponse": { + "type": "object", + "properties": { + "catalogId": { + "type": "string" + }, + "controlId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "poamItemId": { + "type": "string" + } + } + }, + "handler.createFilterRequest": { + "type": "object", + "required": [ + "filter", + "name" + ], + "properties": { + "components": { + "type": "array", + "items": { + "type": "string" + } + }, + "controls": { + "type": "array", + "items": { + "type": "string" + } + }, + "filter": { + "$ref": "#/definitions/labelfilter.Filter" + }, + "name": { + "type": "string" + } + } + }, + "handler.createMilestoneRequest": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "orderIndex": { + "description": "OrderIndex is a pointer so that clients can explicitly set 0 without it\nbeing indistinguishable from an omitted field.", + "type": "integer" + }, + "scheduledCompletionDate": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "handler.createPoamItemRequest": { + "type": "object", + "required": [ + "sspId", + "title" + ], + "properties": { + "acceptanceRationale": { + "type": "string" + }, + "controlRefs": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.poamControlRefRequest" + } + }, + "createdFromRiskId": { + "type": "string" + }, + "description": { + "type": "string" + }, + "evidenceIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "findingIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "milestones": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.createMilestoneRequest" + } + }, + "plannedCompletionDate": { + "type": "string" + }, + "primaryOwnerUserId": { + "type": "string" + }, + "riskIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "sourceType": { + "type": "string" + }, + "sspId": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "handler.createRiskRequest": { + "type": "object", + "properties": { + "acceptanceJustification": { + "type": "string" + }, + "description": { + "type": "string" + }, + "impact": { + "type": "string" + }, + "lastReviewedAt": { + "type": "string" + }, + "likelihood": { + "type": "string" + }, + "ownerAssignments": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.riskOwnerAssignmentRequest" + } + }, + "primaryOwnerUserId": { + "type": "string" + }, + "reviewDeadline": { + "type": "string" + }, + "riskTemplateId": { + "type": "string" + }, + "sspId": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "handler.evidenceLinkResponse": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "evidenceId": { + "type": "string" + }, + "poamItemId": { + "type": "string" + } + } + }, + "handler.findingLinkResponse": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "findingId": { + "type": "string" + }, + "poamItemId": { + "type": "string" + } + } + }, + "handler.milestoneResponse": { + "type": "object", + "properties": { + "completionDate": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "orderIndex": { + "type": "integer" + }, + "poamItemId": { + "type": "string" + }, + "scheduledCompletionDate": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, + "handler.poamControlRefRequest": { "type": "object", + "required": [ + "catalogId", + "controlId" + ], "properties": { "catalogId": { "type": "string" @@ -24373,80 +25984,67 @@ const docTemplate = `{ } } }, - "handler.addEvidenceLinkRequest": { + "handler.poamItemResponse": { "type": "object", "properties": { - "evidenceId": { + "acceptanceRationale": { "type": "string" - } - } - }, - "handler.addSubjectLinkRequest": { - "type": "object", - "properties": { - "subjectId": { + }, + "completedAt": { "type": "string" - } - } - }, - "handler.createFilterRequest": { - "type": "object", - "required": [ - "filter", - "name" - ], - "properties": { - "components": { - "type": "array", - "items": { - "type": "string" - } }, - "controls": { + "controlLinks": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/handler.controlLinkResponse" } }, - "filter": { - "$ref": "#/definitions/labelfilter.Filter" - }, - "name": { + "createdAt": { "type": "string" - } - } - }, - "handler.createRiskRequest": { - "type": "object", - "properties": { - "acceptanceJustification": { + }, + "createdFromRiskId": { "type": "string" }, "description": { "type": "string" }, - "impact": { - "type": "string" + "evidenceLinks": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.evidenceLinkResponse" + } }, - "lastReviewedAt": { + "findingLinks": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.findingLinkResponse" + } + }, + "id": { "type": "string" }, - "likelihood": { + "lastStatusChangeAt": { "type": "string" }, - "ownerAssignments": { + "milestones": { "type": "array", "items": { - "$ref": "#/definitions/handler.riskOwnerAssignmentRequest" + "$ref": "#/definitions/handler.milestoneResponse" } }, - "primaryOwnerUserId": { + "plannedCompletionDate": { "type": "string" }, - "reviewDeadline": { + "primaryOwnerUserId": { "type": "string" }, - "riskTemplateId": { + "riskLinks": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.riskLinkResponse" + } + }, + "sourceType": { "type": "string" }, "sspId": { @@ -24457,6 +26055,9 @@ const docTemplate = `{ }, "title": { "type": "string" + }, + "updatedAt": { + "type": "string" } } }, @@ -24488,6 +26089,20 @@ const docTemplate = `{ } } }, + "handler.riskLinkResponse": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "poamItemId": { + "type": "string" + }, + "riskId": { + "type": "string" + } + } + }, "handler.riskOwnerAssignmentRequest": { "type": "object", "properties": { @@ -24605,6 +26220,98 @@ const docTemplate = `{ } } }, + "handler.updateMilestoneRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "orderIndex": { + "type": "integer" + }, + "scheduledCompletionDate": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "handler.updatePoamItemRequest": { + "type": "object", + "properties": { + "acceptanceRationale": { + "type": "string" + }, + "addControlRefs": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.poamControlRefRequest" + } + }, + "addEvidenceIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "addFindingIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "addRiskIds": { + "description": "Link management — add/remove in the same call as scalar updates.", + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string" + }, + "plannedCompletionDate": { + "type": "string" + }, + "primaryOwnerUserId": { + "type": "string" + }, + "removeControlRefs": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.poamControlRefRequest" + } + }, + "removeEvidenceIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "removeFindingIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "removeRiskIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "handler.updateRiskRequest": { "type": "object", "properties": { @@ -24703,46 +26410,32 @@ const docTemplate = `{ }, "oscal.BuildByPropsRequest": { "type": "object", - "required": [ - "catalog-id", - "match-strategy", - "rules", - "title" - ], "properties": { - "catalog-id": { - "type": "string", - "example": "9b0c9c43-2722-4bbb-b132-13d34fb94d45" + "catalogId": { + "type": "string" }, - "match-strategy": { - "allOf": [ - { - "$ref": "#/definitions/oscal.MatchStrategy" - } - ], - "example": "all" + "matchStrategy": { + "description": "all | any", + "type": "string" }, "rules": { "type": "array", - "minItems": 1, "items": { "$ref": "#/definitions/oscal.rule" } }, "title": { - "type": "string", - "example": "My Custom Profile" + "type": "string" }, "version": { - "type": "string", - "example": "1.0.0" + "type": "string" } } }, "oscal.BuildByPropsResponse": { "type": "object", "properties": { - "control-ids": { + "controlIds": { "type": "array", "items": { "type": "string" @@ -24751,7 +26444,7 @@ const docTemplate = `{ "profile": { "$ref": "#/definitions/oscalTypes_1_1_3.Profile" }, - "profile-id": { + "profileId": { "type": "string" } } @@ -24858,17 +26551,6 @@ const docTemplate = `{ } } }, - "oscal.MatchStrategy": { - "type": "string", - "enum": [ - "all", - "any" - ], - "x-enum-varnames": [ - "MatchStrategyAll", - "MatchStrategyAny" - ] - }, "oscal.ProfileComplianceControl": { "type": "object", "properties": { @@ -25021,21 +26703,6 @@ const docTemplate = `{ "oscal.ProfileHandler": { "type": "object" }, - "oscal.RuleOperator": { - "type": "string", - "enum": [ - "equals", - "contains", - "regex", - "in" - ], - "x-enum-varnames": [ - "RuleOperatorEquals", - "RuleOperatorContains", - "RuleOperatorRegex", - "RuleOperatorIn" - ] - }, "oscal.SystemComponentRequest": { "type": "object", "properties": { @@ -25091,30 +26758,19 @@ const docTemplate = `{ }, "oscal.rule": { "type": "object", - "required": [ - "operator", - "value" - ], "properties": { "name": { - "type": "string", - "example": "class" + "type": "string" }, "ns": { - "type": "string", - "example": "http://csrc.nist.gov/ns/oscal" + "type": "string" }, "operator": { - "allOf": [ - { - "$ref": "#/definitions/oscal.RuleOperator" - } - ], - "example": "equals" + "description": "equals | contains | regex | in", + "type": "string" }, "value": { - "type": "string", - "example": "technical" + "type": "string" } } }, @@ -29304,6 +30960,65 @@ const docTemplate = `{ } } }, + "poam.PoamItemControlLink": { + "type": "object", + "properties": { + "catalogId": { + "type": "string" + }, + "controlId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "poamItemId": { + "type": "string" + } + } + }, + "poam.PoamItemEvidenceLink": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "evidenceId": { + "type": "string" + }, + "poamItemId": { + "type": "string" + } + } + }, + "poam.PoamItemFindingLink": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "findingId": { + "type": "string" + }, + "poamItemId": { + "type": "string" + } + } + }, + "poam.PoamItemRiskLink": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "poamItemId": { + "type": "string" + }, + "riskId": { + "type": "string" + } + } + }, "relational.Action": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 8405552f..8d032848 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -17033,45 +17033,50 @@ } } }, - "/risk-templates": { + "/poam-items": { "get": { - "description": "List risk templates with optional filters and pagination.", "produces": [ "application/json" ], "tags": [ - "Risk Templates" + "POAM Items" ], - "summary": "List risk templates", + "summary": "List POAM items", "parameters": [ { "type": "string", - "description": "Plugin ID", - "name": "pluginId", + "description": "Filter by status (open|in-progress|completed|overdue)", + "name": "status", "in": "query" }, { "type": "string", - "description": "Policy package", - "name": "policyPackage", + "description": "Filter by SSP UUID", + "name": "sspId", "in": "query" }, { - "type": "boolean", - "description": "Active flag", - "name": "isActive", + "type": "string", + "description": "Filter by linked risk UUID", + "name": "riskId", "in": "query" }, { - "type": "integer", - "description": "Page number", - "name": "page", + "type": "string", + "description": "Filter by planned_completion_date before (RFC3339)", + "name": "deadlineBefore", "in": "query" }, { - "type": "integer", - "description": "Page size", - "name": "limit", + "type": "boolean", + "description": "Return only overdue items", + "name": "overdueOnly", + "in": "query" + }, + { + "type": "string", + "description": "Filter by primary_owner_user_id UUID", + "name": "ownerRef", "in": "query" } ], @@ -17079,7 +17084,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/service.ListResponse-templates_riskTemplateResponse" + "$ref": "#/definitions/handler.GenericDataListResponse-handler_poamItemResponse" } }, "400": { @@ -17102,7 +17107,6 @@ ] }, "post": { - "description": "Create a risk template with threat references and remediation template/tasks.", "consumes": [ "application/json" ], @@ -17110,17 +17114,17 @@ "application/json" ], "tags": [ - "Risk Templates" + "POAM Items" ], - "summary": "Create risk template", + "summary": "Create a POAM item", "parameters": [ { - "description": "Risk template payload", - "name": "template", + "description": "POAM item payload", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/templates.upsertRiskTemplateRequest" + "$ref": "#/definitions/handler.createPoamItemRequest" } } ], @@ -17128,7 +17132,7 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/templates.riskTemplateDataResponse" + "$ref": "#/definitions/handler.GenericDataResponse-handler_poamItemResponse" } }, "400": { @@ -17137,6 +17141,12 @@ "$ref": "#/definitions/api.Error" } }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -17151,20 +17161,19 @@ ] } }, - "/risk-templates/{id}": { + "/poam-items/{id}": { "get": { - "description": "Get a risk template by ID.", "produces": [ "application/json" ], "tags": [ - "Risk Templates" + "POAM Items" ], - "summary": "Get risk template", + "summary": "Get a POAM item", "parameters": [ { "type": "string", - "description": "Risk Template ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true @@ -17174,7 +17183,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/templates.riskTemplateDataResponse" + "$ref": "#/definitions/handler.GenericDataResponse-handler_poamItemResponse" } }, "400": { @@ -17203,7 +17212,6 @@ ] }, "put": { - "description": "Update a risk template and atomically replace threat refs and remediation tasks.", "consumes": [ "application/json" ], @@ -17211,24 +17219,24 @@ "application/json" ], "tags": [ - "Risk Templates" + "POAM Items" ], - "summary": "Update risk template", + "summary": "Update a POAM item", "parameters": [ { "type": "string", - "description": "Risk Template ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true }, { - "description": "Risk template payload", - "name": "template", + "description": "Update payload", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/templates.upsertRiskTemplateRequest" + "$ref": "#/definitions/handler.updatePoamItemRequest" } } ], @@ -17236,7 +17244,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/templates.riskTemplateDataResponse" + "$ref": "#/definitions/handler.GenericDataResponse-handler_poamItemResponse" } }, "400": { @@ -17265,18 +17273,14 @@ ] }, "delete": { - "description": "Delete a risk template and its associated threat references and remediation data.", - "produces": [ - "application/json" - ], "tags": [ - "Risk Templates" + "POAM Items" ], - "summary": "Delete risk template", + "summary": "Delete a POAM item", "parameters": [ { "type": "string", - "description": "Risk Template ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true @@ -17312,101 +17316,29 @@ ] } }, - "/risks": { + "/poam-items/{id}/controls": { "get": { - "description": "Lists risk register entries with filtering, sorting, and pagination.", "produces": [ "application/json" ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "List risks", + "summary": "List linked controls", "parameters": [ { "type": "string", - "description": "Risk status", - "name": "status", - "in": "query" - }, - { - "type": "string", - "description": "Risk likelihood", - "name": "likelihood", - "in": "query" - }, - { - "type": "string", - "description": "Risk impact", - "name": "impact", - "in": "query" - }, - { - "type": "string", - "description": "SSP ID", - "name": "sspId", - "in": "query" - }, - { - "type": "string", - "description": "Control ID", - "name": "controlId", - "in": "query" - }, - { - "type": "string", - "description": "Evidence ID", - "name": "evidenceId", - "in": "query" - }, - { - "type": "string", - "description": "Owner kind", - "name": "ownerKind", - "in": "query" - }, - { - "type": "string", - "description": "Owner reference", - "name": "ownerRef", - "in": "query" - }, - { - "type": "string", - "description": "Review deadline upper bound (RFC3339)", - "name": "reviewDeadlineBefore", - "in": "query" - }, - { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Page size", - "name": "limit", - "in": "query" - }, - { - "type": "string", - "description": "Sort field", - "name": "sort", - "in": "query" - }, - { - "type": "string", - "description": "Sort order (asc|desc)", - "name": "order", - "in": "query" + "description": "POAM item ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/service.ListResponse-handler_riskResponse" + "$ref": "#/definitions/handler.GenericDataListResponse-poam_PoamItemControlLink" } }, "400": { @@ -17415,6 +17347,12 @@ "$ref": "#/definitions/api.Error" } }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -17429,7 +17367,6 @@ ] }, "post": { - "description": "Creates a risk register entry.", "consumes": [ "application/json" ], @@ -17437,17 +17374,24 @@ "application/json" ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "Create risk", + "summary": "Add a control link", "parameters": [ { - "description": "Risk payload", - "name": "risk", + "type": "string", + "description": "POAM item ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Control ref payload", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handler.createRiskRequest" + "$ref": "#/definitions/handler.poamControlRefRequest" } } ], @@ -17455,7 +17399,7 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + "$ref": "#/definitions/handler.GenericDataResponse-poam_PoamItemControlLink" } }, "400": { @@ -17464,8 +17408,8 @@ "$ref": "#/definitions/api.Error" } }, - "401": { - "description": "Unauthorized", + "404": { + "description": "Not Found", "schema": { "$ref": "#/definitions/api.Error" } @@ -17484,31 +17428,38 @@ ] } }, - "/risks/{id}": { - "get": { - "description": "Retrieves a risk register entry by ID.", - "produces": [ - "application/json" - ], + "/poam-items/{id}/controls/{catalogId}/{controlId}": { + "delete": { "tags": [ - "Risks" + "POAM Items" ], - "summary": "Get risk", + "summary": "Delete a control link", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true + }, + { + "type": "string", + "description": "Catalog ID", + "name": "catalogId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Control ID", + "name": "controlId", + "in": "path", + "required": true } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" - } + "204": { + "description": "No Content" }, "400": { "description": "Bad Request", @@ -17534,42 +17485,31 @@ "OAuth2Password": [] } ] - }, - "put": { - "description": "Updates a risk register entry by ID.", - "consumes": [ - "application/json" - ], + } + }, + "/poam-items/{id}/evidence": { + "get": { "produces": [ "application/json" ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "Update risk", + "summary": "List linked evidence", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true - }, - { - "description": "Risk payload", - "name": "risk", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handler.updateRiskRequest" - } } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + "$ref": "#/definitions/handler.GenericDataListResponse-poam_PoamItemEvidenceLink" } }, "400": { @@ -17597,24 +17537,41 @@ } ] }, - "delete": { - "description": "Deletes a risk register entry and link rows by ID.", + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "Delete risk", + "summary": "Add an evidence link", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true + }, + { + "description": "Evidence ID payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.addLinkRequest" + } } ], "responses": { - "204": { - "description": "No Content" + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-poam_PoamItemEvidenceLink" + } }, "400": { "description": "Bad Request", @@ -17642,43 +17599,31 @@ ] } }, - "/risks/{id}/accept": { - "post": { - "description": "Accepts a risk with required justification and a future review deadline.", - "consumes": [ - "application/json" + "/poam-items/{id}/evidence/{evidenceId}": { + "delete": { + "tags": [ + "POAM Items" ], - "produces": [ - "application/json" - ], - "tags": [ - "Risks" - ], - "summary": "Accept risk", + "summary": "Delete an evidence link", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true }, { - "description": "Accept payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handler.acceptRiskRequest" - } + "type": "string", + "description": "Evidence ID", + "name": "evidenceId", + "in": "path", + "required": true } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" - } + "204": { + "description": "No Content" }, "400": { "description": "Bad Request", @@ -17706,42 +17651,29 @@ ] } }, - "/risks/{id}/components": { + "/poam-items/{id}/findings": { "get": { - "description": "Lists components linked to a risk.", "produces": [ "application/json" ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "List risk component links", + "summary": "List linked findings", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true - }, - { - "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-risks_RiskComponentLink" + "$ref": "#/definitions/handler.GenericDataListResponse-poam_PoamItemFindingLink" } }, "400": { @@ -17770,7 +17702,6 @@ ] }, "post": { - "description": "Idempotently links a component to a risk.", "consumes": [ "application/json" ], @@ -17778,24 +17709,24 @@ "application/json" ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "Link component to risk", + "summary": "Add a finding link", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true }, { - "description": "Component link payload", - "name": "link", + "description": "Finding ID payload", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handler.addComponentLinkRequest" + "$ref": "#/definitions/handler.addLinkRequest" } } ], @@ -17803,7 +17734,7 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-risks_RiskComponentLink" + "$ref": "#/definitions/handler.GenericDataResponse-poam_PoamItemFindingLink" } }, "400": { @@ -17832,42 +17763,81 @@ ] } }, - "/risks/{id}/controls": { - "get": { - "description": "Lists controls linked to a risk.", - "produces": [ - "application/json" - ], + "/poam-items/{id}/findings/{findingId}": { + "delete": { "tags": [ - "Risks" + "POAM Items" ], - "summary": "List risk control links", + "summary": "Delete a finding link", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true }, { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" + "type": "string", + "description": "Finding ID", + "name": "findingId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ { - "type": "integer", - "description": "Page size", - "name": "limit", - "in": "query" + "OAuth2Password": [] + } + ] + } + }, + "/poam-items/{id}/milestones": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "POAM Items" + ], + "summary": "List milestones for a POAM item", + "parameters": [ + { + "type": "string", + "description": "POAM item ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/service.ListResponse-risks_RiskControlLink" + "$ref": "#/definitions/handler.GenericDataListResponse-handler_milestoneResponse" } }, "400": { @@ -17896,7 +17866,6 @@ ] }, "post": { - "description": "Idempotently links a control to a risk.", "consumes": [ "application/json" ], @@ -17904,24 +17873,24 @@ "application/json" ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "Link control to risk", + "summary": "Add a milestone to a POAM item", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true }, { - "description": "Control link payload", - "name": "link", + "description": "Milestone payload", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handler.addControlLinkRequest" + "$ref": "#/definitions/handler.createMilestoneRequest" } } ], @@ -17929,7 +17898,7 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-risks_RiskControlLink" + "$ref": "#/definitions/handler.GenericDataResponse-handler_milestoneResponse" } }, "400": { @@ -17958,42 +17927,48 @@ ] } }, - "/risks/{id}/evidence": { - "get": { - "description": "Lists evidence IDs linked to a risk.", + "/poam-items/{id}/milestones/{milestoneId}": { + "put": { + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "List risk evidence links", + "summary": "Update a milestone", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true }, { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" + "type": "string", + "description": "Milestone ID", + "name": "milestoneId", + "in": "path", + "required": true }, { - "type": "integer", - "description": "Page size", - "name": "limit", - "in": "query" + "description": "Milestone update payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.updateMilestoneRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/service.ListResponse-uuid_UUID" + "$ref": "#/definitions/handler.GenericDataResponse-handler_milestoneResponse" } }, "400": { @@ -18021,42 +17996,30 @@ } ] }, - "post": { - "description": "Idempotently links an evidence item to a risk.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], + "delete": { "tags": [ - "Risks" + "POAM Items" ], - "summary": "Link evidence to risk", + "summary": "Delete a milestone", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true }, { - "description": "Evidence link payload", - "name": "link", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handler.addEvidenceLinkRequest" - } + "type": "string", + "description": "Milestone ID", + "name": "milestoneId", + "in": "path", + "required": true } ], "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-risks_RiskEvidenceLink" - } + "204": { + "description": "No Content" }, "400": { "description": "Bad Request", @@ -18084,32 +18047,30 @@ ] } }, - "/risks/{id}/evidence/{evidenceId}": { - "delete": { - "description": "Deletes the link between a risk and evidence item.", + "/poam-items/{id}/risks": { + "get": { + "produces": [ + "application/json" + ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "Delete risk evidence link", + "summary": "List linked risks", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true - }, - { - "type": "string", - "description": "Evidence ID", - "name": "evidenceId", - "in": "path", - "required": true } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-poam_PoamItemRiskLink" + } }, "400": { "description": "Bad Request", @@ -18135,11 +18096,8 @@ "OAuth2Password": [] } ] - } - }, - "/risks/{id}/review": { + }, "post": { - "description": "Records a structured review for an accepted risk. nextReviewDeadline is required for decision=extend and must be omitted for decision=reopen.", "consumes": [ "application/json" ], @@ -18147,32 +18105,32 @@ "application/json" ], "tags": [ - "Risks" + "POAM Items" ], - "summary": "Review risk", + "summary": "Add a risk link", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true }, { - "description": "Review payload", + "description": "Risk ID payload", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handler.reviewRiskRequest" + "$ref": "#/definitions/handler.addLinkRequest" } } ], "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + "$ref": "#/definitions/handler.GenericDataResponse-poam_PoamItemRiskLink" } }, "400": { @@ -18201,43 +18159,31 @@ ] } }, - "/risks/{id}/subjects": { - "get": { - "description": "Lists subjects linked to a risk.", - "produces": [ - "application/json" - ], + "/poam-items/{id}/risks/{riskId}": { + "delete": { "tags": [ - "Risks" + "POAM Items" ], - "summary": "List risk subject links", + "summary": "Delete a risk link", "parameters": [ { "type": "string", - "description": "Risk ID", + "description": "POAM item ID", "name": "id", "in": "path", "required": true }, { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Page size", - "name": "limit", - "in": "query" + "type": "string", + "description": "Risk ID", + "name": "riskId", + "in": "path", + "required": true } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/service.ListResponse-risks_RiskSubjectLink" - } + "204": { + "description": "No Content" }, "400": { "description": "Bad Request", @@ -18263,7 +18209,1239 @@ "OAuth2Password": [] } ] - }, + } + }, + "/risk-templates": { + "get": { + "description": "List risk templates with optional filters and pagination.", + "produces": [ + "application/json" + ], + "tags": [ + "Risk Templates" + ], + "summary": "List risk templates", + "parameters": [ + { + "type": "string", + "description": "Plugin ID", + "name": "pluginId", + "in": "query" + }, + { + "type": "string", + "description": "Policy package", + "name": "policyPackage", + "in": "query" + }, + { + "type": "boolean", + "description": "Active flag", + "name": "isActive", + "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-templates_riskTemplateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "post": { + "description": "Create a risk template with threat references and remediation template/tasks.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risk Templates" + ], + "summary": "Create risk template", + "parameters": [ + { + "description": "Risk template payload", + "name": "template", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/templates.upsertRiskTemplateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/templates.riskTemplateDataResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/risk-templates/{id}": { + "get": { + "description": "Get a risk template by ID.", + "produces": [ + "application/json" + ], + "tags": [ + "Risk Templates" + ], + "summary": "Get risk template", + "parameters": [ + { + "type": "string", + "description": "Risk Template ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/templates.riskTemplateDataResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "put": { + "description": "Update a risk template and atomically replace threat refs and remediation tasks.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risk Templates" + ], + "summary": "Update risk template", + "parameters": [ + { + "type": "string", + "description": "Risk Template ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Risk template payload", + "name": "template", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/templates.upsertRiskTemplateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/templates.riskTemplateDataResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "delete": { + "description": "Delete a risk template and its associated threat references and remediation data.", + "produces": [ + "application/json" + ], + "tags": [ + "Risk Templates" + ], + "summary": "Delete risk template", + "parameters": [ + { + "type": "string", + "description": "Risk Template ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/risks": { + "get": { + "description": "Lists risk register entries with filtering, sorting, and pagination.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "List risks", + "parameters": [ + { + "type": "string", + "description": "Risk status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Risk likelihood", + "name": "likelihood", + "in": "query" + }, + { + "type": "string", + "description": "Risk impact", + "name": "impact", + "in": "query" + }, + { + "type": "string", + "description": "SSP ID", + "name": "sspId", + "in": "query" + }, + { + "type": "string", + "description": "Control ID", + "name": "controlId", + "in": "query" + }, + { + "type": "string", + "description": "Evidence ID", + "name": "evidenceId", + "in": "query" + }, + { + "type": "string", + "description": "Owner kind", + "name": "ownerKind", + "in": "query" + }, + { + "type": "string", + "description": "Owner reference", + "name": "ownerRef", + "in": "query" + }, + { + "type": "string", + "description": "Review deadline upper bound (RFC3339)", + "name": "reviewDeadlineBefore", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Sort field", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "description": "Sort order (asc|desc)", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/service.ListResponse-handler_riskResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "post": { + "description": "Creates a risk register entry.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Create risk", + "parameters": [ + { + "description": "Risk payload", + "name": "risk", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.createRiskRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "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": [] + } + ] + } + }, + "/risks/{id}": { + "get": { + "description": "Retrieves a risk register entry by ID.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Get risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "put": { + "description": "Updates a risk register entry by ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Update risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Risk payload", + "name": "risk", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.updateRiskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "delete": { + "description": "Deletes a risk register entry and link rows by ID.", + "tags": [ + "Risks" + ], + "summary": "Delete risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/risks/{id}/accept": { + "post": { + "description": "Accepts a risk with required justification and a future review deadline.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Accept risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Accept payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.acceptRiskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/risks/{id}/components": { + "get": { + "description": "Lists components linked to a risk.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "List risk component links", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "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-risks_RiskComponentLink" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "post": { + "description": "Idempotently links a component to a risk.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Link component to risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Component link payload", + "name": "link", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.addComponentLinkRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-risks_RiskComponentLink" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/risks/{id}/controls": { + "get": { + "description": "Lists controls linked to a risk.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "List risk control links", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "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-risks_RiskControlLink" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "post": { + "description": "Idempotently links a control to a risk.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Link control to risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Control link payload", + "name": "link", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.addControlLinkRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-risks_RiskControlLink" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/risks/{id}/evidence": { + "get": { + "description": "Lists evidence IDs linked to a risk.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "List risk evidence links", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "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-uuid_UUID" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "post": { + "description": "Idempotently links an evidence item to a risk.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Link evidence to risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Evidence link payload", + "name": "link", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.addEvidenceLinkRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-risks_RiskEvidenceLink" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/risks/{id}/evidence/{evidenceId}": { + "delete": { + "description": "Deletes the link between a risk and evidence item.", + "tags": [ + "Risks" + ], + "summary": "Delete risk evidence link", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Evidence ID", + "name": "evidenceId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/risks/{id}/review": { + "post": { + "description": "Records a structured review for an accepted risk. nextReviewDeadline is required for decision=extend and must be omitted for decision=reopen.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Review risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Review payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.reviewRiskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/risks/{id}/subjects": { + "get": { + "description": "Lists subjects linked to a risk.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "List risk subject links", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "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-risks_RiskSubjectLink" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, "post": { "description": "Idempotently links a subject to a risk.", "consumes": [ @@ -22787,6 +23965,30 @@ } } }, + "handler.GenericDataListResponse-handler_milestoneResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/handler.milestoneResponse" + } + } + } + }, + "handler.GenericDataListResponse-handler_poamItemResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/handler.poamItemResponse" + } + } + } + }, "handler.GenericDataListResponse-oscalTypes_1_1_3_AssessmentPlan": { "type": "object", "properties": { @@ -23183,6 +24385,54 @@ } } }, + "handler.GenericDataListResponse-poam_PoamItemControlLink": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/poam.PoamItemControlLink" + } + } + } + }, + "handler.GenericDataListResponse-poam_PoamItemEvidenceLink": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/poam.PoamItemEvidenceLink" + } + } + } + }, + "handler.GenericDataListResponse-poam_PoamItemFindingLink": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/poam.PoamItemFindingLink" + } + } + } + }, + "handler.GenericDataListResponse-poam_PoamItemRiskLink": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/poam.PoamItemRiskLink" + } + } + } + }, "handler.GenericDataListResponse-relational_Evidence": { "type": "object", "properties": { @@ -23333,6 +24583,32 @@ } } }, + "handler.GenericDataResponse-handler_milestoneResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/handler.milestoneResponse" + } + ] + } + } + }, + "handler.GenericDataResponse-handler_poamItemResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/handler.poamItemResponse" + } + ] + } + } + }, "handler.GenericDataResponse-handler_riskResponse": { "type": "object", "properties": { @@ -24016,59 +25292,111 @@ "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/oscal.BuildByPropsResponse" + "$ref": "#/definitions/oscal.BuildByPropsResponse" + } + ] + } + } + }, + "handler.GenericDataResponse-oscal_ImportResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/oscal.ImportResponse" + } + ] + } + } + }, + "handler.GenericDataResponse-oscal_InventoryItemWithSource": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/oscal.InventoryItemWithSource" + } + ] + } + } + }, + "handler.GenericDataResponse-oscal_ProfileComplianceProgress": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/oscal.ProfileComplianceProgress" + } + ] + } + } + }, + "handler.GenericDataResponse-oscal_ProfileHandler": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/oscal.ProfileHandler" } ] } } }, - "handler.GenericDataResponse-oscal_ImportResponse": { + "handler.GenericDataResponse-poam_PoamItemControlLink": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/oscal.ImportResponse" + "$ref": "#/definitions/poam.PoamItemControlLink" } ] } } }, - "handler.GenericDataResponse-oscal_InventoryItemWithSource": { + "handler.GenericDataResponse-poam_PoamItemEvidenceLink": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/oscal.InventoryItemWithSource" + "$ref": "#/definitions/poam.PoamItemEvidenceLink" } ] } } }, - "handler.GenericDataResponse-oscal_ProfileComplianceProgress": { + "handler.GenericDataResponse-poam_PoamItemFindingLink": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/oscal.ProfileComplianceProgress" + "$ref": "#/definitions/poam.PoamItemFindingLink" } ] } } }, - "handler.GenericDataResponse-oscal_ProfileHandler": { + "handler.GenericDataResponse-poam_PoamItemRiskLink": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/oscal.ProfileHandler" + "$ref": "#/definitions/poam.PoamItemRiskLink" } ] } @@ -24356,8 +25684,291 @@ } } }, - "handler.addControlLinkRequest": { + "handler.addControlLinkRequest": { + "type": "object", + "properties": { + "catalogId": { + "type": "string" + }, + "controlId": { + "type": "string" + } + } + }, + "handler.addEvidenceLinkRequest": { + "type": "object", + "properties": { + "evidenceId": { + "type": "string" + } + } + }, + "handler.addLinkRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, + "handler.addSubjectLinkRequest": { + "type": "object", + "properties": { + "subjectId": { + "type": "string" + } + } + }, + "handler.controlLinkResponse": { + "type": "object", + "properties": { + "catalogId": { + "type": "string" + }, + "controlId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "poamItemId": { + "type": "string" + } + } + }, + "handler.createFilterRequest": { + "type": "object", + "required": [ + "filter", + "name" + ], + "properties": { + "components": { + "type": "array", + "items": { + "type": "string" + } + }, + "controls": { + "type": "array", + "items": { + "type": "string" + } + }, + "filter": { + "$ref": "#/definitions/labelfilter.Filter" + }, + "name": { + "type": "string" + } + } + }, + "handler.createMilestoneRequest": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "orderIndex": { + "description": "OrderIndex is a pointer so that clients can explicitly set 0 without it\nbeing indistinguishable from an omitted field.", + "type": "integer" + }, + "scheduledCompletionDate": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "handler.createPoamItemRequest": { + "type": "object", + "required": [ + "sspId", + "title" + ], + "properties": { + "acceptanceRationale": { + "type": "string" + }, + "controlRefs": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.poamControlRefRequest" + } + }, + "createdFromRiskId": { + "type": "string" + }, + "description": { + "type": "string" + }, + "evidenceIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "findingIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "milestones": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.createMilestoneRequest" + } + }, + "plannedCompletionDate": { + "type": "string" + }, + "primaryOwnerUserId": { + "type": "string" + }, + "riskIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "sourceType": { + "type": "string" + }, + "sspId": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "handler.createRiskRequest": { + "type": "object", + "properties": { + "acceptanceJustification": { + "type": "string" + }, + "description": { + "type": "string" + }, + "impact": { + "type": "string" + }, + "lastReviewedAt": { + "type": "string" + }, + "likelihood": { + "type": "string" + }, + "ownerAssignments": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.riskOwnerAssignmentRequest" + } + }, + "primaryOwnerUserId": { + "type": "string" + }, + "reviewDeadline": { + "type": "string" + }, + "riskTemplateId": { + "type": "string" + }, + "sspId": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "handler.evidenceLinkResponse": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "evidenceId": { + "type": "string" + }, + "poamItemId": { + "type": "string" + } + } + }, + "handler.findingLinkResponse": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "findingId": { + "type": "string" + }, + "poamItemId": { + "type": "string" + } + } + }, + "handler.milestoneResponse": { + "type": "object", + "properties": { + "completionDate": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "orderIndex": { + "type": "integer" + }, + "poamItemId": { + "type": "string" + }, + "scheduledCompletionDate": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, + "handler.poamControlRefRequest": { "type": "object", + "required": [ + "catalogId", + "controlId" + ], "properties": { "catalogId": { "type": "string" @@ -24367,80 +25978,67 @@ } } }, - "handler.addEvidenceLinkRequest": { + "handler.poamItemResponse": { "type": "object", "properties": { - "evidenceId": { + "acceptanceRationale": { "type": "string" - } - } - }, - "handler.addSubjectLinkRequest": { - "type": "object", - "properties": { - "subjectId": { + }, + "completedAt": { "type": "string" - } - } - }, - "handler.createFilterRequest": { - "type": "object", - "required": [ - "filter", - "name" - ], - "properties": { - "components": { - "type": "array", - "items": { - "type": "string" - } }, - "controls": { + "controlLinks": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/handler.controlLinkResponse" } }, - "filter": { - "$ref": "#/definitions/labelfilter.Filter" - }, - "name": { + "createdAt": { "type": "string" - } - } - }, - "handler.createRiskRequest": { - "type": "object", - "properties": { - "acceptanceJustification": { + }, + "createdFromRiskId": { "type": "string" }, "description": { "type": "string" }, - "impact": { - "type": "string" + "evidenceLinks": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.evidenceLinkResponse" + } }, - "lastReviewedAt": { + "findingLinks": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.findingLinkResponse" + } + }, + "id": { "type": "string" }, - "likelihood": { + "lastStatusChangeAt": { "type": "string" }, - "ownerAssignments": { + "milestones": { "type": "array", "items": { - "$ref": "#/definitions/handler.riskOwnerAssignmentRequest" + "$ref": "#/definitions/handler.milestoneResponse" } }, - "primaryOwnerUserId": { + "plannedCompletionDate": { "type": "string" }, - "reviewDeadline": { + "primaryOwnerUserId": { "type": "string" }, - "riskTemplateId": { + "riskLinks": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.riskLinkResponse" + } + }, + "sourceType": { "type": "string" }, "sspId": { @@ -24451,6 +26049,9 @@ }, "title": { "type": "string" + }, + "updatedAt": { + "type": "string" } } }, @@ -24482,6 +26083,20 @@ } } }, + "handler.riskLinkResponse": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "poamItemId": { + "type": "string" + }, + "riskId": { + "type": "string" + } + } + }, "handler.riskOwnerAssignmentRequest": { "type": "object", "properties": { @@ -24599,6 +26214,98 @@ } } }, + "handler.updateMilestoneRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "orderIndex": { + "type": "integer" + }, + "scheduledCompletionDate": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "handler.updatePoamItemRequest": { + "type": "object", + "properties": { + "acceptanceRationale": { + "type": "string" + }, + "addControlRefs": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.poamControlRefRequest" + } + }, + "addEvidenceIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "addFindingIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "addRiskIds": { + "description": "Link management — add/remove in the same call as scalar updates.", + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string" + }, + "plannedCompletionDate": { + "type": "string" + }, + "primaryOwnerUserId": { + "type": "string" + }, + "removeControlRefs": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.poamControlRefRequest" + } + }, + "removeEvidenceIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "removeFindingIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "removeRiskIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "handler.updateRiskRequest": { "type": "object", "properties": { @@ -24697,46 +26404,32 @@ }, "oscal.BuildByPropsRequest": { "type": "object", - "required": [ - "catalog-id", - "match-strategy", - "rules", - "title" - ], "properties": { - "catalog-id": { - "type": "string", - "example": "9b0c9c43-2722-4bbb-b132-13d34fb94d45" + "catalogId": { + "type": "string" }, - "match-strategy": { - "allOf": [ - { - "$ref": "#/definitions/oscal.MatchStrategy" - } - ], - "example": "all" + "matchStrategy": { + "description": "all | any", + "type": "string" }, "rules": { "type": "array", - "minItems": 1, "items": { "$ref": "#/definitions/oscal.rule" } }, "title": { - "type": "string", - "example": "My Custom Profile" + "type": "string" }, "version": { - "type": "string", - "example": "1.0.0" + "type": "string" } } }, "oscal.BuildByPropsResponse": { "type": "object", "properties": { - "control-ids": { + "controlIds": { "type": "array", "items": { "type": "string" @@ -24745,7 +26438,7 @@ "profile": { "$ref": "#/definitions/oscalTypes_1_1_3.Profile" }, - "profile-id": { + "profileId": { "type": "string" } } @@ -24852,17 +26545,6 @@ } } }, - "oscal.MatchStrategy": { - "type": "string", - "enum": [ - "all", - "any" - ], - "x-enum-varnames": [ - "MatchStrategyAll", - "MatchStrategyAny" - ] - }, "oscal.ProfileComplianceControl": { "type": "object", "properties": { @@ -25015,21 +26697,6 @@ "oscal.ProfileHandler": { "type": "object" }, - "oscal.RuleOperator": { - "type": "string", - "enum": [ - "equals", - "contains", - "regex", - "in" - ], - "x-enum-varnames": [ - "RuleOperatorEquals", - "RuleOperatorContains", - "RuleOperatorRegex", - "RuleOperatorIn" - ] - }, "oscal.SystemComponentRequest": { "type": "object", "properties": { @@ -25085,30 +26752,19 @@ }, "oscal.rule": { "type": "object", - "required": [ - "operator", - "value" - ], "properties": { "name": { - "type": "string", - "example": "class" + "type": "string" }, "ns": { - "type": "string", - "example": "http://csrc.nist.gov/ns/oscal" + "type": "string" }, "operator": { - "allOf": [ - { - "$ref": "#/definitions/oscal.RuleOperator" - } - ], - "example": "equals" + "description": "equals | contains | regex | in", + "type": "string" }, "value": { - "type": "string", - "example": "technical" + "type": "string" } } }, @@ -29298,6 +30954,65 @@ } } }, + "poam.PoamItemControlLink": { + "type": "object", + "properties": { + "catalogId": { + "type": "string" + }, + "controlId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "poamItemId": { + "type": "string" + } + } + }, + "poam.PoamItemEvidenceLink": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "evidenceId": { + "type": "string" + }, + "poamItemId": { + "type": "string" + } + } + }, + "poam.PoamItemFindingLink": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "findingId": { + "type": "string" + }, + "poamItemId": { + "type": "string" + } + } + }, + "poam.PoamItemRiskLink": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "poamItemId": { + "type": "string" + }, + "riskId": { + "type": "string" + } + } + }, "relational.Action": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d82db2f4..4a9074f8 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -446,6 +446,22 @@ definitions: $ref: '#/definitions/handler.StatusInterval' type: array type: object + handler.GenericDataListResponse-handler_milestoneResponse: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/handler.milestoneResponse' + type: array + type: object + handler.GenericDataListResponse-handler_poamItemResponse: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/handler.poamItemResponse' + type: array + type: object handler.GenericDataListResponse-oscal_InventoryItemWithSource: properties: data: @@ -710,6 +726,38 @@ definitions: $ref: '#/definitions/oscalTypes_1_1_3.SystemUser' type: array type: object + handler.GenericDataListResponse-poam_PoamItemControlLink: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/poam.PoamItemControlLink' + type: array + type: object + handler.GenericDataListResponse-poam_PoamItemEvidenceLink: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/poam.PoamItemEvidenceLink' + type: array + type: object + handler.GenericDataListResponse-poam_PoamItemFindingLink: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/poam.PoamItemFindingLink' + type: array + type: object + handler.GenericDataListResponse-poam_PoamItemRiskLink: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/poam.PoamItemRiskLink' + type: array + type: object handler.GenericDataListResponse-relational_Evidence: properties: data: @@ -800,6 +848,20 @@ definitions: - $ref: '#/definitions/handler.SubscriptionsResponse' description: Items from the list response type: object + handler.GenericDataResponse-handler_milestoneResponse: + properties: + data: + allOf: + - $ref: '#/definitions/handler.milestoneResponse' + description: Items from the list response + type: object + handler.GenericDataResponse-handler_poamItemResponse: + properties: + data: + allOf: + - $ref: '#/definitions/handler.poamItemResponse' + description: Items from the list response + type: object handler.GenericDataResponse-handler_riskResponse: properties: data: @@ -1199,6 +1261,34 @@ definitions: - $ref: '#/definitions/oscalTypes_1_1_3.Task' description: Items from the list response type: object + handler.GenericDataResponse-poam_PoamItemControlLink: + properties: + data: + allOf: + - $ref: '#/definitions/poam.PoamItemControlLink' + description: Items from the list response + type: object + handler.GenericDataResponse-poam_PoamItemEvidenceLink: + properties: + data: + allOf: + - $ref: '#/definitions/poam.PoamItemEvidenceLink' + description: Items from the list response + type: object + handler.GenericDataResponse-poam_PoamItemFindingLink: + properties: + data: + allOf: + - $ref: '#/definitions/poam.PoamItemFindingLink' + description: Items from the list response + type: object + handler.GenericDataResponse-poam_PoamItemRiskLink: + properties: + data: + allOf: + - $ref: '#/definitions/poam.PoamItemRiskLink' + description: Items from the list response + type: object handler.GenericDataResponse-relational_Evidence: properties: data: @@ -1390,11 +1480,29 @@ definitions: evidenceId: type: string type: object + handler.addLinkRequest: + properties: + id: + type: string + required: + - id + type: object handler.addSubjectLinkRequest: properties: subjectId: type: string type: object + handler.controlLinkResponse: + properties: + catalogId: + type: string + controlId: + type: string + createdAt: + type: string + poamItemId: + type: string + type: object handler.createFilterRequest: properties: components: @@ -1413,6 +1521,68 @@ definitions: - filter - name type: object + handler.createMilestoneRequest: + properties: + description: + type: string + orderIndex: + description: |- + OrderIndex is a pointer so that clients can explicitly set 0 without it + being indistinguishable from an omitted field. + type: integer + scheduledCompletionDate: + type: string + status: + type: string + title: + type: string + required: + - title + type: object + handler.createPoamItemRequest: + properties: + acceptanceRationale: + type: string + controlRefs: + items: + $ref: '#/definitions/handler.poamControlRefRequest' + type: array + createdFromRiskId: + type: string + description: + type: string + evidenceIds: + items: + type: string + type: array + findingIds: + items: + type: string + type: array + milestones: + items: + $ref: '#/definitions/handler.createMilestoneRequest' + type: array + plannedCompletionDate: + type: string + primaryOwnerUserId: + type: string + riskIds: + items: + type: string + type: array + sourceType: + type: string + sspId: + type: string + status: + type: string + title: + type: string + required: + - sspId + - title + type: object handler.createRiskRequest: properties: acceptanceJustification: @@ -1442,6 +1612,108 @@ definitions: title: type: string type: object + handler.evidenceLinkResponse: + properties: + createdAt: + type: string + evidenceId: + type: string + poamItemId: + type: string + type: object + handler.findingLinkResponse: + properties: + createdAt: + type: string + findingId: + type: string + poamItemId: + type: string + type: object + handler.milestoneResponse: + properties: + completionDate: + type: string + createdAt: + type: string + description: + type: string + id: + type: string + orderIndex: + type: integer + poamItemId: + type: string + scheduledCompletionDate: + type: string + status: + type: string + title: + type: string + updatedAt: + type: string + type: object + handler.poamControlRefRequest: + properties: + catalogId: + type: string + controlId: + type: string + required: + - catalogId + - controlId + type: object + handler.poamItemResponse: + properties: + acceptanceRationale: + type: string + completedAt: + type: string + controlLinks: + items: + $ref: '#/definitions/handler.controlLinkResponse' + type: array + createdAt: + type: string + createdFromRiskId: + type: string + description: + type: string + evidenceLinks: + items: + $ref: '#/definitions/handler.evidenceLinkResponse' + type: array + findingLinks: + items: + $ref: '#/definitions/handler.findingLinkResponse' + type: array + id: + type: string + lastStatusChangeAt: + type: string + milestones: + items: + $ref: '#/definitions/handler.milestoneResponse' + type: array + plannedCompletionDate: + type: string + primaryOwnerUserId: + type: string + riskLinks: + items: + $ref: '#/definitions/handler.riskLinkResponse' + type: array + sourceType: + type: string + sspId: + type: string + status: + type: string + title: + type: string + updatedAt: + type: string + type: object handler.reviewRiskRequest: properties: decision: @@ -1460,6 +1732,15 @@ definitions: controlId: type: string type: object + handler.riskLinkResponse: + properties: + createdAt: + type: string + poamItemId: + type: string + riskId: + type: string + type: object handler.riskOwnerAssignmentRequest: properties: isPrimary: @@ -1537,6 +1818,67 @@ definitions: updatedAt: type: string type: object + handler.updateMilestoneRequest: + properties: + description: + type: string + orderIndex: + type: integer + scheduledCompletionDate: + type: string + status: + type: string + title: + type: string + type: object + handler.updatePoamItemRequest: + properties: + acceptanceRationale: + type: string + addControlRefs: + items: + $ref: '#/definitions/handler.poamControlRefRequest' + type: array + addEvidenceIds: + items: + type: string + type: array + addFindingIds: + items: + type: string + type: array + addRiskIds: + description: Link management — add/remove in the same call as scalar updates. + items: + type: string + type: array + description: + type: string + plannedCompletionDate: + type: string + primaryOwnerUserId: + type: string + removeControlRefs: + items: + $ref: '#/definitions/handler.poamControlRefRequest' + type: array + removeEvidenceIds: + items: + type: string + type: array + removeFindingIds: + items: + type: string + type: array + removeRiskIds: + items: + type: string + type: array + status: + type: string + title: + type: string + type: object handler.updateRiskRequest: properties: acceptanceJustification: @@ -1603,39 +1945,29 @@ definitions: type: object oscal.BuildByPropsRequest: properties: - catalog-id: - example: 9b0c9c43-2722-4bbb-b132-13d34fb94d45 + catalogId: + type: string + matchStrategy: + description: all | any type: string - match-strategy: - allOf: - - $ref: '#/definitions/oscal.MatchStrategy' - example: all rules: items: $ref: '#/definitions/oscal.rule' - minItems: 1 type: array title: - example: My Custom Profile type: string version: - example: 1.0.0 type: string - required: - - catalog-id - - match-strategy - - rules - - title type: object oscal.BuildByPropsResponse: properties: - control-ids: + controlIds: items: type: string type: array profile: $ref: '#/definitions/oscalTypes_1_1_3.Profile' - profile-id: + profileId: type: string type: object oscal.CreateInventoryItemRequest: @@ -1705,14 +2037,6 @@ definitions: uuid: type: string type: object - oscal.MatchStrategy: - enum: - - all - - any - type: string - x-enum-varnames: - - MatchStrategyAll - - MatchStrategyAny oscal.ProfileComplianceControl: properties: catalogId: @@ -1812,18 +2136,6 @@ definitions: type: object oscal.ProfileHandler: type: object - oscal.RuleOperator: - enum: - - equals - - contains - - regex - - in - type: string - x-enum-varnames: - - RuleOperatorEquals - - RuleOperatorContains - - RuleOperatorRegex - - RuleOperatorIn oscal.SystemComponentRequest: properties: definedComponentId: @@ -1862,21 +2174,14 @@ definitions: oscal.rule: properties: name: - example: class type: string ns: - example: http://csrc.nist.gov/ns/oscal type: string operator: - allOf: - - $ref: '#/definitions/oscal.RuleOperator' - example: equals + description: equals | contains | regex | in + type: string value: - example: technical type: string - required: - - operator - - value type: object oscalTypes_1_1_3.Action: properties: @@ -4621,6 +4926,44 @@ definitions: $ref: '#/definitions/oscalTypes_1_1_3.ResponsibleParty' type: array type: object + poam.PoamItemControlLink: + properties: + catalogId: + type: string + controlId: + type: string + createdAt: + type: string + poamItemId: + type: string + type: object + poam.PoamItemEvidenceLink: + properties: + createdAt: + type: string + evidenceId: + type: string + poamItemId: + type: string + type: object + poam.PoamItemFindingLink: + properties: + createdAt: + type: string + findingId: + type: string + poamItemId: + type: string + type: object + poam.PoamItemRiskLink: + properties: + createdAt: + type: string + poamItemId: + type: string + riskId: + type: string + type: object relational.Action: properties: date: @@ -19251,6 +19594,754 @@ paths: summary: Update a system user tags: - System Security Plans + /poam-items: + get: + parameters: + - description: Filter by status (open|in-progress|completed|overdue) + in: query + name: status + type: string + - description: Filter by SSP UUID + in: query + name: sspId + type: string + - description: Filter by linked risk UUID + in: query + name: riskId + type: string + - description: Filter by planned_completion_date before (RFC3339) + in: query + name: deadlineBefore + type: string + - description: Return only overdue items + in: query + name: overdueOnly + type: boolean + - description: Filter by primary_owner_user_id UUID + in: query + name: ownerRef + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-handler_poamItemResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List POAM items + tags: + - POAM Items + post: + consumes: + - application/json + parameters: + - description: POAM item payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.createPoamItemRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_poamItemResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Create a POAM item + tags: + - POAM Items + /poam-items/{id}: + delete: + parameters: + - description: POAM item ID + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Delete a POAM item + tags: + - POAM Items + get: + parameters: + - description: POAM item ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_poamItemResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Get a POAM item + tags: + - POAM Items + put: + consumes: + - application/json + parameters: + - description: POAM item ID + in: path + name: id + required: true + type: string + - description: Update payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.updatePoamItemRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_poamItemResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Update a POAM item + tags: + - POAM Items + /poam-items/{id}/controls: + get: + parameters: + - description: POAM item ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-poam_PoamItemControlLink' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List linked controls + tags: + - POAM Items + post: + consumes: + - application/json + parameters: + - description: POAM item ID + in: path + name: id + required: true + type: string + - description: Control ref payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.poamControlRefRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handler.GenericDataResponse-poam_PoamItemControlLink' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Add a control link + tags: + - POAM Items + /poam-items/{id}/controls/{catalogId}/{controlId}: + delete: + parameters: + - description: POAM item ID + in: path + name: id + required: true + type: string + - description: Catalog ID + in: path + name: catalogId + required: true + type: string + - description: Control ID + in: path + name: controlId + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Delete a control link + tags: + - POAM Items + /poam-items/{id}/evidence: + get: + parameters: + - description: POAM item ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-poam_PoamItemEvidenceLink' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List linked evidence + tags: + - POAM Items + post: + consumes: + - application/json + parameters: + - description: POAM item ID + in: path + name: id + required: true + type: string + - description: Evidence ID payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.addLinkRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handler.GenericDataResponse-poam_PoamItemEvidenceLink' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Add an evidence link + tags: + - POAM Items + /poam-items/{id}/evidence/{evidenceId}: + delete: + parameters: + - description: POAM item ID + in: path + name: id + required: true + type: string + - description: Evidence ID + in: path + name: evidenceId + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Delete an evidence link + tags: + - POAM Items + /poam-items/{id}/findings: + get: + parameters: + - description: POAM item ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-poam_PoamItemFindingLink' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List linked findings + tags: + - POAM Items + post: + consumes: + - application/json + parameters: + - description: POAM item ID + in: path + name: id + required: true + type: string + - description: Finding ID payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.addLinkRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handler.GenericDataResponse-poam_PoamItemFindingLink' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Add a finding link + tags: + - POAM Items + /poam-items/{id}/findings/{findingId}: + delete: + parameters: + - description: POAM item ID + in: path + name: id + required: true + type: string + - description: Finding ID + in: path + name: findingId + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Delete a finding link + tags: + - POAM Items + /poam-items/{id}/milestones: + get: + parameters: + - description: POAM item ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-handler_milestoneResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List milestones for a POAM item + tags: + - POAM Items + post: + consumes: + - application/json + parameters: + - description: POAM item ID + in: path + name: id + required: true + type: string + - description: Milestone payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.createMilestoneRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_milestoneResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Add a milestone to a POAM item + tags: + - POAM Items + /poam-items/{id}/milestones/{milestoneId}: + delete: + parameters: + - description: POAM item ID + in: path + name: id + required: true + type: string + - description: Milestone ID + in: path + name: milestoneId + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Delete a milestone + tags: + - POAM Items + put: + consumes: + - application/json + parameters: + - description: POAM item ID + in: path + name: id + required: true + type: string + - description: Milestone ID + in: path + name: milestoneId + required: true + type: string + - description: Milestone update payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.updateMilestoneRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_milestoneResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Update a milestone + tags: + - POAM Items + /poam-items/{id}/risks: + get: + parameters: + - description: POAM item ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-poam_PoamItemRiskLink' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List linked risks + tags: + - POAM Items + post: + consumes: + - application/json + parameters: + - description: POAM item ID + in: path + name: id + required: true + type: string + - description: Risk ID payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.addLinkRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handler.GenericDataResponse-poam_PoamItemRiskLink' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Add a risk link + tags: + - POAM Items + /poam-items/{id}/risks/{riskId}: + delete: + parameters: + - description: POAM item ID + in: path + name: id + required: true + type: string + - description: Risk ID + in: path + name: riskId + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Delete a risk link + tags: + - POAM Items /risk-templates: get: description: List risk templates with optional filters and pagination. diff --git a/internal/api/handler/api.go b/internal/api/handler/api.go index bf2bf407..67d15e74 100644 --- a/internal/api/handler/api.go +++ b/internal/api/handler/api.go @@ -10,6 +10,7 @@ import ( "github.com/compliance-framework/api/internal/config" "github.com/compliance-framework/api/internal/service/digest" evidencesvc "github.com/compliance-framework/api/internal/service/relational/evidence" + poamsvc "github.com/compliance-framework/api/internal/service/relational/poam" workflowsvc "github.com/compliance-framework/api/internal/service/relational/workflows" "github.com/compliance-framework/api/internal/workflow" "github.com/labstack/echo/v4" @@ -48,15 +49,25 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB evidenceHandler := NewEvidenceHandler(logger, services.EvidenceService) evidenceHandler.Register(server.API().Group("/evidence")) + poamService := poamsvc.NewPoamService(db) + poamHandler := NewPoamItemsHandler(poamService, logger) + // Flat route: /api/poam-items (supports ?sspId= query filter) + poamGroup := server.API().Group("/poam-items") + poamGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) + poamHandler.Register(poamGroup) + // SSP-scoped route: /api/system-security-plans/:sspId/poam-items + // The :sspId path param is automatically injected into list/create filters. + sspPoamGroup := server.API().Group("/system-security-plans/:sspId/poam-items") + sspPoamGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) + poamHandler.RegisterSSPScoped(sspPoamGroup) + riskHandler := NewRiskHandler(logger, db) riskGroup := server.API().Group("/risks") riskGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) riskHandler.Register(riskGroup) - sspRiskGroup := server.API().Group("/ssp/:sspId/risks") sspRiskGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) riskHandler.RegisterSSPScoped(sspRiskGroup) - riskTemplateHandler := templatehandlers.NewRiskTemplateHandler(logger, db) riskTemplateGroup := server.API().Group("/risk-templates") riskTemplateGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) diff --git a/internal/api/handler/oscal/profiles.go b/internal/api/handler/oscal/profiles.go index 4ded97f3..917e7252 100644 --- a/internal/api/handler/oscal/profiles.go +++ b/internal/api/handler/oscal/profiles.go @@ -1,6 +1,7 @@ package oscal import ( + "encoding/json" "errors" "fmt" "net/http" @@ -25,40 +26,26 @@ type ProfileHandler struct { db *gorm.DB } -type RuleOperator string - -const ( - RuleOperatorEquals RuleOperator = "equals" - RuleOperatorContains RuleOperator = "contains" - RuleOperatorRegex RuleOperator = "regex" - RuleOperatorIn RuleOperator = "in" -) - -type MatchStrategy string - -const ( - MatchStrategyAll MatchStrategy = "all" - MatchStrategyAny MatchStrategy = "any" -) - type rule struct { - Name string `json:"name" example:"class"` - Ns string `json:"ns" example:"http://csrc.nist.gov/ns/oscal"` - Operator RuleOperator `json:"operator" binding:"required" example:"equals"` - Value string `json:"value" binding:"required" example:"technical"` + Name string `json:"name"` + Ns string `json:"ns"` + Operator string `json:"operator"` // equals | contains | regex | in + Value string `json:"value"` } +// BuildByPropsRequest represents the payload to build a Profile by matching control props. type BuildByPropsRequest struct { - CatalogID string `json:"catalog-id" binding:"required" example:"9b0c9c43-2722-4bbb-b132-13d34fb94d45"` - MatchStrategy MatchStrategy `json:"match-strategy" binding:"required" example:"all"` - Rules []rule `json:"rules" binding:"required,min=1"` - Title string `json:"title" binding:"required" example:"My Custom Profile"` - Version string `json:"version" example:"1.0.0"` + CatalogID string `json:"catalogId"` + MatchStrategy string `json:"matchStrategy"` // all | any + Rules []rule `json:"rules"` + Title string `json:"title"` + Version string `json:"version"` } +// BuildByPropsResponse represents the response payload for Profile build-by-props. type BuildByPropsResponse struct { - ProfileID uuid.UUID `json:"profile-id"` - ControlIDs []string `json:"control-ids"` + ProfileID uuid.UUID `json:"profileId"` + ControlIDs []string `json:"controlIds"` Profile oscalTypes_1_1_3.Profile `json:"profile"` } @@ -75,7 +62,6 @@ func (h *ProfileHandler) Register(api *echo.Group) { api.POST("/build-props", h.BuildByProps) api.GET("/:id", h.Get) api.GET("/:id/resolved", h.Resolved) - api.GET("/:id/compliance-progress", h.ComplianceProgress) api.GET("/:id/modify", h.GetModify) api.GET("/:id/back-matter", h.GetBackmatter) @@ -111,171 +97,168 @@ func (h *ProfileHandler) Register(api *echo.Group) { // @Router /oscal/profiles/build-props [post] func (h *ProfileHandler) BuildByProps(ctx echo.Context) error { var req BuildByPropsRequest - if err := ctx.Bind(&req); err != nil { - h.sugar.Warnw("failed to bind BuildByProps request", "error", err) + var raw map[string]any + if err := json.NewDecoder(ctx.Request().Body).Decode(&raw); err != nil { + h.sugar.Warnw("failed to decode BuildByProps request", "error", err) return ctx.JSON(http.StatusBadRequest, api.NewError(err)) } - + // Accept both camelCase and kebab-case keys + getStr := func(m map[string]any, keys ...string) string { + for _, k := range keys { + if v, ok := m[k]; ok { + if s, ok := v.(string); ok { + return s + } + } + } + return "" + } + req.CatalogID = getStr(raw, "catalogId", "catalog-id") + req.MatchStrategy = getStr(raw, "matchStrategy", "match-strategy") + req.Title = getStr(raw, "title") + req.Version = getStr(raw, "version") + if rv, ok := raw["rules"]; ok { + if arr, ok := rv.([]any); ok { + out := make([]rule, 0, len(arr)) + for _, it := range arr { + if mm, ok := it.(map[string]any); ok { + out = append(out, rule{ + Name: getStr(mm, "name"), + Ns: getStr(mm, "ns"), + Operator: getStr(mm, "operator"), + Value: getStr(mm, "value"), + }) + } + } + req.Rules = out + } + } if req.CatalogID == "" || len(req.Rules) == 0 { - return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("catalog-id and rules are required"))) + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("catalogId and rules are required"))) } - - // Filter out invalid rules and validate operators + // filter out invalid rules (empty operator or value) validRules := make([]rule, 0, len(req.Rules)) for _, r := range req.Rules { - if strings.TrimSpace(string(r.Operator)) != "" && strings.TrimSpace(r.Value) != "" { + if strings.TrimSpace(r.Operator) != "" && strings.TrimSpace(r.Value) != "" { validRules = append(validRules, r) } } if len(validRules) == 0 { return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("rules must include non-empty operator and value"))) } - - // Pre-compile regex patterns and validate - regexCache := make(map[string]*regexp.Regexp) - for _, r := range validRules { - if r.Operator == RuleOperatorRegex { - re, err := regexp.Compile(r.Value) - if err != nil { - h.sugar.Warnw("invalid regex pattern", "pattern", r.Value, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("invalid regex pattern '%s': %w", r.Value, err))) - } - regexCache[r.Value] = re - } - } - catUUID, err := uuid.Parse(req.CatalogID) if err != nil { return ctx.JSON(http.StatusBadRequest, api.NewError(err)) } - - // Check if catalog exists - var catalog relational.Catalog - if err := h.db.Preload("Metadata").First(&catalog, "id = ?", catUUID).Error; err != nil { + var controls []relational.Control + if err := h.db.Where("catalog_id = ?", catUUID).Find(&controls).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ctx.JSON(http.StatusNotFound, api.NewError(err)) } - h.sugar.Errorw("failed to load catalog metadata", "catalogId", req.CatalogID, "error", err) - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) - } - - var controls []relational.Control - if err := h.db.Where("catalog_id = ?", catUUID).Find(&controls).Error; err != nil { h.sugar.Errorw("failed to list catalog controls", "catalogId", req.CatalogID, "error", err) return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } - - // Check if controls were found - if len(controls) == 0 { - return ctx.JSON(http.StatusNotFound, api.NewError(errors.New("no controls found in catalog"))) - } - - matchAll := req.MatchStrategy == MatchStrategyAll + matchAll := strings.ToLower(req.MatchStrategy) == "all" matched := make([]relational.Control, 0, len(controls)) matchedIDs := make([]string, 0, len(controls)) for i := range controls { - if matchControlByProps(&controls[i], validRules, matchAll, regexCache) { + if matchControlByProps(&controls[i], validRules, matchAll) { matched = append(matched, controls[i]) matchedIDs = append(matchedIDs, controls[i].ID) } } - - // Wrap the entire build flow in a transaction - var profileID uuid.UUID - var oscalProfile *oscalTypes_1_1_3.Profile - err = h.db.Transaction(func(tx *gorm.DB) error { - now := time.Now() - resourceUUID := uuid.New() - title := catalog.Metadata.Title - resource := relational.BackMatterResource{ - ID: resourceUUID, - Title: &title, - RLinks: []relational.ResourceLink{ - { - Href: "#" + req.CatalogID, - MediaType: "application/ccf+oscal+json", - }, - }, - } - includeGroup := relational.SelectControlById{ - WithChildControls: "", - WithIds: datatypes.NewJSONSlice(matchedIDs), - } - newImport := relational.Import{ - Href: "#" + resourceUUID.String(), - } - profile := &relational.Profile{ - Metadata: relational.Metadata{ - Title: req.Title, - Version: req.Version, - OscalVersion: versioning.GetLatestSupportedVersion(), - LastModified: &now, + now := time.Now() + // build BackMatter resource and Import pointing to the catalog + var catalog relational.Catalog + if err := h.db.Preload("Metadata").First(&catalog, "id = ?", catUUID).Error; err != nil { + h.sugar.Warnw("failed to load catalog metadata", "catalogId", req.CatalogID, "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + resourceUUID := uuid.New() + title := catalog.Metadata.Title + resource := relational.BackMatterResource{ + ID: resourceUUID, + Title: &title, + RLinks: []relational.ResourceLink{ + { + Href: "#" + req.CatalogID, + MediaType: "application/ccf+oscal+json", }, - Controls: matched, - } - if err := tx.Create(profile).Error; err != nil { - return fmt.Errorf("failed to create profile: %w", err) - } - profileID = *profile.ID - - // Persist BackMatter and resource under this profile - parentID := profile.ID.String() - parentType := "profiles" - bmRecord := &relational.BackMatter{ - ParentID: &parentID, - ParentType: &parentType, - } - if err := tx.Create(bmRecord).Error; err != nil { - return fmt.Errorf("failed to create backmatter: %w", err) - } - if bmRecord.ID != nil { - resource.BackMatterID = *bmRecord.ID - } - if err := tx.Create(&resource).Error; err != nil { - return fmt.Errorf("failed to create backmatter resource: %w", err) - } - - // Persist import and include-controls - newImport.ProfileID = *profile.ID - if err := tx.Create(&newImport).Error; err != nil { - return fmt.Errorf("failed to create import: %w", err) - } - if len(matchedIDs) > 0 && newImport.ID != nil { - includeGroup.ParentID = *newImport.ID - includeGroup.ParentType = "included" - if err := tx.Create(&includeGroup).Error; err != nil { - return fmt.Errorf("failed to create include-controls: %w", err) - } - } - - if _, err := SyncProfileControls(tx, *profile.ID); err != nil { - return fmt.Errorf("failed to sync profile controls: %w", err) - } - - // Reload full profile with associations for response - fullProfile, err := FindFullProfile(tx, *profile.ID) - if err != nil { - return fmt.Errorf("failed to reload full profile: %w", err) + }, + } + includeGroup := relational.SelectControlById{ + WithChildControls: "", + WithIds: datatypes.NewJSONSlice(matchedIDs), + } + newImport := relational.Import{ + Href: "#" + resourceUUID.String(), + } + profile := &relational.Profile{ + Metadata: relational.Metadata{ + Title: req.Title, + Version: req.Version, + OscalVersion: versioning.GetLatestSupportedVersion(), + LastModified: &now, + }, + Controls: matched, + } + if err := h.db.Create(profile).Error; err != nil { + h.sugar.Errorw("failed to create profile from props", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + // Persist BackMatter and resource under this profile + parentID := profile.ID.String() + parentType := "profiles" + bmRecord := &relational.BackMatter{ + ParentID: &parentID, + ParentType: &parentType, + } + if err := h.db.Create(bmRecord).Error; err != nil { + h.sugar.Errorw("failed to create backmatter for profile", "profileId", profile.ID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + if bmRecord.ID != nil { + resource.BackMatterID = *bmRecord.ID + } + if err := h.db.Create(&resource).Error; err != nil { + h.sugar.Errorw("failed to create backmatter resource", "profileId", profile.ID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + // Persist import and include-controls + newImport.ProfileID = *profile.ID + if err := h.db.Create(&newImport).Error; err != nil { + h.sugar.Errorw("failed to create import", "profileId", profile.ID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + if len(matchedIDs) > 0 && newImport.ID != nil { + includeGroup.ParentID = *newImport.ID + includeGroup.ParentType = "included" + if err := h.db.Create(&includeGroup).Error; err != nil { + h.sugar.Errorw("failed to create include-controls", "profileId", profile.ID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } - oscalProfile = fullProfile.MarshalOscal() - return nil - }) - + } + if _, err := SyncProfileControls(h.db, *profile.ID); err != nil { + h.sugar.Errorw("failed to sync profile controls", "profileId", profile.ID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + // Reload full profile with associations for response + fullProfile, err := FindFullProfile(h.db, *profile.ID) if err != nil { - h.sugar.Errorw("failed to build profile by props", "error", err) + h.sugar.Errorw("failed to reload full profile", "profileId", profile.ID, "error", err) return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } - + oscalProfile := fullProfile.MarshalOscal() return ctx.JSON(http.StatusCreated, handler.GenericDataResponse[BuildByPropsResponse]{ Data: BuildByPropsResponse{ - ProfileID: profileID, + ProfileID: *profile.ID, ControlIDs: matchedIDs, Profile: *oscalProfile, }, }) } -func matchControlByProps(ctl *relational.Control, rules []rule, matchAll bool, regexCache map[string]*regexp.Regexp) bool { +func matchControlByProps(ctl *relational.Control, rules []rule, matchAll bool) bool { if len(rules) == 0 { return false } @@ -286,17 +269,22 @@ func matchControlByProps(ctl *relational.Control, rules []rule, matchAll bool, r if r.Ns != "" && !strings.EqualFold(r.Ns, p.Ns) { return false } - switch r.Operator { - case RuleOperatorEquals: + switch strings.ToLower(r.Operator) { + case "equals": return strings.EqualFold(p.Value, r.Value) - case RuleOperatorContains: + case "contains": return strings.Contains(strings.ToLower(p.Value), strings.ToLower(r.Value)) - case RuleOperatorRegex: - if re, ok := regexCache[r.Value]; ok { - return re.MatchString(p.Value) - } - return false - case RuleOperatorIn: + case "regex": + m, _ := func() (bool, error) { + // simple regex match + re, err := regexp.Compile(r.Value) + if err != nil { + return false, err + } + return re.MatchString(p.Value), nil + }() + return m + case "in": parts := strings.Split(r.Value, ",") for _, v := range parts { if strings.EqualFold(strings.TrimSpace(v), p.Value) { @@ -1458,12 +1446,8 @@ func rollUpToRootControl(db *gorm.DB, control relational.Control) (relational.Co tx := db.Session(&gorm.Session{}) if *control.ParentType == "controls" { - if control.ParentID == nil { - return control, fmt.Errorf("control %s has parent type %q but nil parent ID", control.ID, *control.ParentType) - } - parent := relational.Control{} - if err := tx.First(&parent, "id = ? AND catalog_id = ?", *control.ParentID, control.CatalogID).Error; err != nil { + if err := tx.First(&parent, "id = ?", control.ParentID).Error; err != nil { return control, err } parent.Controls = append(parent.Controls, control) @@ -1480,12 +1464,8 @@ func rollUpToRootGroup(db *gorm.DB, group relational.Group) (relational.Group, e tx := db.Session(&gorm.Session{}) if *group.ParentType == "groups" { - if group.ParentID == nil { - return group, fmt.Errorf("group %s has parent type %q but nil parent ID", group.ID, *group.ParentType) - } - parent := relational.Group{} - if err := tx.First(&parent, "id = ? AND catalog_id = ?", *group.ParentID, group.CatalogID).Error; err != nil { + if err := tx.First(&parent, "id = ?", *group.ParentID).Error; err != nil { return group, err } parent.Groups = append(parent.Groups, group) @@ -1571,12 +1551,8 @@ func rollUpControlsToCatalog(db *gorm.DB, allControls []relational.Control) (*re // If the control has a group as a parent, roll it up. if *rootControl.ParentType == "groups" { - if rootControl.ParentID == nil { - return nil, fmt.Errorf("control %s has parent type %q but nil parent ID", rootControl.ID, *rootControl.ParentType) - } - group := &relational.Group{} - if err = db.First(group, "id = ? AND catalog_id = ?", *rootControl.ParentID, rootControl.CatalogID).Error; err != nil { + if err = db.First(group, "id = ?", *rootControl.ParentID).Error; err != nil { return nil, err } group.Controls = append(group.Controls, rootControl) diff --git a/internal/api/handler/oscal/profiles_integration_test.go b/internal/api/handler/oscal/profiles_integration_test.go index 1a2efd83..2bd30f57 100644 --- a/internal/api/handler/oscal/profiles_integration_test.go +++ b/internal/api/handler/oscal/profiles_integration_test.go @@ -14,16 +14,13 @@ import ( "github.com/compliance-framework/api/internal/api" "github.com/compliance-framework/api/internal/api/handler" - "github.com/compliance-framework/api/internal/converters/labelfilter" "github.com/compliance-framework/api/internal/service/relational" - evidencesvc "github.com/compliance-framework/api/internal/service/relational/evidence" "github.com/compliance-framework/api/internal/tests" oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/stretchr/testify/suite" "go.uber.org/zap" - "gorm.io/datatypes" ) var ( @@ -65,8 +62,7 @@ func (suite *ProfileIntegrationSuite) SetupSuite() { suite.logger = logger.Sugar() metrics := api.NewMetricsHandler(context.Background(), suite.logger) suite.server = api.NewServer(context.Background(), suite.logger, suite.Config, metrics) - evidenceSvc := evidencesvc.NewEvidenceService(suite.DB, logger.Sugar(), suite.Config, nil) - RegisterHandlers(suite.server, logger.Sugar(), suite.DB, suite.Config, evidenceSvc) + RegisterHandlers(suite.server, suite.logger, suite.DB, suite.Config, nil) profileFp, err := os.Open("../../../../testdata/profile_fedramp_low.json") suite.Require().NoError(err, "Failed to open profile file") @@ -603,8 +599,8 @@ func (suite *ProfileIntegrationSuite) TestBuildByPropsCreatesImportAndControls() // Build profile by props targeting the seeded catalog and rule body := map[string]any{ - "catalog-id": catID.String(), - "match-strategy": "all", + "catalogId": catID.String(), + "matchStrategy": "all", "rules": []map[string]string{ {"name": "class", "operator": "equals", "value": "technical"}, }, @@ -621,8 +617,8 @@ func (suite *ProfileIntegrationSuite) TestBuildByPropsCreatesImportAndControls() suite.Require().Equal(http.StatusCreated, rec.Code, "Expected 201 from build-by-props") var response handler.GenericDataResponse[struct { - ProfileID uuid.UUID `json:"profile-id"` - ControlIDs []string `json:"control-ids"` + ProfileID uuid.UUID `json:"profileId"` + ControlIDs []string `json:"controlIds"` Profile oscalTypes_1_1_3.Profile `json:"profile"` }] err = json.NewDecoder(rec.Body).Decode(&response) @@ -642,237 +638,6 @@ func (suite *ProfileIntegrationSuite) TestBuildByPropsCreatesImportAndControls() suite.Require().Len(list.Data, 1, "Expected a single import") } -func (suite *ProfileIntegrationSuite) TestBuildByPropsOperators() { - suite.IntegrationTestSuite.Migrator.Refresh() - token, err := suite.GetAuthToken() - suite.Require().NoError(err, "Failed to get auth token") - - // Seed a catalog with multiple controls with different props - catID := uuid.New() - catalog := &relational.Catalog{ - UUIDModel: relational.UUIDModel{ID: &catID}, - Metadata: relational.Metadata{Title: "Operator Test Catalog"}, - Controls: []relational.Control{ - { - ID: "ac-1", - Title: "Access Control 1", - CatalogID: catID, - Props: []relational.Prop{ - {Name: "class", Value: "technical"}, - {Name: "priority", Value: "P1"}, - }, - }, - { - ID: "ac-2", - Title: "Access Control 2", - CatalogID: catID, - Props: []relational.Prop{ - {Name: "class", Value: "operational"}, - {Name: "priority", Value: "P2"}, - }, - }, - { - ID: "ac-3", - Title: "Access Control 3", - CatalogID: catID, - Props: []relational.Prop{ - {Name: "class", Value: "management"}, - {Name: "priority", Value: "P1-critical"}, - }, - }, - { - ID: "sc-1", - Title: "System and Communications Protection 1", - CatalogID: catID, - Props: []relational.Prop{ - {Name: "class", Value: "technical"}, - {Name: "family", Value: "SC"}, - }, - }, - }, - } - err = suite.DB.Create(catalog).Error - suite.Require().NoError(err, "Failed to seed test catalog") - - suite.Run("Regex operator - match controls with priority starting with P1", func() { - body := map[string]any{ - "catalog-id": catID.String(), - "match-strategy": "any", - "rules": []map[string]string{ - {"name": "priority", "operator": "regex", "value": "^P1"}, - }, - "title": "Regex Test Profile", - "version": "1.0.0", - } - payload, _ := json.Marshal(body) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/oscal/profiles/build-props", bytes.NewReader(payload)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) - suite.server.E().ServeHTTP(rec, req) - suite.Require().Equal(http.StatusCreated, rec.Code, "Expected 201 from build-by-props") - - var response handler.GenericDataResponse[struct { - ProfileID uuid.UUID `json:"profile-id"` - ControlIDs []string `json:"control-ids"` - Profile oscalTypes_1_1_3.Profile `json:"profile"` - }] - err = json.NewDecoder(rec.Body).Decode(&response) - suite.Require().NoError(err, "Failed to decode response") - suite.Require().Len(response.Data.ControlIDs, 2, "Expected two matched controls (ac-1, ac-3)") - suite.Require().Contains(response.Data.ControlIDs, "ac-1") - suite.Require().Contains(response.Data.ControlIDs, "ac-3") - }) - - suite.Run("In operator - match controls with class in list", func() { - body := map[string]any{ - "catalog-id": catID.String(), - "match-strategy": "any", - "rules": []map[string]string{ - {"name": "class", "operator": "in", "value": "technical,operational"}, - }, - "title": "In Operator Test Profile", - "version": "1.0.0", - } - payload, _ := json.Marshal(body) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/oscal/profiles/build-props", bytes.NewReader(payload)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) - suite.server.E().ServeHTTP(rec, req) - suite.Require().Equal(http.StatusCreated, rec.Code, "Expected 201 from build-by-props") - - var response handler.GenericDataResponse[struct { - ProfileID uuid.UUID `json:"profile-id"` - ControlIDs []string `json:"control-ids"` - Profile oscalTypes_1_1_3.Profile `json:"profile"` - }] - err = json.NewDecoder(rec.Body).Decode(&response) - suite.Require().NoError(err, "Failed to decode response") - suite.Require().Len(response.Data.ControlIDs, 3, "Expected three matched controls (ac-1, ac-2, sc-1)") - suite.Require().Contains(response.Data.ControlIDs, "ac-1") - suite.Require().Contains(response.Data.ControlIDs, "ac-2") - suite.Require().Contains(response.Data.ControlIDs, "sc-1") - }) - - suite.Run("Contains operator - match controls with class containing 'tech'", func() { - body := map[string]any{ - "catalog-id": catID.String(), - "match-strategy": "any", - "rules": []map[string]string{ - {"name": "class", "operator": "contains", "value": "tech"}, - }, - "title": "Contains Operator Test Profile", - "version": "1.0.0", - } - payload, _ := json.Marshal(body) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/oscal/profiles/build-props", bytes.NewReader(payload)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) - suite.server.E().ServeHTTP(rec, req) - suite.Require().Equal(http.StatusCreated, rec.Code, "Expected 201 from build-by-props") - - var response handler.GenericDataResponse[struct { - ProfileID uuid.UUID `json:"profile-id"` - ControlIDs []string `json:"control-ids"` - Profile oscalTypes_1_1_3.Profile `json:"profile"` - }] - err = json.NewDecoder(rec.Body).Decode(&response) - suite.Require().NoError(err, "Failed to decode response") - suite.Require().Len(response.Data.ControlIDs, 2, "Expected two matched controls (ac-1, sc-1)") - suite.Require().Contains(response.Data.ControlIDs, "ac-1") - suite.Require().Contains(response.Data.ControlIDs, "sc-1") - }) - - suite.Run("Invalid regex pattern returns 400", func() { - body := map[string]any{ - "catalog-id": catID.String(), - "match-strategy": "any", - "rules": []map[string]string{ - {"name": "priority", "operator": "regex", "value": "[invalid(regex"}, - }, - "title": "Invalid Regex Test", - "version": "1.0.0", - } - payload, _ := json.Marshal(body) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/oscal/profiles/build-props", bytes.NewReader(payload)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) - suite.server.E().ServeHTTP(rec, req) - suite.Require().Equal(http.StatusBadRequest, rec.Code, "Expected 400 for invalid regex") - suite.Require().Contains(rec.Body.String(), "invalid regex pattern") - }) - - suite.Run("Match strategy 'all' requires all rules to match", func() { - body := map[string]any{ - "catalog-id": catID.String(), - "match-strategy": "all", - "rules": []map[string]string{ - {"name": "class", "operator": "equals", "value": "technical"}, - {"name": "priority", "operator": "equals", "value": "P1"}, - }, - "title": "Match All Strategy Test", - "version": "1.0.0", - } - payload, _ := json.Marshal(body) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/oscal/profiles/build-props", bytes.NewReader(payload)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) - suite.server.E().ServeHTTP(rec, req) - suite.Require().Equal(http.StatusCreated, rec.Code, "Expected 201 from build-by-props") - - var response handler.GenericDataResponse[struct { - ProfileID uuid.UUID `json:"profile-id"` - ControlIDs []string `json:"control-ids"` - Profile oscalTypes_1_1_3.Profile `json:"profile"` - }] - err = json.NewDecoder(rec.Body).Decode(&response) - suite.Require().NoError(err, "Failed to decode response") - suite.Require().Len(response.Data.ControlIDs, 1, "Expected only one control matching both rules (ac-1)") - suite.Require().Equal("ac-1", response.Data.ControlIDs[0]) - }) - - suite.Run("Match strategy 'any' matches if any rule matches", func() { - body := map[string]any{ - "catalog-id": catID.String(), - "match-strategy": "any", - "rules": []map[string]string{ - {"name": "class", "operator": "equals", "value": "technical"}, - {"name": "family", "operator": "equals", "value": "SC"}, - }, - "title": "Match Any Strategy Test", - "version": "1.0.0", - } - payload, _ := json.Marshal(body) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/oscal/profiles/build-props", bytes.NewReader(payload)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) - suite.server.E().ServeHTTP(rec, req) - suite.Require().Equal(http.StatusCreated, rec.Code, "Expected 201 from build-by-props") - - var response handler.GenericDataResponse[struct { - ProfileID uuid.UUID `json:"profile-id"` - ControlIDs []string `json:"control-ids"` - Profile oscalTypes_1_1_3.Profile `json:"profile"` - }] - err = json.NewDecoder(rec.Body).Decode(&response) - suite.Require().NoError(err, "Failed to decode response") - suite.Require().Len(response.Data.ControlIDs, 2, "Expected two controls (ac-1 and sc-1)") - suite.Require().Contains(response.Data.ControlIDs, "ac-1") - suite.Require().Contains(response.Data.ControlIDs, "sc-1") - }) -} - func (suite *ProfileIntegrationSuite) TestDeleteImport() { suite.IntegrationTestSuite.Migrator.Refresh() suite.SeedDatabase() @@ -1021,353 +786,6 @@ func (suite *ProfileIntegrationSuite) TestResolved() { }) } -func (suite *ProfileIntegrationSuite) TestComplianceProgress() { - suite.IntegrationTestSuite.Migrator.Refresh() - - token, err := suite.GetAuthToken() - suite.Require().NoError(err, "Failed to get auth token") - - catalog := &relational.Catalog{ - Metadata: relational.Metadata{Title: "Compliance Progress Catalog"}, - } - suite.Require().NoError(suite.DB.Create(catalog).Error) - - controlSatisfied := relational.Control{ID: "CTRL-SAT", CatalogID: *catalog.ID, Title: "Satisfied Control"} - controlNotSatisfied := relational.Control{ID: "CTRL-NS", CatalogID: *catalog.ID, Title: "Not Satisfied Control"} - controlUnknown := relational.Control{ID: "CTRL-UNK", CatalogID: *catalog.ID, Title: "Unknown Control"} - - suite.Require().NoError(suite.DB.Create(&controlSatisfied).Error) - suite.Require().NoError(suite.DB.Create(&controlNotSatisfied).Error) - suite.Require().NoError(suite.DB.Create(&controlUnknown).Error) - - filterSatisfied := relational.Filter{ - Name: "Satisfied Filter", - Filter: datatypes.NewJSONType(labelfilter.Filter{ - Scope: &labelfilter.Scope{ - Condition: &labelfilter.Condition{ - Label: "provider", - Operator: "=", - Value: "aws", - }, - }, - }), - } - - filterNotSatisfied := relational.Filter{ - Name: "Not Satisfied Filter", - Filter: datatypes.NewJSONType(labelfilter.Filter{ - Scope: &labelfilter.Scope{ - Condition: &labelfilter.Condition{ - Label: "provider", - Operator: "=", - Value: "gcp", - }, - }, - }), - } - - suite.Require().NoError(suite.DB.Create(&filterSatisfied).Error) - suite.Require().NoError(suite.DB.Create(&filterNotSatisfied).Error) - suite.Require().NoError(suite.DB.Model(&controlSatisfied).Association("Filters").Append(&filterSatisfied)) - suite.Require().NoError(suite.DB.Model(&controlNotSatisfied).Association("Filters").Append(&filterNotSatisfied)) - - profile := &relational.Profile{ - Metadata: relational.Metadata{Title: "Compliance Progress Profile"}, - Controls: []relational.Control{controlSatisfied, controlNotSatisfied, controlUnknown}, - } - suite.Require().NoError(suite.DB.Create(profile).Error) - - now := time.Now().UTC() - evidenceRecords := []relational.Evidence{ - { - UUID: uuid.New(), - Title: "AWS satisfied evidence", - Start: now.Add(-time.Hour), - End: now.Add(-time.Minute), - Status: datatypes.NewJSONType(oscalTypes_1_1_3.ObjectiveStatus{State: "satisfied"}), - Labels: []relational.Labels{{Name: "provider", Value: "aws"}}, - }, - { - UUID: uuid.New(), - Title: "GCP not satisfied evidence", - Start: now.Add(-time.Hour), - End: now.Add(-time.Minute), - Status: datatypes.NewJSONType(oscalTypes_1_1_3.ObjectiveStatus{State: "not-satisfied"}), - Labels: []relational.Labels{{Name: "provider", Value: "gcp"}}, - }, - { - UUID: uuid.New(), - Title: "Non-matching evidence", - Start: now.Add(-time.Hour), - End: now.Add(-time.Minute), - Status: datatypes.NewJSONType(oscalTypes_1_1_3.ObjectiveStatus{State: "satisfied"}), - Labels: []relational.Labels{{Name: "provider", Value: "azure"}}, - }, - } - suite.Require().NoError(suite.DB.Create(&evidenceRecords).Error) - - suite.Run("Returns aggregated compliance progress", func() { - rec := httptest.NewRecorder() - url := "/api/oscal/profiles/" + profile.ID.String() + "/compliance-progress" - req := httptest.NewRequest(http.MethodGet, url, nil) - req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) - - suite.server.E().ServeHTTP(rec, req) - suite.Require().Equal(http.StatusOK, rec.Code, "Expected status code 200 OK") - - var response handler.GenericDataResponse[ProfileComplianceProgress] - err = json.NewDecoder(rec.Body).Decode(&response) - suite.Require().NoError(err, "Failed to decode response body") - - suite.Require().Equal(profile.ID.String(), response.Data.Scope.ID.String()) - suite.Require().Equal("profile", response.Data.Scope.Type) - suite.Require().Equal("Compliance Progress Profile", response.Data.Scope.Title) - - suite.Require().Equal(3, response.Data.Summary.TotalControls) - suite.Require().Equal(1, response.Data.Summary.Satisfied) - suite.Require().Equal(1, response.Data.Summary.NotSatisfied) - suite.Require().Equal(1, response.Data.Summary.Unknown) - suite.Require().Equal(33, response.Data.Summary.CompliancePct) - suite.Require().Equal(67, response.Data.Summary.AssessedPct) - - suite.Require().Len(response.Data.Groups, 0) - suite.Require().Len(response.Data.Controls, 3) - - controlsByID := make(map[string]ProfileComplianceControl, len(response.Data.Controls)) - for _, control := range response.Data.Controls { - controlsByID[control.ControlID] = control - } - - suite.Require().Equal("satisfied", controlsByID["CTRL-SAT"].ComputedStatus) - suite.Require().Equal("not-satisfied", controlsByID["CTRL-NS"].ComputedStatus) - suite.Require().Equal("unknown", controlsByID["CTRL-UNK"].ComputedStatus) - }) - - suite.Run("Allows omitting controls from response", func() { - rec := httptest.NewRecorder() - url := "/api/oscal/profiles/" + profile.ID.String() + "/compliance-progress?includeControls=false" - req := httptest.NewRequest(http.MethodGet, url, nil) - req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) - - suite.server.E().ServeHTTP(rec, req) - suite.Require().Equal(http.StatusOK, rec.Code, "Expected status code 200 OK") - - var response handler.GenericDataResponse[ProfileComplianceProgress] - err = json.NewDecoder(rec.Body).Decode(&response) - suite.Require().NoError(err, "Failed to decode response body") - suite.Require().Len(response.Data.Controls, 0) - suite.Require().Equal(3, response.Data.Summary.TotalControls) - }) - - suite.Run("Returns 404 for non-existing profile", func() { - rec := httptest.NewRecorder() - url := "/api/oscal/profiles/" + uuid.New().String() + "/compliance-progress" - req := httptest.NewRequest(http.MethodGet, url, nil) - req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) - - suite.server.E().ServeHTTP(rec, req) - suite.Require().Equal(http.StatusNotFound, rec.Code, "Expected status code 404 Not Found") - }) - - suite.Run("Returns 400 for invalid profile UUID", func() { - rec := httptest.NewRecorder() - url := "/api/oscal/profiles/invalid-uuid/compliance-progress" - req := httptest.NewRequest(http.MethodGet, url, nil) - req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) - - suite.server.E().ServeHTTP(rec, req) - suite.Require().Equal(http.StatusBadRequest, rec.Code, "Expected status code 400 Bad Request") - }) -} - -func (suite *ProfileIntegrationSuite) TestComplianceProgressEdgeCases() { - suite.IntegrationTestSuite.Migrator.Refresh() - - token, err := suite.GetAuthToken() - suite.Require().NoError(err, "Failed to get auth token") - - suite.Run("Profile with zero controls returns empty summary", func() { - emptyProfile := &relational.Profile{ - Metadata: relational.Metadata{Title: "Empty Profile"}, - } - suite.Require().NoError(suite.DB.Create(emptyProfile).Error) - - rec := httptest.NewRecorder() - url := "/api/oscal/profiles/" + emptyProfile.ID.String() + "/compliance-progress" - req := httptest.NewRequest(http.MethodGet, url, nil) - req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) - - suite.server.E().ServeHTTP(rec, req) - suite.Require().Equal(http.StatusOK, rec.Code) - - var response handler.GenericDataResponse[ProfileComplianceProgress] - suite.Require().NoError(json.NewDecoder(rec.Body).Decode(&response)) - - suite.Require().Equal(0, response.Data.Summary.TotalControls) - suite.Require().Equal(0, response.Data.Summary.Satisfied) - suite.Require().Equal(0, response.Data.Summary.NotSatisfied) - suite.Require().Equal(0, response.Data.Summary.Unknown) - suite.Require().Equal(0, response.Data.Summary.CompliancePct) - suite.Require().Nil(response.Data.Summary.ImplementedTotal, "implementedControls should be absent when no sspId requested") - suite.Require().Len(response.Data.Controls, 0) - suite.Require().Len(response.Data.Groups, 0) - }) - - suite.Run("Control with no linked filters reports unknown status", func() { - cat := &relational.Catalog{Metadata: relational.Metadata{Title: "Unfiltered Catalog"}} - suite.Require().NoError(suite.DB.Create(cat).Error) - - ctrl := relational.Control{ID: "CTRL-NOFILTER", CatalogID: *cat.ID, Title: "No Filter Control"} - suite.Require().NoError(suite.DB.Create(&ctrl).Error) - - p := &relational.Profile{ - Metadata: relational.Metadata{Title: "No Filter Profile"}, - Controls: []relational.Control{ctrl}, - } - suite.Require().NoError(suite.DB.Create(p).Error) - - rec := httptest.NewRecorder() - url := "/api/oscal/profiles/" + p.ID.String() + "/compliance-progress" - req := httptest.NewRequest(http.MethodGet, url, nil) - req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) - - suite.server.E().ServeHTTP(rec, req) - suite.Require().Equal(http.StatusOK, rec.Code) - - var response handler.GenericDataResponse[ProfileComplianceProgress] - suite.Require().NoError(json.NewDecoder(rec.Body).Decode(&response)) - - suite.Require().Equal(1, response.Data.Summary.TotalControls) - suite.Require().Equal(0, response.Data.Summary.Satisfied) - suite.Require().Equal(0, response.Data.Summary.NotSatisfied) - suite.Require().Equal(1, response.Data.Summary.Unknown) - suite.Require().Len(response.Data.Controls, 1) - suite.Require().Equal("unknown", response.Data.Controls[0].ComputedStatus) - }) - - suite.Run("Duplicate control IDs across different catalogs are tracked separately", func() { - catA := &relational.Catalog{Metadata: relational.Metadata{Title: "Catalog A"}} - catB := &relational.Catalog{Metadata: relational.Metadata{Title: "Catalog B"}} - suite.Require().NoError(suite.DB.Create(catA).Error) - suite.Require().NoError(suite.DB.Create(catB).Error) - - ctrlA := relational.Control{ID: "CTRL-SHARED", CatalogID: *catA.ID, Title: "Shared Control from A"} - ctrlB := relational.Control{ID: "CTRL-SHARED", CatalogID: *catB.ID, Title: "Shared Control from B"} - suite.Require().NoError(suite.DB.Create(&ctrlA).Error) - suite.Require().NoError(suite.DB.Create(&ctrlB).Error) - - p := &relational.Profile{ - Metadata: relational.Metadata{Title: "Cross-Catalog Profile"}, - Controls: []relational.Control{ctrlA, ctrlB}, - } - suite.Require().NoError(suite.DB.Create(p).Error) - - rec := httptest.NewRecorder() - url := "/api/oscal/profiles/" + p.ID.String() + "/compliance-progress" - req := httptest.NewRequest(http.MethodGet, url, nil) - req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) - - suite.server.E().ServeHTTP(rec, req) - suite.Require().Equal(http.StatusOK, rec.Code) - - var response handler.GenericDataResponse[ProfileComplianceProgress] - suite.Require().NoError(json.NewDecoder(rec.Body).Decode(&response)) - - // Both controls have the same controlId but different catalogIds — they must each be counted - suite.Require().Equal(2, response.Data.Summary.TotalControls, "Controls with same ID but different catalogs must be counted separately") - suite.Require().Len(response.Data.Controls, 2) - - catalogIDs := make(map[string]struct{}, 2) - for _, c := range response.Data.Controls { - suite.Require().Equal("CTRL-SHARED", c.ControlID) - catalogIDs[c.CatalogID.String()] = struct{}{} - } - suite.Require().Len(catalogIDs, 2, "Each entry must have a distinct catalogId") - }) - - suite.Run("sspId scope reports implemented and unimplemented controls", func() { - cat := &relational.Catalog{Metadata: relational.Metadata{Title: "SSP Catalog"}} - suite.Require().NoError(suite.DB.Create(cat).Error) - - ctrlImpl := relational.Control{ID: "CTRL-IMPL", CatalogID: *cat.ID, Title: "Implemented Control"} - ctrlUnimpl := relational.Control{ID: "CTRL-UNIMPL", CatalogID: *cat.ID, Title: "Unimplemented Control"} - suite.Require().NoError(suite.DB.Create(&ctrlImpl).Error) - suite.Require().NoError(suite.DB.Create(&ctrlUnimpl).Error) - - p := &relational.Profile{ - Metadata: relational.Metadata{Title: "SSP Profile"}, - Controls: []relational.Control{ctrlImpl, ctrlUnimpl}, - } - suite.Require().NoError(suite.DB.Create(p).Error) - - ssp := &relational.SystemSecurityPlan{ - Metadata: relational.Metadata{Title: "Test SSP"}, - ControlImplementation: relational.ControlImplementation{ - ImplementedRequirements: []relational.ImplementedRequirement{ - { - ControlId: "CTRL-IMPL", - Statements: []relational.Statement{ - {StatementId: "CTRL-IMPL_smt.a"}, - }, - }, - }, - }, - } - suite.Require().NoError(suite.DB.Create(ssp).Error) - - rec := httptest.NewRecorder() - url := "/api/oscal/profiles/" + p.ID.String() + "/compliance-progress?sspId=" + ssp.ID.String() - req := httptest.NewRequest(http.MethodGet, url, nil) - req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) - - suite.server.E().ServeHTTP(rec, req) - suite.Require().Equal(http.StatusOK, rec.Code) - - var response handler.GenericDataResponse[ProfileComplianceProgress] - suite.Require().NoError(json.NewDecoder(rec.Body).Decode(&response)) - - suite.Require().Equal(2, response.Data.Summary.TotalControls) - suite.Require().NotNil(response.Data.Summary.ImplementedTotal, "implementedControls must be present when sspId provided") - suite.Require().Equal(1, *response.Data.Summary.ImplementedTotal) - - suite.Require().NotNil(response.Data.Implementation) - suite.Require().Equal(1, response.Data.Implementation.ImplementedControls) - suite.Require().Equal(1, response.Data.Implementation.UnimplementedControls) - suite.Require().Equal(50, response.Data.Implementation.ImplementationPct) - - implByID := make(map[string]bool, 2) - for _, c := range response.Data.Controls { - if c.Implemented != nil { - implByID[c.ControlID] = *c.Implemented - } - } - suite.Require().True(implByID["CTRL-IMPL"], "CTRL-IMPL should be implemented") - suite.Require().False(implByID["CTRL-UNIMPL"], "CTRL-UNIMPL should not be implemented") - }) - - suite.Run("Non-existent sspId returns 404", func() { - cat := &relational.Catalog{Metadata: relational.Metadata{Title: "404 SSP Catalog"}} - suite.Require().NoError(suite.DB.Create(cat).Error) - - ctrl := relational.Control{ID: "CTRL-ANY", CatalogID: *cat.ID, Title: "Any Control"} - suite.Require().NoError(suite.DB.Create(&ctrl).Error) - - p := &relational.Profile{ - Metadata: relational.Metadata{Title: "404 SSP Profile"}, - Controls: []relational.Control{ctrl}, - } - suite.Require().NoError(suite.DB.Create(p).Error) - - rec := httptest.NewRecorder() - url := "/api/oscal/profiles/" + p.ID.String() + "/compliance-progress?sspId=" + uuid.New().String() - req := httptest.NewRequest(http.MethodGet, url, nil) - req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) - - suite.server.E().ServeHTTP(rec, req) - suite.Require().Equal(http.StatusNotFound, rec.Code) - }) -} - func (suite *ProfileIntegrationSuite) TestGetControlCatalogFromBuiltProfile() { suite.IntegrationTestSuite.Migrator.Refresh() diff --git a/internal/api/handler/poam_items.go b/internal/api/handler/poam_items.go new file mode 100644 index 00000000..5e70afdb --- /dev/null +++ b/internal/api/handler/poam_items.go @@ -0,0 +1,1278 @@ +package handler + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/compliance-framework/api/internal/api" + poamsvc "github.com/compliance-framework/api/internal/service/relational/poam" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// PoamItemsHandler handles all HTTP requests for POAM items and their +// sub-resources. It delegates all persistence to PoamService and never +// imports gorm directly for data access. +type PoamItemsHandler struct { + poamService *poamsvc.PoamService + sugar *zap.SugaredLogger +} + +// NewPoamItemsHandler constructs a PoamItemsHandler. +func NewPoamItemsHandler(svc *poamsvc.PoamService, sugar *zap.SugaredLogger) *PoamItemsHandler { + return &PoamItemsHandler{poamService: svc, sugar: sugar} +} + +// Register mounts all POAM routes onto the given Echo group. JWT middleware +// is applied at the group level in api.go. +func (h *PoamItemsHandler) Register(g *echo.Group) { + h.registerRoutes(g) +} + +// RegisterSSPScoped mounts all POAM routes under an SSP-scoped group +// (e.g. /system-security-plans/:sspId/poam-items). The :sspId path param is +// extracted and injected into list/create filters automatically. +func (h *PoamItemsHandler) RegisterSSPScoped(g *echo.Group) { + h.registerRoutes(g) +} + +func (h *PoamItemsHandler) registerRoutes(g *echo.Group) { + g.GET("", h.List) + g.POST("", h.Create) + g.GET("/:id", h.Get) + g.PUT("/:id", h.Update) + g.DELETE("/:id", h.Delete) + + g.GET("/:id/milestones", h.ListMilestones) + g.POST("/:id/milestones", h.AddMilestone) + g.PUT("/:id/milestones/:milestoneId", h.UpdateMilestone) + g.DELETE("/:id/milestones/:milestoneId", h.DeleteMilestone) + + g.GET("/:id/risks", h.ListRisks) + g.POST("/:id/risks", h.AddRiskLink) + g.DELETE("/:id/risks/:riskId", h.DeleteRiskLink) + + g.GET("/:id/evidence", h.ListEvidence) + g.POST("/:id/evidence", h.AddEvidenceLink) + g.DELETE("/:id/evidence/:evidenceId", h.DeleteEvidenceLink) + + g.GET("/:id/controls", h.ListControls) + g.POST("/:id/controls", h.AddControlLink) + g.DELETE("/:id/controls/:catalogId/:controlId", h.DeleteControlLink) + + g.GET("/:id/findings", h.ListFindings) + g.POST("/:id/findings", h.AddFindingLink) + g.DELETE("/:id/findings/:findingId", h.DeleteFindingLink) +} + +// --------------------------------------------------------------------------- +// Request / response types +// --------------------------------------------------------------------------- + +type createPoamItemRequest struct { + SspID string `json:"sspId" validate:"required"` + Title string `json:"title" validate:"required"` + Description string `json:"description"` + Status string `json:"status"` + SourceType string `json:"sourceType"` + PrimaryOwnerUserID *string `json:"primaryOwnerUserId"` + PlannedCompletionDate *time.Time `json:"plannedCompletionDate"` + CreatedFromRiskID *string `json:"createdFromRiskId"` + AcceptanceRationale *string `json:"acceptanceRationale"` + RiskIDs []string `json:"riskIds"` + EvidenceIDs []string `json:"evidenceIds"` + ControlRefs []poamControlRefRequest `json:"controlRefs"` + FindingIDs []string `json:"findingIds"` + Milestones []createMilestoneRequest `json:"milestones"` +} + +type updatePoamItemRequest struct { + Title *string `json:"title"` + Description *string `json:"description"` + Status *string `json:"status"` + PrimaryOwnerUserID *string `json:"primaryOwnerUserId"` + PlannedCompletionDate *time.Time `json:"plannedCompletionDate"` + AcceptanceRationale *string `json:"acceptanceRationale"` + // Link management — add/remove in the same call as scalar updates. + AddRiskIDs []string `json:"addRiskIds"` + RemoveRiskIDs []string `json:"removeRiskIds"` + AddEvidenceIDs []string `json:"addEvidenceIds"` + RemoveEvidenceIDs []string `json:"removeEvidenceIds"` + AddControlRefs []poamControlRefRequest `json:"addControlRefs"` + RemoveControlRefs []poamControlRefRequest `json:"removeControlRefs"` + AddFindingIDs []string `json:"addFindingIds"` + RemoveFindingIDs []string `json:"removeFindingIds"` +} + +type createMilestoneRequest struct { + Title string `json:"title" validate:"required"` + Description string `json:"description"` + Status string `json:"status"` + ScheduledCompletionDate *time.Time `json:"scheduledCompletionDate"` + // OrderIndex is a pointer so that clients can explicitly set 0 without it + // being indistinguishable from an omitted field. + OrderIndex *int `json:"orderIndex"` +} + +type updateMilestoneRequest struct { + Title *string `json:"title"` + Description *string `json:"description"` + Status *string `json:"status"` + ScheduledCompletionDate *time.Time `json:"scheduledCompletionDate"` + OrderIndex *int `json:"orderIndex"` +} + +type addLinkRequest struct { + ID string `json:"id" validate:"required"` +} + +type poamControlRefRequest struct { + CatalogID string `json:"catalogId" validate:"required"` + ControlID string `json:"controlId" validate:"required"` +} + +// Response types — thin wrappers that avoid exposing raw GORM models. + +type riskLinkResponse struct { + PoamItemID uuid.UUID `json:"poamItemId"` + RiskID uuid.UUID `json:"riskId"` + CreatedAt time.Time `json:"createdAt"` +} + +type evidenceLinkResponse struct { + PoamItemID uuid.UUID `json:"poamItemId"` + EvidenceID uuid.UUID `json:"evidenceId"` + CreatedAt time.Time `json:"createdAt"` +} + +type controlLinkResponse struct { + PoamItemID uuid.UUID `json:"poamItemId"` + CatalogID uuid.UUID `json:"catalogId"` + ControlID string `json:"controlId"` + CreatedAt time.Time `json:"createdAt"` +} + +type findingLinkResponse struct { + PoamItemID uuid.UUID `json:"poamItemId"` + FindingID uuid.UUID `json:"findingId"` + CreatedAt time.Time `json:"createdAt"` +} + +type poamItemResponse struct { + ID uuid.UUID `json:"id"` + SspID uuid.UUID `json:"sspId"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + SourceType string `json:"sourceType"` + PrimaryOwnerUserID *uuid.UUID `json:"primaryOwnerUserId,omitempty"` + PlannedCompletionDate *time.Time `json:"plannedCompletionDate,omitempty"` + CompletedAt *time.Time `json:"completedAt,omitempty"` + CreatedFromRiskID *uuid.UUID `json:"createdFromRiskId,omitempty"` + AcceptanceRationale *string `json:"acceptanceRationale,omitempty"` + LastStatusChangeAt time.Time `json:"lastStatusChangeAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Milestones []milestoneResponse `json:"milestones,omitempty"` + RiskLinks []riskLinkResponse `json:"riskLinks,omitempty"` + EvidenceLinks []evidenceLinkResponse `json:"evidenceLinks,omitempty"` + ControlLinks []controlLinkResponse `json:"controlLinks,omitempty"` + FindingLinks []findingLinkResponse `json:"findingLinks,omitempty"` +} + +type milestoneResponse struct { + ID uuid.UUID `json:"id"` + PoamItemID uuid.UUID `json:"poamItemId"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + ScheduledCompletionDate *time.Time `json:"scheduledCompletionDate,omitempty"` + CompletionDate *time.Time `json:"completionDate,omitempty"` + OrderIndex int `json:"orderIndex"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func toPoamItemResponse(item *poamsvc.PoamItem) poamItemResponse { + r := poamItemResponse{ + ID: item.ID, + SspID: item.SspID, + Title: item.Title, + Description: item.Description, + Status: item.Status, + SourceType: item.SourceType, + PrimaryOwnerUserID: item.PrimaryOwnerUserID, + PlannedCompletionDate: item.PlannedCompletionDate, + CompletedAt: item.CompletedAt, + CreatedFromRiskID: item.CreatedFromRiskID, + AcceptanceRationale: item.AcceptanceRationale, + LastStatusChangeAt: item.LastStatusChangeAt, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } + for _, m := range item.Milestones { + r.Milestones = append(r.Milestones, toMilestoneResponse(&m)) + } + for _, l := range item.RiskLinks { + r.RiskLinks = append(r.RiskLinks, riskLinkResponse{ + PoamItemID: l.PoamItemID, + RiskID: l.RiskID, + CreatedAt: l.CreatedAt, + }) + } + for _, l := range item.EvidenceLinks { + r.EvidenceLinks = append(r.EvidenceLinks, evidenceLinkResponse{ + PoamItemID: l.PoamItemID, + EvidenceID: l.EvidenceID, + CreatedAt: l.CreatedAt, + }) + } + for _, l := range item.ControlLinks { + r.ControlLinks = append(r.ControlLinks, controlLinkResponse{ + PoamItemID: l.PoamItemID, + CatalogID: l.CatalogID, + ControlID: l.ControlID, + CreatedAt: l.CreatedAt, + }) + } + for _, l := range item.FindingLinks { + r.FindingLinks = append(r.FindingLinks, findingLinkResponse{ + PoamItemID: l.PoamItemID, + FindingID: l.FindingID, + CreatedAt: l.CreatedAt, + }) + } + return r +} + +func toMilestoneResponse(m *poamsvc.PoamItemMilestone) milestoneResponse { + return milestoneResponse{ + ID: m.ID, + PoamItemID: m.PoamItemID, + Title: m.Title, + Description: m.Description, + Status: m.Status, + ScheduledCompletionDate: m.ScheduledCompletionDate, + CompletionDate: m.CompletionDate, + OrderIndex: m.OrderIndex, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + } +} + +// --------------------------------------------------------------------------- +// POAM item handlers +// --------------------------------------------------------------------------- + +// List godoc +// +// @Summary List POAM items +// @Tags POAM Items +// @Produce json +// @Param status query string false "Filter by status (open|in-progress|completed|overdue)" +// @Param sspId query string false "Filter by SSP UUID" +// @Param riskId query string false "Filter by linked risk UUID" +// @Param deadlineBefore query string false "Filter by planned_completion_date before (RFC3339)" +// @Param overdueOnly query bool false "Return only overdue items" +// @Param ownerRef query string false "Filter by primary_owner_user_id UUID" +// @Success 200 {object} GenericDataListResponse[poamItemResponse] +// @Failure 400 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items [get] +func (h *PoamItemsHandler) List(c echo.Context) error { + filters, err := parsePoamListFilters(c) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + // When mounted under /system-security-plans/:sspId/poam-items, the sspId + // path param takes precedence over the query parameter. + if sspIDParam := c.Param("sspId"); sspIDParam != "" { + parsed, err := uuid.Parse(sspIDParam) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("sspId path param must be a valid UUID"))) + } + filters.SspID = &parsed + } + items, err := h.poamService.List(filters) + if err != nil { + return h.internalError(c, "failed to list poam items", err) + } + resp := make([]poamItemResponse, 0, len(items)) + for i := range items { + resp = append(resp, toPoamItemResponse(&items[i])) + } + return c.JSON(http.StatusOK, GenericDataListResponse[poamItemResponse]{Data: resp}) +} + +// Create godoc +// +// @Summary Create a POAM item +// @Tags POAM Items +// @Accept json +// @Produce json +// @Param body body createPoamItemRequest true "POAM item payload" +// @Success 201 {object} GenericDataResponse[poamItemResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items [post] +func (h *PoamItemsHandler) Create(c echo.Context) error { + var in createPoamItemRequest + if err := c.Bind(&in); err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + // When mounted under /system-security-plans/:sspId/poam-items, the sspId + // path param overrides the body field so the client doesn't have to repeat it. + if sspIDParam := c.Param("sspId"); sspIDParam != "" { + in.SspID = sspIDParam + } + if err := c.Validate(&in); err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + sspID, err := uuid.Parse(in.SspID) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("sspId must be a valid UUID"))) + } + if err := h.poamService.EnsureSSPExists(sspID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("ssp not found: %s", sspID))) + } + return h.internalError(c, "failed to validate ssp", err) + } + + if in.Status != "" && !poamsvc.PoamItemStatus(in.Status).IsValid() { + return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("invalid status: %s", in.Status))) + } + if in.SourceType != "" && !poamsvc.PoamItemSourceType(in.SourceType).IsValid() { + return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("invalid sourceType: %s", in.SourceType))) + } + + params := poamsvc.CreatePoamItemParams{ + SspID: sspID, + Title: in.Title, + Description: in.Description, + Status: in.Status, + SourceType: in.SourceType, + PlannedCompletionDate: in.PlannedCompletionDate, + AcceptanceRationale: in.AcceptanceRationale, + } + + if in.PrimaryOwnerUserID != nil { + ownerID, err := uuid.Parse(*in.PrimaryOwnerUserID) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("primaryOwnerUserId must be a valid UUID"))) + } + params.PrimaryOwnerUserID = &ownerID + } + if in.CreatedFromRiskID != nil { + riskID, err := uuid.Parse(*in.CreatedFromRiskID) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("createdFromRiskId must be a valid UUID"))) + } + params.CreatedFromRiskID = &riskID + } + + riskIDs, err := parseUUIDs(in.RiskIDs, "riskIds") + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + params.RiskIDs = riskIDs + + evidenceIDs, err := parseUUIDs(in.EvidenceIDs, "evidenceIds") + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + params.EvidenceIDs = evidenceIDs + + findingIDs, err := parseUUIDs(in.FindingIDs, "findingIds") + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + params.FindingIDs = findingIDs + + controlRefs, err := parseControlRefs(in.ControlRefs) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + params.ControlRefs = controlRefs + + for i, mr := range in.Milestones { + if mr.Title == "" { + return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("milestone title is required"))) + } + if mr.Status != "" && !poamsvc.MilestoneStatus(mr.Status).IsValid() { + return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("invalid milestone status: %s", mr.Status))) + } + // When orderIndex is omitted (nil), fall back to the slice position so + // ordering is still deterministic without requiring the client to set it. + msOrderIdx := i + if mr.OrderIndex != nil { + msOrderIdx = *mr.OrderIndex + } + params.Milestones = append(params.Milestones, poamsvc.CreateMilestoneParams{ + Title: mr.Title, + Description: mr.Description, + Status: mr.Status, + ScheduledCompletionDate: mr.ScheduledCompletionDate, + OrderIndex: msOrderIdx, + }) + } + + item, err := h.poamService.Create(params) + if err != nil { + return h.internalError(c, "failed to create poam item", err) + } + return c.JSON(http.StatusCreated, GenericDataResponse[poamItemResponse]{Data: toPoamItemResponse(item)}) +} + +// Get godoc +// +// @Summary Get a POAM item +// @Tags POAM Items +// @Produce json +// @Param id path string true "POAM item ID" +// @Success 200 {object} GenericDataResponse[poamItemResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items/{id} [get] +func (h *PoamItemsHandler) Get(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + item, err := h.poamService.GetByID(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to get poam item", err) + } + return c.JSON(http.StatusOK, GenericDataResponse[poamItemResponse]{Data: toPoamItemResponse(item)}) +} + +// Update godoc +// +// @Summary Update a POAM item +// @Tags POAM Items +// @Accept json +// @Produce json +// @Param id path string true "POAM item ID" +// @Param body body updatePoamItemRequest true "Update payload" +// @Success 200 {object} GenericDataResponse[poamItemResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items/{id} [put] +func (h *PoamItemsHandler) Update(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + var in updatePoamItemRequest + if err := c.Bind(&in); err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + + if in.Status != nil && !poamsvc.PoamItemStatus(*in.Status).IsValid() { + return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("invalid status: %s", *in.Status))) + } + + params := poamsvc.UpdatePoamItemParams{ + Title: in.Title, + Description: in.Description, + Status: in.Status, + PlannedCompletionDate: in.PlannedCompletionDate, + AcceptanceRationale: in.AcceptanceRationale, + } + + if in.PrimaryOwnerUserID != nil { + ownerID, err := uuid.Parse(*in.PrimaryOwnerUserID) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("primaryOwnerUserId must be a valid UUID"))) + } + params.PrimaryOwnerUserID = &ownerID + } + + addRiskIDs, err := parseUUIDs(in.AddRiskIDs, "addRiskIds") + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + params.AddRiskIDs = addRiskIDs + + removeRiskIDs, err := parseUUIDs(in.RemoveRiskIDs, "removeRiskIds") + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + params.RemoveRiskIDs = removeRiskIDs + + addEvidenceIDs, err := parseUUIDs(in.AddEvidenceIDs, "addEvidenceIds") + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + params.AddEvidenceIDs = addEvidenceIDs + + removeEvidenceIDs, err := parseUUIDs(in.RemoveEvidenceIDs, "removeEvidenceIds") + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + params.RemoveEvidenceIDs = removeEvidenceIDs + + addControlRefs, err := parseControlRefs(in.AddControlRefs) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + params.AddControlRefs = addControlRefs + + removeControlRefs, err := parseControlRefs(in.RemoveControlRefs) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + params.RemoveControlRefs = removeControlRefs + + addFindingIDs, err := parseUUIDs(in.AddFindingIDs, "addFindingIds") + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + params.AddFindingIDs = addFindingIDs + + removeFindingIDs, err := parseUUIDs(in.RemoveFindingIDs, "removeFindingIds") + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + params.RemoveFindingIDs = removeFindingIDs + + item, err := h.poamService.Update(id, params) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to update poam item", err) + } + return c.JSON(http.StatusOK, GenericDataResponse[poamItemResponse]{Data: toPoamItemResponse(item)}) +} + +// Delete godoc +// +// @Summary Delete a POAM item +// @Tags POAM Items +// @Param id path string true "POAM item ID" +// @Success 204 "No Content" +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items/{id} [delete] +func (h *PoamItemsHandler) Delete(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.poamService.Delete(id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to delete poam item", err) + } + return c.NoContent(http.StatusNoContent) +} + +// --------------------------------------------------------------------------- +// Milestone handlers +// --------------------------------------------------------------------------- + +// ListMilestones godoc +// +// @Summary List milestones for a POAM item +// @Tags POAM Items +// @Produce json +// @Param id path string true "POAM item ID" +// @Success 200 {object} GenericDataListResponse[milestoneResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items/{id}/milestones [get] +func (h *PoamItemsHandler) ListMilestones(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.poamService.EnsureExists(id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to validate poam item", err) + } + milestones, err := h.poamService.ListMilestones(id) + if err != nil { + return h.internalError(c, "failed to list milestones", err) + } + resp := make([]milestoneResponse, 0, len(milestones)) + for i := range milestones { + resp = append(resp, toMilestoneResponse(&milestones[i])) + } + return c.JSON(http.StatusOK, GenericDataListResponse[milestoneResponse]{Data: resp}) +} + +// AddMilestone godoc +// +// @Summary Add a milestone to a POAM item +// @Tags POAM Items +// @Accept json +// @Produce json +// @Param id path string true "POAM item ID" +// @Param body body createMilestoneRequest true "Milestone payload" +// @Success 201 {object} GenericDataResponse[milestoneResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items/{id}/milestones [post] +func (h *PoamItemsHandler) AddMilestone(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.poamService.EnsureExists(id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to validate poam item", err) + } + var in createMilestoneRequest + if err := c.Bind(&in); err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := c.Validate(&in); err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if in.Status != "" && !poamsvc.MilestoneStatus(in.Status).IsValid() { + return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("invalid milestone status: %s", in.Status))) + } + var orderIdx int + if in.OrderIndex != nil { + orderIdx = *in.OrderIndex + } + m, err := h.poamService.AddMilestone(id, poamsvc.CreateMilestoneParams{ + Title: in.Title, + Description: in.Description, + Status: in.Status, + ScheduledCompletionDate: in.ScheduledCompletionDate, + OrderIndex: orderIdx, + }) + if err != nil { + return h.internalError(c, "failed to add milestone", err) + } + return c.JSON(http.StatusCreated, GenericDataResponse[milestoneResponse]{Data: toMilestoneResponse(m)}) +} + +// UpdateMilestone godoc +// +// @Summary Update a milestone +// @Tags POAM Items +// @Accept json +// @Produce json +// @Param id path string true "POAM item ID" +// @Param milestoneId path string true "Milestone ID" +// @Param body body updateMilestoneRequest true "Milestone update payload" +// @Success 200 {object} GenericDataResponse[milestoneResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items/{id}/milestones/{milestoneId} [put] +func (h *PoamItemsHandler) UpdateMilestone(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + milestoneID, err := uuid.Parse(c.Param("milestoneId")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + var in updateMilestoneRequest + if err := c.Bind(&in); err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if in.Status != nil && !poamsvc.MilestoneStatus(*in.Status).IsValid() { + return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("invalid milestone status: %s", *in.Status))) + } + m, err := h.poamService.UpdateMilestone(id, milestoneID, poamsvc.UpdateMilestoneParams{ + Title: in.Title, + Description: in.Description, + Status: in.Status, + ScheduledCompletionDate: in.ScheduledCompletionDate, + OrderIndex: in.OrderIndex, + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to update milestone", err) + } + return c.JSON(http.StatusOK, GenericDataResponse[milestoneResponse]{Data: toMilestoneResponse(m)}) +} + +// DeleteMilestone godoc +// +// @Summary Delete a milestone +// @Tags POAM Items +// @Param id path string true "POAM item ID" +// @Param milestoneId path string true "Milestone ID" +// @Success 204 "No Content" +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items/{id}/milestones/{milestoneId} [delete] +func (h *PoamItemsHandler) DeleteMilestone(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + milestoneID, err := uuid.Parse(c.Param("milestoneId")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.poamService.DeleteMilestone(id, milestoneID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to delete milestone", err) + } + return c.NoContent(http.StatusNoContent) +} + +// --------------------------------------------------------------------------- +// Risk link handlers +// --------------------------------------------------------------------------- + +// ListRisks godoc +// +// @Summary List linked risks +// @Tags POAM Items +// @Produce json +// @Param id path string true "POAM item ID" +// @Success 200 {object} GenericDataListResponse[poamsvc.PoamItemRiskLink] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items/{id}/risks [get] +func (h *PoamItemsHandler) ListRisks(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.poamService.EnsureExists(id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to validate poam item", err) + } + links, err := h.poamService.ListRiskLinks(id) + if err != nil { + return h.internalError(c, "failed to list risk links", err) + } + return c.JSON(http.StatusOK, GenericDataListResponse[poamsvc.PoamItemRiskLink]{Data: links}) +} + +// AddRiskLink godoc +// +// @Summary Add a risk link +// @Tags POAM Items +// @Accept json +// @Produce json +// @Param id path string true "POAM item ID" +// @Param body body addLinkRequest true "Risk ID payload" +// @Success 201 {object} GenericDataResponse[poamsvc.PoamItemRiskLink] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items/{id}/risks [post] +func (h *PoamItemsHandler) AddRiskLink(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.poamService.EnsureExists(id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to validate poam item", err) + } + var in addLinkRequest + if err := c.Bind(&in); err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := c.Validate(&in); err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + riskID, err := uuid.Parse(in.ID) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("id must be a valid UUID"))) + } + link, err := h.poamService.AddRiskLink(id, riskID) + if err != nil { + return h.internalError(c, "failed to add risk link", err) + } + return c.JSON(http.StatusCreated, GenericDataResponse[poamsvc.PoamItemRiskLink]{Data: *link}) +} + +// DeleteRiskLink godoc +// +// @Summary Delete a risk link +// @Tags POAM Items +// @Param id path string true "POAM item ID" +// @Param riskId path string true "Risk ID" +// @Success 204 "No Content" +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items/{id}/risks/{riskId} [delete] +func (h *PoamItemsHandler) DeleteRiskLink(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + riskID, err := uuid.Parse(c.Param("riskId")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.poamService.DeleteRiskLink(id, riskID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to delete risk link", err) + } + return c.NoContent(http.StatusNoContent) +} + +// --------------------------------------------------------------------------- +// Evidence link handlers +// --------------------------------------------------------------------------- + +// ListEvidence godoc +// +// @Summary List linked evidence +// @Tags POAM Items +// @Produce json +// @Param id path string true "POAM item ID" +// @Success 200 {object} GenericDataListResponse[poamsvc.PoamItemEvidenceLink] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items/{id}/evidence [get] +func (h *PoamItemsHandler) ListEvidence(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.poamService.EnsureExists(id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to validate poam item", err) + } + links, err := h.poamService.ListEvidenceLinks(id) + if err != nil { + return h.internalError(c, "failed to list evidence links", err) + } + return c.JSON(http.StatusOK, GenericDataListResponse[poamsvc.PoamItemEvidenceLink]{Data: links}) +} + +// AddEvidenceLink godoc +// +// @Summary Add an evidence link +// @Tags POAM Items +// @Accept json +// @Produce json +// @Param id path string true "POAM item ID" +// @Param body body addLinkRequest true "Evidence ID payload" +// @Success 201 {object} GenericDataResponse[poamsvc.PoamItemEvidenceLink] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items/{id}/evidence [post] +func (h *PoamItemsHandler) AddEvidenceLink(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.poamService.EnsureExists(id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to validate poam item", err) + } + var in addLinkRequest + if err := c.Bind(&in); err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := c.Validate(&in); err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + evidenceID, err := uuid.Parse(in.ID) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("id must be a valid UUID"))) + } + link, err := h.poamService.AddEvidenceLink(id, evidenceID) + if err != nil { + return h.internalError(c, "failed to add evidence link", err) + } + return c.JSON(http.StatusCreated, GenericDataResponse[poamsvc.PoamItemEvidenceLink]{Data: *link}) +} + +// DeleteEvidenceLink godoc +// +// @Summary Delete an evidence link +// @Tags POAM Items +// @Param id path string true "POAM item ID" +// @Param evidenceId path string true "Evidence ID" +// @Success 204 "No Content" +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items/{id}/evidence/{evidenceId} [delete] +func (h *PoamItemsHandler) DeleteEvidenceLink(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + evidenceID, err := uuid.Parse(c.Param("evidenceId")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.poamService.DeleteEvidenceLink(id, evidenceID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to delete evidence link", err) + } + return c.NoContent(http.StatusNoContent) +} + +// --------------------------------------------------------------------------- +// Control link handlers +// --------------------------------------------------------------------------- + +// ListControls godoc +// +// @Summary List linked controls +// @Tags POAM Items +// @Produce json +// @Param id path string true "POAM item ID" +// @Success 200 {object} GenericDataListResponse[poamsvc.PoamItemControlLink] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items/{id}/controls [get] +func (h *PoamItemsHandler) ListControls(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.poamService.EnsureExists(id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to validate poam item", err) + } + links, err := h.poamService.ListControlLinks(id) + if err != nil { + return h.internalError(c, "failed to list control links", err) + } + return c.JSON(http.StatusOK, GenericDataListResponse[poamsvc.PoamItemControlLink]{Data: links}) +} + +// AddControlLink godoc +// +// @Summary Add a control link +// @Tags POAM Items +// @Accept json +// @Produce json +// @Param id path string true "POAM item ID" +// @Param body body poamControlRefRequest true "Control ref payload" +// @Success 201 {object} GenericDataResponse[poamsvc.PoamItemControlLink] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items/{id}/controls [post] +func (h *PoamItemsHandler) AddControlLink(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.poamService.EnsureExists(id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to validate poam item", err) + } + var in poamControlRefRequest + if err := c.Bind(&in); err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := c.Validate(&in); err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + catID, err := uuid.Parse(in.CatalogID) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("catalogId must be a valid UUID"))) + } + link, err := h.poamService.AddControlLink(id, poamsvc.ControlRef{CatalogID: catID, ControlID: in.ControlID}) + if err != nil { + return h.internalError(c, "failed to add control link", err) + } + return c.JSON(http.StatusCreated, GenericDataResponse[poamsvc.PoamItemControlLink]{Data: *link}) +} + +// DeleteControlLink godoc +// +// @Summary Delete a control link +// @Tags POAM Items +// @Param id path string true "POAM item ID" +// @Param catalogId path string true "Catalog ID" +// @Param controlId path string true "Control ID" +// @Success 204 "No Content" +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items/{id}/controls/{catalogId}/{controlId} [delete] +func (h *PoamItemsHandler) DeleteControlLink(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + catID, err := uuid.Parse(c.Param("catalogId")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + controlID := c.Param("controlId") + if controlID == "" { + return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("controlId path param is required"))) + } + if err := h.poamService.DeleteControlLink(id, catID, controlID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to delete control link", err) + } + return c.NoContent(http.StatusNoContent) +} + +// --------------------------------------------------------------------------- +// Finding link handlers +// --------------------------------------------------------------------------- + +// ListFindings godoc +// +// @Summary List linked findings +// @Tags POAM Items +// @Produce json +// @Param id path string true "POAM item ID" +// @Success 200 {object} GenericDataListResponse[poamsvc.PoamItemFindingLink] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items/{id}/findings [get] +func (h *PoamItemsHandler) ListFindings(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.poamService.EnsureExists(id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to validate poam item", err) + } + links, err := h.poamService.ListFindingLinks(id) + if err != nil { + return h.internalError(c, "failed to list finding links", err) + } + return c.JSON(http.StatusOK, GenericDataListResponse[poamsvc.PoamItemFindingLink]{Data: links}) +} + +// AddFindingLink godoc +// +// @Summary Add a finding link +// @Tags POAM Items +// @Accept json +// @Produce json +// @Param id path string true "POAM item ID" +// @Param body body addLinkRequest true "Finding ID payload" +// @Success 201 {object} GenericDataResponse[poamsvc.PoamItemFindingLink] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items/{id}/findings [post] +func (h *PoamItemsHandler) AddFindingLink(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.poamService.EnsureExists(id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to validate poam item", err) + } + var in addLinkRequest + if err := c.Bind(&in); err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := c.Validate(&in); err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + findingID, err := uuid.Parse(in.ID) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("id must be a valid UUID"))) + } + link, err := h.poamService.AddFindingLink(id, findingID) + if err != nil { + return h.internalError(c, "failed to add finding link", err) + } + return c.JSON(http.StatusCreated, GenericDataResponse[poamsvc.PoamItemFindingLink]{Data: *link}) +} + +// DeleteFindingLink godoc +// +// @Summary Delete a finding link +// @Tags POAM Items +// @Param id path string true "POAM item ID" +// @Param findingId path string true "Finding ID" +// @Success 204 "No Content" +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /poam-items/{id}/findings/{findingId} [delete] +func (h *PoamItemsHandler) DeleteFindingLink(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + findingID, err := uuid.Parse(c.Param("findingId")) + if err != nil { + return c.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.poamService.DeleteFindingLink(id, findingID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to delete finding link", err) + } + return c.NoContent(http.StatusNoContent) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// parseListFilters parses and validates all query parameters for the List +// endpoint. Returns 400-compatible errors for any malformed UUID or RFC3339 +// value rather than silently ignoring them (Copilot item 12). +func parsePoamListFilters(c echo.Context) (poamsvc.ListFilters, error) { + var f poamsvc.ListFilters + + if s := c.QueryParam("status"); s != "" { + if !poamsvc.PoamItemStatus(s).IsValid() { + return f, fmt.Errorf("invalid status filter: %s", s) + } + f.Status = s + } + + if s := c.QueryParam("sspId"); s != "" { + id, err := uuid.Parse(s) + if err != nil { + return f, fmt.Errorf("sspId must be a valid UUID") + } + f.SspID = &id + } + + if s := c.QueryParam("riskId"); s != "" { + id, err := uuid.Parse(s) + if err != nil { + return f, fmt.Errorf("riskId must be a valid UUID") + } + f.RiskID = &id + } + + if s := c.QueryParam("deadlineBefore"); s != "" { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return f, fmt.Errorf("deadlineBefore must be an RFC3339 timestamp") + } + f.DeadlineBefore = &t + } + + if s := c.QueryParam("overdueOnly"); s == "true" { + f.OverdueOnly = true + } + + if s := c.QueryParam("ownerRef"); s != "" { + id, err := uuid.Parse(s) + if err != nil { + return f, fmt.Errorf("ownerRef must be a valid UUID") + } + f.OwnerRef = &id + } + + return f, nil +} + +// parseUUIDs converts a slice of raw strings to uuid.UUIDs, returning a +// descriptive 400 error for any malformed entry. +func parseUUIDs(raw []string, field string) ([]uuid.UUID, error) { + result := make([]uuid.UUID, 0, len(raw)) + for _, s := range raw { + id, err := uuid.Parse(s) + if err != nil { + return nil, fmt.Errorf("%s contains invalid UUID: %s", field, s) + } + result = append(result, id) + } + return result, nil +} + +// parseControlRefs converts a slice of poamControlRefRequest to ControlRef, +// validating the catalogId UUID in each entry. +func parseControlRefs(raw []poamControlRefRequest) ([]poamsvc.ControlRef, error) { + result := make([]poamsvc.ControlRef, 0, len(raw)) + for _, r := range raw { + catID, err := uuid.Parse(r.CatalogID) + if err != nil { + return nil, fmt.Errorf("controlRefs contains invalid catalogId UUID: %s", r.CatalogID) + } + if r.ControlID == "" { + return nil, fmt.Errorf("controlRefs entry is missing controlId") + } + result = append(result, poamsvc.ControlRef{CatalogID: catID, ControlID: r.ControlID}) + } + return result, nil +} + +func (h *PoamItemsHandler) internalError(c echo.Context, msg string, err error) error { + h.sugar.Errorw(msg, "error", err) + return c.JSON(http.StatusInternalServerError, api.NewError(err)) +} diff --git a/internal/api/handler/poam_items_integration_test.go b/internal/api/handler/poam_items_integration_test.go new file mode 100644 index 00000000..283fcbd5 --- /dev/null +++ b/internal/api/handler/poam_items_integration_test.go @@ -0,0 +1,946 @@ +//go:build integration + +package handler + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/compliance-framework/api/internal/api" + "github.com/compliance-framework/api/internal/service/relational" + poamsvc "github.com/compliance-framework/api/internal/service/relational/poam" + "github.com/compliance-framework/api/internal/tests" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "go.uber.org/zap" + "gorm.io/gorm/clause" +) + +// --------------------------------------------------------------------------- +// Suite bootstrap +// --------------------------------------------------------------------------- + +func TestPoamItemsApi(t *testing.T) { + suite.Run(t, new(PoamItemsApiIntegrationSuite)) +} + +type PoamItemsApiIntegrationSuite struct { + tests.IntegrationTestSuite +} + +func (suite *PoamItemsApiIntegrationSuite) newServer() *api.Server { + logger, _ := zap.NewDevelopment() + metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) + server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil) + return server +} + +// authedReq creates an authenticated HTTP request with a valid JWT token. +// body may be nil for requests without a payload (GET, DELETE). +func (suite *PoamItemsApiIntegrationSuite) authedReq(method, path string, body []byte) (*httptest.ResponseRecorder, *http.Request) { + token, err := suite.GetAuthToken() + suite.Require().NoError(err) + + var reader *bytes.Reader + if body != nil { + reader = bytes.NewReader(body) + } else { + reader = bytes.NewReader([]byte{}) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(method, path, reader) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) + return rec, req +} + +// intPtr is a convenience helper that returns a pointer to an int literal. +func intPtr(i int) *int { return &i } + +// ensureSSP seeds a SystemSecurityPlan row so that the Create handler's +// EnsureSSPExists check passes. The SSP record only needs an ID. +func (suite *PoamItemsApiIntegrationSuite) ensureSSP(id uuid.UUID) { + ssp := relational.SystemSecurityPlan{UUIDModel: relational.UUIDModel{ID: &id}} + suite.Require().NoError(suite.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&ssp).Error) +} + +// seedItem inserts a PoamItem directly into the DB, bypassing the API. +func (suite *PoamItemsApiIntegrationSuite) seedItem(sspID uuid.UUID, title, status string) poamsvc.PoamItem { + item := poamsvc.PoamItem{ + ID: uuid.New(), + SspID: sspID, + Title: title, + Description: "seeded for test", + Status: status, + SourceType: string(poamsvc.PoamItemSourceTypeManual), + LastStatusChangeAt: time.Now().UTC(), + } + suite.Require().NoError(suite.DB.Create(&item).Error) + return item +} + +// seedMilestone inserts a PoamItemMilestone directly into the DB. +func (suite *PoamItemsApiIntegrationSuite) seedMilestone(poamID uuid.UUID, title, status string, orderIdx int) poamsvc.PoamItemMilestone { + m := poamsvc.PoamItemMilestone{ + ID: uuid.New(), + PoamItemID: poamID, + Title: title, + Status: string(poamsvc.MilestoneStatus(status)), + OrderIndex: orderIdx, + } + suite.Require().NoError(suite.DB.Create(&m).Error) + return m +} + +// --------------------------------------------------------------------------- +// POST /poam-items +// --------------------------------------------------------------------------- + +func (suite *PoamItemsApiIntegrationSuite) TestCreate_MinimalPayload() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + suite.ensureSSP(sspID) + body := createPoamItemRequest{ + SspID: sspID.String(), + Title: "Remediate secret scanning", + Description: "Enable secret scanning across all repos", + Status: "open", + } + raw, _ := json.Marshal(body) + rec, req := suite.authedReq(http.MethodPost, "/api/poam-items", raw) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusCreated, rec.Code) + var resp GenericDataResponse[poamItemResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(suite.T(), "Remediate secret scanning", resp.Data.Title) + assert.Equal(suite.T(), "open", resp.Data.Status) + assert.Equal(suite.T(), "manual", resp.Data.SourceType) + assert.NotEqual(suite.T(), uuid.Nil, resp.Data.ID) +} + +func (suite *PoamItemsApiIntegrationSuite) TestCreate_WithMilestonesAndLinks() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + suite.ensureSSP(sspID) + due := time.Now().Add(30 * 24 * time.Hour).UTC().Truncate(time.Second) + body := createPoamItemRequest{ + SspID: sspID.String(), + Title: "Patch OS vulnerabilities", + Description: "Apply all critical OS patches", + Status: "open", + SourceType: "risk-promotion", + Milestones: []createMilestoneRequest{ + {Title: "Patch staging", Status: "planned", ScheduledCompletionDate: &due, OrderIndex: intPtr(0)}, + {Title: "Patch production", Status: "planned", OrderIndex: intPtr(1)}, + }, + } + raw, _ := json.Marshal(body) + rec, req := suite.authedReq(http.MethodPost, "/api/poam-items", raw) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusCreated, rec.Code) + var resp GenericDataResponse[poamItemResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(suite.T(), "risk-promotion", resp.Data.SourceType) + assert.Len(suite.T(), resp.Data.Milestones, 2) + assert.Equal(suite.T(), "Patch staging", resp.Data.Milestones[0].Title) + assert.Equal(suite.T(), "Patch production", resp.Data.Milestones[1].Title) +} + +func (suite *PoamItemsApiIntegrationSuite) TestCreate_WithRiskLinks() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + suite.ensureSSP(sspID) + riskID := uuid.New() + body := createPoamItemRequest{ + SspID: sspID.String(), + Title: "Linked to risk", + Description: "POAM item linked to a risk", + Status: "open", + RiskIDs: []string{riskID.String()}, + } + raw, _ := json.Marshal(body) + rec, req := suite.authedReq(http.MethodPost, "/api/poam-items", raw) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusCreated, rec.Code) + var resp GenericDataResponse[poamItemResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + var links []poamsvc.PoamItemRiskLink + suite.Require().NoError(suite.DB.Where("poam_item_id = ?", resp.Data.ID).Find(&links).Error) + assert.Len(suite.T(), links, 1) + assert.Equal(suite.T(), riskID, links[0].RiskID) +} + +func (suite *PoamItemsApiIntegrationSuite) TestCreate_WithAllLinkTypes() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + suite.ensureSSP(sspID) + riskID := uuid.New() + evidenceID := uuid.New() + findingID := uuid.New() + catalogID := uuid.New() + body := createPoamItemRequest{ + SspID: sspID.String(), + Title: "Full link test", + Description: "POAM item with all link types", + Status: "open", + RiskIDs: []string{riskID.String()}, + EvidenceIDs: []string{evidenceID.String()}, + FindingIDs: []string{findingID.String()}, + ControlRefs: []poamControlRefRequest{{CatalogID: catalogID.String(), ControlID: "AC-1"}}, + } + raw, _ := json.Marshal(body) + rec, req := suite.authedReq(http.MethodPost, "/api/poam-items", raw) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusCreated, rec.Code) + var resp GenericDataResponse[poamItemResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + itemID := resp.Data.ID + var riskLinks []poamsvc.PoamItemRiskLink + suite.DB.Where("poam_item_id = ?", itemID).Find(&riskLinks) + assert.Len(suite.T(), riskLinks, 1) + var evidenceLinks []poamsvc.PoamItemEvidenceLink + suite.DB.Where("poam_item_id = ?", itemID).Find(&evidenceLinks) + assert.Len(suite.T(), evidenceLinks, 1) + var findingLinks []poamsvc.PoamItemFindingLink + suite.DB.Where("poam_item_id = ?", itemID).Find(&findingLinks) + assert.Len(suite.T(), findingLinks, 1) + var controlLinks []poamsvc.PoamItemControlLink + suite.DB.Where("poam_item_id = ?", itemID).Find(&controlLinks) + assert.Len(suite.T(), controlLinks, 1) + assert.Equal(suite.T(), "AC-1", controlLinks[0].ControlID) +} + +func (suite *PoamItemsApiIntegrationSuite) TestCreate_InvalidSspID() { + suite.Require().NoError(suite.Migrator.Refresh()) + // No SSP seeded — invalid UUID should be rejected before the DB lookup. + body := map[string]interface{}{"sspId": "not-a-uuid", "title": "X", "status": "open"} + raw, _ := json.Marshal(body) + rec, req := suite.authedReq(http.MethodPost, "/api/poam-items", raw) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusBadRequest, rec.Code) +} + +// --------------------------------------------------------------------------- +// GET /poam-items (list with filters) +// --------------------------------------------------------------------------- + +func (suite *PoamItemsApiIntegrationSuite) TestList_NoFilter() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + suite.seedItem(sspID, "Item A", "open") + suite.seedItem(sspID, "Item B", "in-progress") + suite.seedItem(uuid.New(), "Item C", "completed") + rec, req := suite.authedReq(http.MethodGet, "/api/poam-items", nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var resp GenericDataListResponse[poamItemResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Len(suite.T(), resp.Data, 3) +} + +func (suite *PoamItemsApiIntegrationSuite) TestList_FilterByStatus() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + suite.seedItem(sspID, "Open item", "open") + suite.seedItem(sspID, "In-progress item", "in-progress") + suite.seedItem(sspID, "Completed item", "completed") + rec, req := suite.authedReq(http.MethodGet, "/api/poam-items?status=open", nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var resp GenericDataListResponse[poamItemResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Len(suite.T(), resp.Data, 1) + assert.Equal(suite.T(), "open", resp.Data[0].Status) +} + +func (suite *PoamItemsApiIntegrationSuite) TestList_FilterBySspId() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspA := uuid.New() + sspB := uuid.New() + suite.seedItem(sspA, "SSP-A item 1", "open") + suite.seedItem(sspA, "SSP-A item 2", "open") + suite.seedItem(sspB, "SSP-B item", "open") + rec, req := suite.authedReq(http.MethodGet, fmt.Sprintf("/api/poam-items?sspId=%s", sspA), nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var resp GenericDataListResponse[poamItemResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Len(suite.T(), resp.Data, 2) + for _, item := range resp.Data { + assert.Equal(suite.T(), sspA, item.SspID) + } +} + +func (suite *PoamItemsApiIntegrationSuite) TestList_FilterByRiskId() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + riskID := uuid.New() + item1 := suite.seedItem(sspID, "Linked to risk", "open") + suite.seedItem(sspID, "Not linked", "open") + suite.Require().NoError(suite.DB.Create(&poamsvc.PoamItemRiskLink{PoamItemID: item1.ID, RiskID: riskID}).Error) + rec, req := suite.authedReq(http.MethodGet, fmt.Sprintf("/api/poam-items?riskId=%s", riskID), nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var resp GenericDataListResponse[poamItemResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Len(suite.T(), resp.Data, 1) + assert.Equal(suite.T(), item1.ID, resp.Data[0].ID) +} + +func (suite *PoamItemsApiIntegrationSuite) TestList_FilterByDueBefore() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + past := time.Now().Add(-24 * time.Hour).UTC() + future := time.Now().Add(30 * 24 * time.Hour).UTC() + itemPast := poamsvc.PoamItem{ + ID: uuid.New(), SspID: sspID, Title: "Past due", Description: "d", + Status: string(poamsvc.PoamItemStatusOpen), SourceType: string(poamsvc.PoamItemSourceTypeManual), + PlannedCompletionDate: &past, LastStatusChangeAt: time.Now().UTC(), + } + itemFuture := poamsvc.PoamItem{ + ID: uuid.New(), SspID: sspID, Title: "Future due", Description: "d", + Status: string(poamsvc.PoamItemStatusOpen), SourceType: string(poamsvc.PoamItemSourceTypeManual), + PlannedCompletionDate: &future, LastStatusChangeAt: time.Now().UTC(), + } + suite.Require().NoError(suite.DB.Create(&itemPast).Error) + suite.Require().NoError(suite.DB.Create(&itemFuture).Error) + cutoff := time.Now().UTC().Format(time.RFC3339) + rec, req := suite.authedReq(http.MethodGet, fmt.Sprintf("/api/poam-items?deadlineBefore=%s", cutoff), nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var resp GenericDataListResponse[poamItemResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Len(suite.T(), resp.Data, 1) + assert.Equal(suite.T(), itemPast.ID, resp.Data[0].ID) +} + +func (suite *PoamItemsApiIntegrationSuite) TestList_FilterOverdueOnly() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + past := time.Now().Add(-24 * time.Hour).UTC() + future := time.Now().Add(30 * 24 * time.Hour).UTC() + overdueItem := poamsvc.PoamItem{ + ID: uuid.New(), SspID: sspID, Title: "Overdue open", Description: "d", + Status: string(poamsvc.PoamItemStatusOpen), SourceType: string(poamsvc.PoamItemSourceTypeManual), + PlannedCompletionDate: &past, LastStatusChangeAt: time.Now().UTC(), + } + completedPast := poamsvc.PoamItem{ + ID: uuid.New(), SspID: sspID, Title: "Completed past", Description: "d", + Status: string(poamsvc.PoamItemStatusCompleted), SourceType: string(poamsvc.PoamItemSourceTypeManual), + PlannedCompletionDate: &past, LastStatusChangeAt: time.Now().UTC(), + } + futureItem := poamsvc.PoamItem{ + ID: uuid.New(), SspID: sspID, Title: "Future open", Description: "d", + Status: string(poamsvc.PoamItemStatusOpen), SourceType: string(poamsvc.PoamItemSourceTypeManual), + PlannedCompletionDate: &future, LastStatusChangeAt: time.Now().UTC(), + } + suite.Require().NoError(suite.DB.Create(&overdueItem).Error) + suite.Require().NoError(suite.DB.Create(&completedPast).Error) + suite.Require().NoError(suite.DB.Create(&futureItem).Error) + rec, req := suite.authedReq(http.MethodGet, "/api/poam-items?overdueOnly=true", nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var resp GenericDataListResponse[poamItemResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Len(suite.T(), resp.Data, 1) + assert.Equal(suite.T(), overdueItem.ID, resp.Data[0].ID) +} + +func (suite *PoamItemsApiIntegrationSuite) TestList_FilterByOwnerRef() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + ownerID := uuid.New() + otherOwnerID := uuid.New() + itemOwned := poamsvc.PoamItem{ + ID: uuid.New(), SspID: sspID, Title: "Owned", Description: "d", + Status: string(poamsvc.PoamItemStatusOpen), SourceType: string(poamsvc.PoamItemSourceTypeManual), + PrimaryOwnerUserID: &ownerID, LastStatusChangeAt: time.Now().UTC(), + } + itemOther := poamsvc.PoamItem{ + ID: uuid.New(), SspID: sspID, Title: "Other owner", Description: "d", + Status: string(poamsvc.PoamItemStatusOpen), SourceType: string(poamsvc.PoamItemSourceTypeManual), + PrimaryOwnerUserID: &otherOwnerID, LastStatusChangeAt: time.Now().UTC(), + } + suite.Require().NoError(suite.DB.Create(&itemOwned).Error) + suite.Require().NoError(suite.DB.Create(&itemOther).Error) + rec, req := suite.authedReq(http.MethodGet, fmt.Sprintf("/api/poam-items?ownerRef=%s", ownerID), nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var resp GenericDataListResponse[poamItemResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Len(suite.T(), resp.Data, 1) + assert.Equal(suite.T(), itemOwned.ID, resp.Data[0].ID) +} + +// --------------------------------------------------------------------------- +// GET /poam-items/:id +// --------------------------------------------------------------------------- + +func (suite *PoamItemsApiIntegrationSuite) TestGet_Exists() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Get test item", "open") + suite.seedMilestone(item.ID, "Milestone A", "planned", 0) + suite.seedMilestone(item.ID, "Milestone B", "planned", 1) + rec, req := suite.authedReq(http.MethodGet, fmt.Sprintf("/api/poam-items/%s", item.ID), nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var resp GenericDataResponse[poamItemResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(suite.T(), item.ID, resp.Data.ID) + assert.Len(suite.T(), resp.Data.Milestones, 2) + assert.Equal(suite.T(), "Milestone A", resp.Data.Milestones[0].Title) + assert.Equal(suite.T(), "Milestone B", resp.Data.Milestones[1].Title) +} + +func (suite *PoamItemsApiIntegrationSuite) TestGet_NotFound() { + suite.Require().NoError(suite.Migrator.Refresh()) + rec, req := suite.authedReq(http.MethodGet, fmt.Sprintf("/api/poam-items/%s", uuid.New()), nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusNotFound, rec.Code) +} + +func (suite *PoamItemsApiIntegrationSuite) TestGet_InvalidUUID() { + suite.Require().NoError(suite.Migrator.Refresh()) + rec, req := suite.authedReq(http.MethodGet, "/api/poam-items/not-a-uuid", nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusBadRequest, rec.Code) +} + +func (suite *PoamItemsApiIntegrationSuite) TestGet_IncludesAllLinkSets() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Link sets test", "open") + riskID := uuid.New() + evidenceID := uuid.New() + findingID := uuid.New() + catalogID := uuid.New() + suite.DB.Create(&poamsvc.PoamItemRiskLink{PoamItemID: item.ID, RiskID: riskID}) + suite.DB.Create(&poamsvc.PoamItemEvidenceLink{PoamItemID: item.ID, EvidenceID: evidenceID}) + suite.DB.Create(&poamsvc.PoamItemFindingLink{PoamItemID: item.ID, FindingID: findingID}) + suite.DB.Create(&poamsvc.PoamItemControlLink{PoamItemID: item.ID, CatalogID: catalogID, ControlID: "AC-2"}) + rec, req := suite.authedReq(http.MethodGet, fmt.Sprintf("/api/poam-items/%s", item.ID), nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var resp GenericDataResponse[poamItemResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Len(suite.T(), resp.Data.RiskLinks, 1) + assert.Len(suite.T(), resp.Data.EvidenceLinks, 1) + assert.Len(suite.T(), resp.Data.FindingLinks, 1) + assert.Len(suite.T(), resp.Data.ControlLinks, 1) +} + +// --------------------------------------------------------------------------- +// PUT /poam-items/:id +// --------------------------------------------------------------------------- + +func (suite *PoamItemsApiIntegrationSuite) TestUpdate_ScalarFields() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Original title", "open") + newTitle := "Updated title" + newDesc := "Updated description" + body := updatePoamItemRequest{Title: &newTitle, Description: &newDesc} + raw, _ := json.Marshal(body) + rec, req := suite.authedReq(http.MethodPut, fmt.Sprintf("/api/poam-items/%s", item.ID), raw) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var resp GenericDataResponse[poamItemResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(suite.T(), "Updated title", resp.Data.Title) + assert.Equal(suite.T(), "Updated description", resp.Data.Description) +} + +func (suite *PoamItemsApiIntegrationSuite) TestUpdate_StatusToCompleted_SetsCompletedAt() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Will complete", "open") + newStatus := "completed" + body := updatePoamItemRequest{Status: &newStatus} + raw, _ := json.Marshal(body) + rec, req := suite.authedReq(http.MethodPut, fmt.Sprintf("/api/poam-items/%s", item.ID), raw) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var updated poamsvc.PoamItem + suite.Require().NoError(suite.DB.First(&updated, "id = ?", item.ID).Error) + assert.Equal(suite.T(), string(poamsvc.PoamItemStatusCompleted), updated.Status) + assert.NotNil(suite.T(), updated.CompletedAt) +} + +func (suite *PoamItemsApiIntegrationSuite) TestUpdate_StatusChange_SetsLastStatusChangeAt() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Status change", "open") + originalChangeAt := item.LastStatusChangeAt + time.Sleep(10 * time.Millisecond) + newStatus := "in-progress" + body := updatePoamItemRequest{Status: &newStatus} + raw, _ := json.Marshal(body) + rec, req := suite.authedReq(http.MethodPut, fmt.Sprintf("/api/poam-items/%s", item.ID), raw) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var updated poamsvc.PoamItem + suite.Require().NoError(suite.DB.First(&updated, "id = ?", item.ID).Error) + assert.True(suite.T(), updated.LastStatusChangeAt.After(originalChangeAt)) +} + +func (suite *PoamItemsApiIntegrationSuite) TestUpdate_NotFound() { + suite.Require().NoError(suite.Migrator.Refresh()) + newTitle := "Ghost" + body := updatePoamItemRequest{Title: &newTitle} + raw, _ := json.Marshal(body) + rec, req := suite.authedReq(http.MethodPut, fmt.Sprintf("/api/poam-items/%s", uuid.New()), raw) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusNotFound, rec.Code) +} + +// --------------------------------------------------------------------------- +// DELETE /poam-items/:id +// --------------------------------------------------------------------------- + +func (suite *PoamItemsApiIntegrationSuite) TestDelete_CascadesAllLinks() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "To delete", "open") + suite.seedMilestone(item.ID, "MS1", "planned", 0) + riskID := uuid.New() + suite.DB.Create(&poamsvc.PoamItemRiskLink{PoamItemID: item.ID, RiskID: riskID}) + evidenceID := uuid.New() + suite.DB.Create(&poamsvc.PoamItemEvidenceLink{PoamItemID: item.ID, EvidenceID: evidenceID}) + rec, req := suite.authedReq(http.MethodDelete, fmt.Sprintf("/api/poam-items/%s", item.ID), nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusNoContent, rec.Code) + var count int64 + suite.DB.Model(&poamsvc.PoamItem{}).Where("id = ?", item.ID).Count(&count) + assert.Equal(suite.T(), int64(0), count) + suite.DB.Model(&poamsvc.PoamItemMilestone{}).Where("poam_item_id = ?", item.ID).Count(&count) + assert.Equal(suite.T(), int64(0), count) + suite.DB.Model(&poamsvc.PoamItemRiskLink{}).Where("poam_item_id = ?", item.ID).Count(&count) + assert.Equal(suite.T(), int64(0), count) + suite.DB.Model(&poamsvc.PoamItemEvidenceLink{}).Where("poam_item_id = ?", item.ID).Count(&count) + assert.Equal(suite.T(), int64(0), count) +} + +func (suite *PoamItemsApiIntegrationSuite) TestDelete_NotFound() { + suite.Require().NoError(suite.Migrator.Refresh()) + rec, req := suite.authedReq(http.MethodDelete, fmt.Sprintf("/api/poam-items/%s", uuid.New()), nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusNotFound, rec.Code) +} + +// --------------------------------------------------------------------------- +// GET /poam-items/:id/milestones +// --------------------------------------------------------------------------- + +func (suite *PoamItemsApiIntegrationSuite) TestListMilestones_OrderedByIndex() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "MS order test", "open") + suite.seedMilestone(item.ID, "Third", "planned", 2) + suite.seedMilestone(item.ID, "First", "planned", 0) + suite.seedMilestone(item.ID, "Second", "planned", 1) + rec, req := suite.authedReq(http.MethodGet, fmt.Sprintf("/api/poam-items/%s/milestones", item.ID), nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var resp GenericDataListResponse[milestoneResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Len(suite.T(), resp.Data, 3) + assert.Equal(suite.T(), "First", resp.Data[0].Title) + assert.Equal(suite.T(), "Second", resp.Data[1].Title) + assert.Equal(suite.T(), "Third", resp.Data[2].Title) +} + +func (suite *PoamItemsApiIntegrationSuite) TestListMilestones_ParentNotFound() { + suite.Require().NoError(suite.Migrator.Refresh()) + rec, req := suite.authedReq(http.MethodGet, fmt.Sprintf("/api/poam-items/%s/milestones", uuid.New()), nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusNotFound, rec.Code) +} + +// --------------------------------------------------------------------------- +// POST /poam-items/:id/milestones +// --------------------------------------------------------------------------- + +func (suite *PoamItemsApiIntegrationSuite) TestAddMilestone() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Add milestone test", "open") + due := time.Now().Add(7 * 24 * time.Hour).UTC().Truncate(time.Second) + body := createMilestoneRequest{ + Title: "Deploy to staging", + Description: "Deploy patched version to staging", + Status: "planned", + ScheduledCompletionDate: &due, + OrderIndex: intPtr(0), + } + raw, _ := json.Marshal(body) + rec, req := suite.authedReq(http.MethodPost, fmt.Sprintf("/api/poam-items/%s/milestones", item.ID), raw) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusCreated, rec.Code) + var resp GenericDataResponse[milestoneResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(suite.T(), "Deploy to staging", resp.Data.Title) + assert.Equal(suite.T(), "planned", resp.Data.Status) + assert.Equal(suite.T(), item.ID, resp.Data.PoamItemID) +} + +func (suite *PoamItemsApiIntegrationSuite) TestAddMilestone_ParentNotFound() { + suite.Require().NoError(suite.Migrator.Refresh()) + body := createMilestoneRequest{Title: "Ghost MS", Status: "planned"} + raw, _ := json.Marshal(body) + rec, req := suite.authedReq(http.MethodPost, fmt.Sprintf("/api/poam-items/%s/milestones", uuid.New()), raw) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusNotFound, rec.Code) +} + +// --------------------------------------------------------------------------- +// PUT /poam-items/:id/milestones/:milestoneId +// --------------------------------------------------------------------------- + +func (suite *PoamItemsApiIntegrationSuite) TestUpdateMilestone_MarkCompleted_SetsCompletionDate() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Milestone complete test", "open") + ms := suite.seedMilestone(item.ID, "Enable scanning", "planned", 0) + newStatus := "completed" + body := updateMilestoneRequest{Status: &newStatus} + raw, _ := json.Marshal(body) + rec, req := suite.authedReq( + http.MethodPut, + fmt.Sprintf("/api/poam-items/%s/milestones/%s", item.ID, ms.ID), + raw, + ) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var updated poamsvc.PoamItemMilestone + suite.Require().NoError(suite.DB.First(&updated, "id = ?", ms.ID).Error) + assert.Equal(suite.T(), string(poamsvc.MilestoneStatusCompleted), updated.Status) + assert.NotNil(suite.T(), updated.CompletionDate) +} + +func (suite *PoamItemsApiIntegrationSuite) TestUpdateMilestone_UpdateTitle() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "MS title update", "open") + ms := suite.seedMilestone(item.ID, "Old title", "planned", 0) + newTitle := "New title" + body := updateMilestoneRequest{Title: &newTitle} + raw, _ := json.Marshal(body) + rec, req := suite.authedReq( + http.MethodPut, + fmt.Sprintf("/api/poam-items/%s/milestones/%s", item.ID, ms.ID), + raw, + ) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var resp GenericDataResponse[milestoneResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(suite.T(), "New title", resp.Data.Title) +} + +func (suite *PoamItemsApiIntegrationSuite) TestUpdateMilestone_UpdateOrderIndex() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "MS order update", "open") + ms := suite.seedMilestone(item.ID, "Reorder me", "planned", 0) + newOrder := 5 + body := updateMilestoneRequest{OrderIndex: &newOrder} + raw, _ := json.Marshal(body) + rec, req := suite.authedReq( + http.MethodPut, + fmt.Sprintf("/api/poam-items/%s/milestones/%s", item.ID, ms.ID), + raw, + ) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var resp GenericDataResponse[milestoneResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(suite.T(), 5, resp.Data.OrderIndex) +} + +func (suite *PoamItemsApiIntegrationSuite) TestUpdateMilestone_NotFound() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Parent exists", "open") + newStatus := "completed" + body := updateMilestoneRequest{Status: &newStatus} + raw, _ := json.Marshal(body) + rec, req := suite.authedReq( + http.MethodPut, + fmt.Sprintf("/api/poam-items/%s/milestones/%s", item.ID, uuid.New()), + raw, + ) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusNotFound, rec.Code) +} + +// --------------------------------------------------------------------------- +// DELETE /poam-items/:id/milestones/:milestoneId +// --------------------------------------------------------------------------- + +func (suite *PoamItemsApiIntegrationSuite) TestDeleteMilestone() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Delete MS test", "open") + ms := suite.seedMilestone(item.ID, "To delete", "planned", 0) + rec, req := suite.authedReq( + http.MethodDelete, + fmt.Sprintf("/api/poam-items/%s/milestones/%s", item.ID, ms.ID), + nil, + ) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusNoContent, rec.Code) + var count int64 + suite.DB.Model(&poamsvc.PoamItemMilestone{}).Where("id = ?", ms.ID).Count(&count) + assert.Equal(suite.T(), int64(0), count) +} + +func (suite *PoamItemsApiIntegrationSuite) TestDeleteMilestone_NotFound() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Parent exists", "open") + rec, req := suite.authedReq( + http.MethodDelete, + fmt.Sprintf("/api/poam-items/%s/milestones/%s", item.ID, uuid.New()), + nil, + ) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusNotFound, rec.Code) +} + +// --------------------------------------------------------------------------- +// Link sub-resource endpoints — GET +// --------------------------------------------------------------------------- + +func (suite *PoamItemsApiIntegrationSuite) TestListRisks() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Risk list test", "open") + suite.DB.Create(&poamsvc.PoamItemRiskLink{PoamItemID: item.ID, RiskID: uuid.New()}) + suite.DB.Create(&poamsvc.PoamItemRiskLink{PoamItemID: item.ID, RiskID: uuid.New()}) + rec, req := suite.authedReq(http.MethodGet, fmt.Sprintf("/api/poam-items/%s/risks", item.ID), nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var resp GenericDataListResponse[poamsvc.PoamItemRiskLink] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Len(suite.T(), resp.Data, 2) +} + +func (suite *PoamItemsApiIntegrationSuite) TestListEvidence() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Evidence list test", "open") + suite.DB.Create(&poamsvc.PoamItemEvidenceLink{PoamItemID: item.ID, EvidenceID: uuid.New()}) + rec, req := suite.authedReq(http.MethodGet, fmt.Sprintf("/api/poam-items/%s/evidence", item.ID), nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var resp GenericDataListResponse[poamsvc.PoamItemEvidenceLink] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Len(suite.T(), resp.Data, 1) +} + +func (suite *PoamItemsApiIntegrationSuite) TestListControls() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Control list test", "open") + suite.DB.Create(&poamsvc.PoamItemControlLink{PoamItemID: item.ID, CatalogID: uuid.New(), ControlID: "SI-2"}) + rec, req := suite.authedReq(http.MethodGet, fmt.Sprintf("/api/poam-items/%s/controls", item.ID), nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var resp GenericDataListResponse[poamsvc.PoamItemControlLink] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Len(suite.T(), resp.Data, 1) + assert.Equal(suite.T(), "SI-2", resp.Data[0].ControlID) +} + +func (suite *PoamItemsApiIntegrationSuite) TestListFindings() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Finding list test", "open") + suite.DB.Create(&poamsvc.PoamItemFindingLink{PoamItemID: item.ID, FindingID: uuid.New()}) + rec, req := suite.authedReq(http.MethodGet, fmt.Sprintf("/api/poam-items/%s/findings", item.ID), nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusOK, rec.Code) + var resp GenericDataListResponse[poamsvc.PoamItemFindingLink] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Len(suite.T(), resp.Data, 1) +} + +func (suite *PoamItemsApiIntegrationSuite) TestListLinks_ParentNotFound() { + suite.Require().NoError(suite.Migrator.Refresh()) + ghostID := uuid.New() + for _, path := range []string{"risks", "evidence", "controls", "findings"} { + rec, req := suite.authedReq(http.MethodGet, fmt.Sprintf("/api/poam-items/%s/%s", ghostID, path), nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusNotFound, rec.Code, "expected 404 for /poam-items/:id/%s with unknown parent", path) + } +} + +// --------------------------------------------------------------------------- +// Link sub-resource endpoints — POST / DELETE +// --------------------------------------------------------------------------- + +func (suite *PoamItemsApiIntegrationSuite) TestAddRiskLink() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Add risk link test", "open") + riskID := uuid.New() + body := addLinkRequest{ID: riskID.String()} + raw, _ := json.Marshal(body) + rec, req := suite.authedReq(http.MethodPost, fmt.Sprintf("/api/poam-items/%s/risks", item.ID), raw) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusCreated, rec.Code) + var count int64 + suite.DB.Model(&poamsvc.PoamItemRiskLink{}).Where("poam_item_id = ? AND risk_id = ?", item.ID, riskID).Count(&count) + assert.Equal(suite.T(), int64(1), count) +} + +func (suite *PoamItemsApiIntegrationSuite) TestDeleteRiskLink() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Delete risk link test", "open") + riskID := uuid.New() + suite.Require().NoError(suite.DB.Create(&poamsvc.PoamItemRiskLink{PoamItemID: item.ID, RiskID: riskID}).Error) + rec, req := suite.authedReq(http.MethodDelete, fmt.Sprintf("/api/poam-items/%s/risks/%s", item.ID, riskID), nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusNoContent, rec.Code) + var count int64 + suite.DB.Model(&poamsvc.PoamItemRiskLink{}).Where("poam_item_id = ? AND risk_id = ?", item.ID, riskID).Count(&count) + assert.Equal(suite.T(), int64(0), count) +} + +func (suite *PoamItemsApiIntegrationSuite) TestAddEvidenceLink() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Add evidence link test", "open") + evidenceID := uuid.New() + body := addLinkRequest{ID: evidenceID.String()} + raw, _ := json.Marshal(body) + rec, req := suite.authedReq(http.MethodPost, fmt.Sprintf("/api/poam-items/%s/evidence", item.ID), raw) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusCreated, rec.Code) + var count int64 + suite.DB.Model(&poamsvc.PoamItemEvidenceLink{}).Where("poam_item_id = ? AND evidence_id = ?", item.ID, evidenceID).Count(&count) + assert.Equal(suite.T(), int64(1), count) +} + +func (suite *PoamItemsApiIntegrationSuite) TestDeleteEvidenceLink() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Delete evidence link test", "open") + evidenceID := uuid.New() + suite.Require().NoError(suite.DB.Create(&poamsvc.PoamItemEvidenceLink{PoamItemID: item.ID, EvidenceID: evidenceID}).Error) + rec, req := suite.authedReq(http.MethodDelete, fmt.Sprintf("/api/poam-items/%s/evidence/%s", item.ID, evidenceID), nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusNoContent, rec.Code) + var count int64 + suite.DB.Model(&poamsvc.PoamItemEvidenceLink{}).Where("poam_item_id = ? AND evidence_id = ?", item.ID, evidenceID).Count(&count) + assert.Equal(suite.T(), int64(0), count) +} + +func (suite *PoamItemsApiIntegrationSuite) TestAddFindingLink() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Add finding link test", "open") + findingID := uuid.New() + body := addLinkRequest{ID: findingID.String()} + raw, _ := json.Marshal(body) + rec, req := suite.authedReq(http.MethodPost, fmt.Sprintf("/api/poam-items/%s/findings", item.ID), raw) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusCreated, rec.Code) + var count int64 + suite.DB.Model(&poamsvc.PoamItemFindingLink{}).Where("poam_item_id = ? AND finding_id = ?", item.ID, findingID).Count(&count) + assert.Equal(suite.T(), int64(1), count) +} + +func (suite *PoamItemsApiIntegrationSuite) TestDeleteFindingLink() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Delete finding link test", "open") + findingID := uuid.New() + suite.Require().NoError(suite.DB.Create(&poamsvc.PoamItemFindingLink{PoamItemID: item.ID, FindingID: findingID}).Error) + rec, req := suite.authedReq(http.MethodDelete, fmt.Sprintf("/api/poam-items/%s/findings/%s", item.ID, findingID), nil) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusNoContent, rec.Code) + var count int64 + suite.DB.Model(&poamsvc.PoamItemFindingLink{}).Where("poam_item_id = ? AND finding_id = ?", item.ID, findingID).Count(&count) + assert.Equal(suite.T(), int64(0), count) +} + +func (suite *PoamItemsApiIntegrationSuite) TestAddControlLink() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Add control link test", "open") + catalogID := uuid.New() + body := poamControlRefRequest{CatalogID: catalogID.String(), ControlID: "AC-3"} + raw, _ := json.Marshal(body) + rec, req := suite.authedReq(http.MethodPost, fmt.Sprintf("/api/poam-items/%s/controls", item.ID), raw) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusCreated, rec.Code) + var count int64 + suite.DB.Model(&poamsvc.PoamItemControlLink{}).Where("poam_item_id = ? AND control_id = ?", item.ID, "AC-3").Count(&count) + assert.Equal(suite.T(), int64(1), count) +} + +func (suite *PoamItemsApiIntegrationSuite) TestDeleteControlLink() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + item := suite.seedItem(sspID, "Delete control link test", "open") + catalogID := uuid.New() + suite.Require().NoError(suite.DB.Create(&poamsvc.PoamItemControlLink{PoamItemID: item.ID, CatalogID: catalogID, ControlID: "AC-4"}).Error) + rec, req := suite.authedReq( + http.MethodDelete, + fmt.Sprintf("/api/poam-items/%s/controls/%s/AC-4", item.ID, catalogID), + nil, + ) + suite.newServer().E().ServeHTTP(rec, req) + assert.Equal(suite.T(), http.StatusNoContent, rec.Code) + var count int64 + suite.DB.Model(&poamsvc.PoamItemControlLink{}).Where("poam_item_id = ? AND catalog_id = ? AND control_id = ?", item.ID, catalogID, "AC-4").Count(&count) + assert.Equal(suite.T(), int64(0), count) +} + +// --------------------------------------------------------------------------- +// Uniqueness constraint — duplicate risk link +// --------------------------------------------------------------------------- + +// TestCreate_DuplicateRiskLink_IsIdempotent verifies that POSTing the same risk +// link twice returns HTTP 201 both times (ON CONFLICT DO NOTHING — same pattern +// as the Risk service). The unique constraint still exists in the DB; the +// service simply re-fetches and returns the existing record on conflict. +func (suite *PoamItemsApiIntegrationSuite) TestCreate_DuplicateRiskLink_IsIdempotent() { + suite.Require().NoError(suite.Migrator.Refresh()) + sspID := uuid.New() + riskID := uuid.New() + item := suite.seedItem(sspID, "Dup risk test", "open") + + body := fmt.Sprintf(`{"id":"%s"}`, riskID) + + // First POST — creates the link. + rec1, req1 := suite.authedReq(http.MethodPost, + fmt.Sprintf("/api/poam-items/%s/risks", item.ID), []byte(body)) + suite.newServer().E().ServeHTTP(rec1, req1) + assert.Equal(suite.T(), http.StatusCreated, rec1.Code, "first POST should return 201") + + // Second POST — idempotent, should also return 201. + rec2, req2 := suite.authedReq(http.MethodPost, + fmt.Sprintf("/api/poam-items/%s/risks", item.ID), []byte(body)) + suite.newServer().E().ServeHTTP(rec2, req2) + assert.Equal(suite.T(), http.StatusCreated, rec2.Code, "duplicate POST should be idempotent (201)") + + // Verify only one link exists in the DB. + var count int64 + suite.DB.Model(&poamsvc.PoamItemRiskLink{}).Where("poam_item_id = ? AND risk_id = ?", item.ID, riskID).Count(&count) + assert.Equal(suite.T(), int64(1), count, "only one risk link should exist") +} + diff --git a/internal/service/migrator.go b/internal/service/migrator.go index 02039f90..54776ca5 100644 --- a/internal/service/migrator.go +++ b/internal/service/migrator.go @@ -2,6 +2,7 @@ package service import ( "github.com/compliance-framework/api/internal/service/relational" + poamrel "github.com/compliance-framework/api/internal/service/relational/poam" riskrel "github.com/compliance-framework/api/internal/service/relational/risks" templaterel "github.com/compliance-framework/api/internal/service/relational/templates" "github.com/compliance-framework/api/internal/service/relational/workflows" @@ -135,6 +136,12 @@ func MigrateUp(db *gorm.DB) error { // Compliance-Framework - not related to OSCAL &relational.SSOUserLink{}, + &poamrel.PoamItem{}, + &poamrel.PoamItemMilestone{}, + &poamrel.PoamItemRiskLink{}, + &poamrel.PoamItemEvidenceLink{}, + &poamrel.PoamItemControlLink{}, + &poamrel.PoamItemFindingLink{}, &relational.User{}, &Heartbeat{}, &relational.Evidence{}, @@ -342,6 +349,13 @@ func MigrateDown(db *gorm.DB) error { "poam_findings", "poam_risks", + &poamrel.PoamItemFindingLink{}, + &poamrel.PoamItemControlLink{}, + &poamrel.PoamItemEvidenceLink{}, + &poamrel.PoamItemRiskLink{}, + &poamrel.PoamItemMilestone{}, + &poamrel.PoamItem{}, + &relational.User{}, &Heartbeat{}, diff --git a/internal/service/relational/poam/models.go b/internal/service/relational/poam/models.go new file mode 100644 index 00000000..61ce18b5 --- /dev/null +++ b/internal/service/relational/poam/models.go @@ -0,0 +1,208 @@ +package poam + +import ( + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// PoamItemStatus represents the lifecycle state of a POAM item. +type PoamItemStatus string + +const ( + PoamItemStatusOpen PoamItemStatus = "open" + PoamItemStatusInProgress PoamItemStatus = "in-progress" + PoamItemStatusCompleted PoamItemStatus = "completed" + PoamItemStatusOverdue PoamItemStatus = "overdue" +) + +// IsValid reports whether the status value is one of the defined constants. +func (s PoamItemStatus) IsValid() bool { + switch s { + case PoamItemStatusOpen, PoamItemStatusInProgress, PoamItemStatusCompleted, PoamItemStatusOverdue: + return true + } + return false +} + +// PoamItemSourceType describes how a POAM item was created. +type PoamItemSourceType string + +const ( + PoamItemSourceTypeManual PoamItemSourceType = "manual" + PoamItemSourceTypeRiskPromotion PoamItemSourceType = "risk-promotion" + PoamItemSourceTypeImport PoamItemSourceType = "import" +) + +// IsValid reports whether the source type value is one of the defined constants. +func (s PoamItemSourceType) IsValid() bool { + switch s { + case PoamItemSourceTypeManual, PoamItemSourceTypeRiskPromotion, PoamItemSourceTypeImport: + return true + } + return false +} + +// MilestoneStatus represents the lifecycle state of a POAM milestone. +type MilestoneStatus string + +const ( + MilestoneStatusPlanned MilestoneStatus = "planned" + MilestoneStatusCompleted MilestoneStatus = "completed" +) + +// IsValid reports whether the milestone status is one of the defined constants. +func (s MilestoneStatus) IsValid() bool { + switch s { + case MilestoneStatusPlanned, MilestoneStatusCompleted: + return true + } + return false +} + +// PoamItem is the primary GORM model for a POAM item. +// Field names follow the Confluence design doc (v15). +type PoamItem struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` + SspID uuid.UUID `gorm:"type:uuid;not null;index" json:"sspId"` + Title string `gorm:"not null" json:"title"` + Description string ` json:"description"` + Status string `gorm:"type:text;not null" json:"status"` + SourceType string `gorm:"type:text;not null" json:"sourceType"` + PrimaryOwnerUserID *uuid.UUID `gorm:"type:uuid" json:"primaryOwnerUserId,omitempty"` + PlannedCompletionDate *time.Time ` json:"plannedCompletionDate,omitempty"` + CompletedAt *time.Time ` json:"completedAt,omitempty"` + CreatedFromRiskID *uuid.UUID `gorm:"type:uuid" json:"createdFromRiskId,omitempty"` + AcceptanceRationale *string ` json:"acceptanceRationale,omitempty"` + LastStatusChangeAt time.Time `gorm:"not null" json:"lastStatusChangeAt"` + CreatedAt time.Time ` json:"createdAt"` + UpdatedAt time.Time ` json:"updatedAt"` + + // Associations — loaded on demand via Preload. + Milestones []PoamItemMilestone `gorm:"foreignKey:PoamItemID;constraint:OnDelete:CASCADE" json:"milestones,omitempty"` + RiskLinks []PoamItemRiskLink `gorm:"foreignKey:PoamItemID;constraint:OnDelete:CASCADE" json:"riskLinks,omitempty"` + EvidenceLinks []PoamItemEvidenceLink `gorm:"foreignKey:PoamItemID;constraint:OnDelete:CASCADE" json:"evidenceLinks,omitempty"` + ControlLinks []PoamItemControlLink `gorm:"foreignKey:PoamItemID;constraint:OnDelete:CASCADE" json:"controlLinks,omitempty"` + FindingLinks []PoamItemFindingLink `gorm:"foreignKey:PoamItemID;constraint:OnDelete:CASCADE" json:"findingLinks,omitempty"` +} + +// TableName returns the physical table name. +func (PoamItem) TableName() string { return "ccf_poam_items" } + +// BeforeCreate auto-assigns a UUID and validates enum fields. +func (p *PoamItem) BeforeCreate(_ *gorm.DB) error { + if p.ID == uuid.Nil { + p.ID = uuid.New() + } + if p.Status == "" { + p.Status = string(PoamItemStatusOpen) + } + if p.SourceType == "" { + p.SourceType = string(PoamItemSourceTypeManual) + } + if !PoamItemStatus(p.Status).IsValid() { + return fmt.Errorf("invalid poam item status: %s", p.Status) + } + if !PoamItemSourceType(p.SourceType).IsValid() { + return fmt.Errorf("invalid poam item source type: %s", p.SourceType) + } + if p.LastStatusChangeAt.IsZero() { + p.LastStatusChangeAt = time.Now().UTC() + } + return nil +} + +// PoamItemMilestone is a strong-typed milestone entry for a PoamItem. +// Field names follow the Confluence design doc (v15). +type PoamItemMilestone struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` + PoamItemID uuid.UUID `gorm:"type:uuid;index;not null" json:"poamItemId"` + Title string `gorm:"not null" json:"title"` + Description string ` json:"description"` + Status string `gorm:"type:text;not null" json:"status"` + ScheduledCompletionDate *time.Time ` json:"scheduledCompletionDate,omitempty"` + CompletionDate *time.Time ` json:"completionDate,omitempty"` + OrderIndex int `gorm:"not null;default:0" json:"orderIndex"` + CreatedAt time.Time ` json:"createdAt"` + UpdatedAt time.Time ` json:"updatedAt"` +} + +// TableName returns the physical table name. +func (PoamItemMilestone) TableName() string { return "ccf_poam_item_milestones" } + +// BeforeCreate auto-assigns a UUID and validates enum fields. +func (m *PoamItemMilestone) BeforeCreate(_ *gorm.DB) error { + if m.ID == uuid.Nil { + m.ID = uuid.New() + } + if m.Status == "" { + m.Status = string(MilestoneStatusPlanned) + } + if !MilestoneStatus(m.Status).IsValid() { + return fmt.Errorf("invalid milestone status: %s", m.Status) + } + return nil +} + +// PoamItemRiskLink is the join table linking PoamItems to Risks. +// Uses a composite primary key and OnDelete:CASCADE to match the Risk service +// link table pattern (e.g., risk_evidence_links). +// +// Note: only the PoamItem side carries a DB-level FK constraint. The RiskID +// column intentionally has no FK back to the risks table because Risks live in +// a separate bounded context. Referential integrity on the Risk side is +// enforced at the application layer (EnsureExists checks before link creation). +type PoamItemRiskLink struct { + PoamItemID uuid.UUID `gorm:"type:uuid;primaryKey" json:"poamItemId"` + RiskID uuid.UUID `gorm:"type:uuid;primaryKey;index" json:"riskId"` + CreatedAt time.Time ` json:"createdAt"` + PoamItem *PoamItem `json:"-" gorm:"foreignKey:PoamItemID;references:ID;constraint:OnDelete:CASCADE"` +} + +// TableName returns the physical table name. +func (PoamItemRiskLink) TableName() string { return "ccf_poam_item_risk_links" } + +// PoamItemEvidenceLink is the join table linking PoamItems to Evidence records. +// EvidenceID has no DB-level FK (same cross-context reasoning as PoamItemRiskLink). +type PoamItemEvidenceLink struct { + PoamItemID uuid.UUID `gorm:"type:uuid;primaryKey" json:"poamItemId"` + EvidenceID uuid.UUID `gorm:"type:uuid;primaryKey;index" json:"evidenceId"` + CreatedAt time.Time ` json:"createdAt"` + PoamItem *PoamItem `json:"-" gorm:"foreignKey:PoamItemID;references:ID;constraint:OnDelete:CASCADE"` +} + +// TableName returns the physical table name. +func (PoamItemEvidenceLink) TableName() string { return "ccf_poam_item_evidence_links" } + +// PoamItemControlLink is the join table linking PoamItems to Controls. +// CatalogID/ControlID have no DB-level FK (same cross-context reasoning as PoamItemRiskLink). +type PoamItemControlLink struct { + PoamItemID uuid.UUID `gorm:"type:uuid;primaryKey" json:"poamItemId"` + CatalogID uuid.UUID `gorm:"type:uuid;primaryKey;index" json:"catalogId"` + ControlID string `gorm:"type:text;not null;primaryKey" json:"controlId"` + CreatedAt time.Time ` json:"createdAt"` + PoamItem *PoamItem `json:"-" gorm:"foreignKey:PoamItemID;references:ID;constraint:OnDelete:CASCADE"` +} + +// TableName returns the physical table name. +func (PoamItemControlLink) TableName() string { return "ccf_poam_item_control_links" } + +// PoamItemFindingLink is the join table linking PoamItems to Findings. +// FindingID has no DB-level FK (same cross-context reasoning as PoamItemRiskLink). +type PoamItemFindingLink struct { + PoamItemID uuid.UUID `gorm:"type:uuid;primaryKey" json:"poamItemId"` + FindingID uuid.UUID `gorm:"type:uuid;primaryKey;index" json:"findingId"` + CreatedAt time.Time ` json:"createdAt"` + PoamItem *PoamItem `json:"-" gorm:"foreignKey:PoamItemID;references:ID;constraint:OnDelete:CASCADE"` +} + +// TableName returns the physical table name. +func (PoamItemFindingLink) TableName() string { return "ccf_poam_item_finding_links" } + +// ControlRef is a typed reference to a control within a catalog. +type ControlRef struct { + CatalogID uuid.UUID `json:"catalogId"` + ControlID string `json:"controlId"` +} diff --git a/internal/service/relational/poam/queries.go b/internal/service/relational/poam/queries.go new file mode 100644 index 00000000..cd9b3a74 --- /dev/null +++ b/internal/service/relational/poam/queries.go @@ -0,0 +1,56 @@ +package poam + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// ListFilters holds all supported filter parameters for listing POAM items. +type ListFilters struct { + Status string + SspID *uuid.UUID + RiskID *uuid.UUID + DeadlineBefore *time.Time + OverdueOnly bool + OwnerRef *uuid.UUID +} + +// ApplyFilters applies all non-nil filters to the given GORM query and returns it. +func ApplyFilters(query *gorm.DB, filters ListFilters) *gorm.DB { + q := query.Model(&PoamItem{}) + + if filters.Status != "" { + q = q.Where("ccf_poam_items.status = ?", filters.Status) + } + if filters.SspID != nil { + q = q.Where("ccf_poam_items.ssp_id = ?", *filters.SspID) + } + if filters.OwnerRef != nil { + q = q.Where("ccf_poam_items.primary_owner_user_id = ?", *filters.OwnerRef) + } + if filters.DeadlineBefore != nil { + q = q.Where( + "ccf_poam_items.planned_completion_date IS NOT NULL AND ccf_poam_items.planned_completion_date < ?", + *filters.DeadlineBefore, + ) + } + if filters.OverdueOnly { + now := time.Now().UTC() + q = q.Where( + // Include 'overdue' in the filter so that items already persisted with + // that status (a valid PoamItemStatus) are not silently excluded. + "ccf_poam_items.status IN ('open','in-progress','overdue') AND ccf_poam_items.planned_completion_date IS NOT NULL AND ccf_poam_items.planned_completion_date < ?", + now, + ) + } + if filters.RiskID != nil { + q = q.Joins( + "JOIN ccf_poam_item_risk_links rl ON rl.poam_item_id = ccf_poam_items.id AND rl.risk_id = ?", + *filters.RiskID, + ) + } + + return q +} diff --git a/internal/service/relational/poam/service.go b/internal/service/relational/poam/service.go new file mode 100644 index 00000000..ea39f155 --- /dev/null +++ b/internal/service/relational/poam/service.go @@ -0,0 +1,694 @@ +package poam + +import ( + "fmt" + "time" + + "github.com/compliance-framework/api/internal/service/relational" + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// PoamService encapsulates all database operations for POAM items and their +// sub-resources. Handlers must not import gorm directly; all persistence is +// delegated here. +type PoamService struct { + db *gorm.DB +} + +// NewPoamService constructs a PoamService backed by the given *gorm.DB. +func NewPoamService(db *gorm.DB) *PoamService { + return &PoamService{db: db} +} + +// --------------------------------------------------------------------------- +// Param types +// --------------------------------------------------------------------------- + +// CreatePoamItemParams carries all data required to create a POAM item and its +// initial milestones and link records in a single transaction. +type CreatePoamItemParams struct { + SspID uuid.UUID + Title string + Description string + Status string + SourceType string + PrimaryOwnerUserID *uuid.UUID + PlannedCompletionDate *time.Time + CreatedFromRiskID *uuid.UUID + AcceptanceRationale *string + RiskIDs []uuid.UUID + EvidenceIDs []uuid.UUID + ControlRefs []ControlRef + FindingIDs []uuid.UUID + Milestones []CreateMilestoneParams +} + +// UpdatePoamItemParams carries the fields that may be patched on an existing +// POAM item. Only non-nil pointer fields are applied. Link slices use explicit +// add/remove semantics so callers can manage associations in one call. +type UpdatePoamItemParams struct { + Title *string + Description *string + Status *string + PrimaryOwnerUserID *uuid.UUID + PlannedCompletionDate *time.Time + AcceptanceRationale *string + // Link management — applied inside the same transaction as the scalar update. + AddRiskIDs []uuid.UUID + RemoveRiskIDs []uuid.UUID + AddEvidenceIDs []uuid.UUID + RemoveEvidenceIDs []uuid.UUID + AddControlRefs []ControlRef + RemoveControlRefs []ControlRef + AddFindingIDs []uuid.UUID + RemoveFindingIDs []uuid.UUID +} + +// CreateMilestoneParams carries all data required to create a single milestone. +type CreateMilestoneParams struct { + Title string + Description string + Status string + ScheduledCompletionDate *time.Time + OrderIndex int +} + +// UpdateMilestoneParams carries the fields that may be patched on an existing +// milestone. Only non-nil pointer fields are applied. +type UpdateMilestoneParams struct { + Title *string + Description *string + Status *string + ScheduledCompletionDate *time.Time + OrderIndex *int +} + +// --------------------------------------------------------------------------- +// POAM item CRUD +// --------------------------------------------------------------------------- + +// List returns all POAM items matching the given filters. +func (s *PoamService) List(filters ListFilters) ([]PoamItem, error) { + var items []PoamItem + q := ApplyFilters(s.db, filters) + if err := q.Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +// Create inserts a new POAM item together with its initial milestones and all +// link records inside a single database transaction. +func (s *PoamService) Create(params CreatePoamItemParams) (*PoamItem, error) { + item := PoamItem{ + SspID: params.SspID, + Title: params.Title, + Description: params.Description, + Status: params.Status, + SourceType: params.SourceType, + PrimaryOwnerUserID: params.PrimaryOwnerUserID, + PlannedCompletionDate: params.PlannedCompletionDate, + CreatedFromRiskID: params.CreatedFromRiskID, + AcceptanceRationale: params.AcceptanceRationale, + } + + tx, err := beginTx(s.db) + if err != nil { + return nil, err + } + defer rollbackTxOnPanic(tx) + + if err := tx.Create(&item).Error; err != nil { + tx.Rollback() + return nil, err + } + + for i, mp := range params.Milestones { + orderIdx := mp.OrderIndex + if orderIdx == 0 { + orderIdx = i + } + ms := PoamItemMilestone{ + PoamItemID: item.ID, + Title: mp.Title, + Description: mp.Description, + Status: mp.Status, + ScheduledCompletionDate: mp.ScheduledCompletionDate, + OrderIndex: orderIdx, + } + if err := tx.Create(&ms).Error; err != nil { + tx.Rollback() + return nil, err + } + } + + for _, riskID := range params.RiskIDs { + link := PoamItemRiskLink{PoamItemID: item.ID, RiskID: riskID} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&link).Error; err != nil { + tx.Rollback() + return nil, err + } + } + + for _, evidenceID := range params.EvidenceIDs { + link := PoamItemEvidenceLink{PoamItemID: item.ID, EvidenceID: evidenceID} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&link).Error; err != nil { + tx.Rollback() + return nil, err + } + } + + for _, cr := range params.ControlRefs { + link := PoamItemControlLink{PoamItemID: item.ID, CatalogID: cr.CatalogID, ControlID: cr.ControlID} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&link).Error; err != nil { + tx.Rollback() + return nil, err + } + } + + for _, findingID := range params.FindingIDs { + link := PoamItemFindingLink{PoamItemID: item.ID, FindingID: findingID} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&link).Error; err != nil { + tx.Rollback() + return nil, err + } + } + + if err := tx.Commit().Error; err != nil { + return nil, err + } + + return s.GetByID(item.ID) +} + +// GetByID fetches a single POAM item by its UUID, preloading milestones ordered +// by order_index ascending. +func (s *PoamService) GetByID(id uuid.UUID) (*PoamItem, error) { + var item PoamItem + err := s.db. + Preload("Milestones", func(db *gorm.DB) *gorm.DB { + return db.Order("order_index ASC") + }). + Preload("RiskLinks"). + Preload("EvidenceLinks"). + Preload("ControlLinks"). + Preload("FindingLinks"). + First(&item, "id = ?", id).Error + if err != nil { + return nil, err + } + return &item, nil +} + +// Update applies non-nil scalar fields from params to the POAM item identified +// by id, and processes any link add/remove operations — all inside a single +// transaction. This follows the Risk service pattern: fetch the current record, +// mutate the struct, then call tx.Save() rather than using a raw map. +// +// last_status_change_at is stamped only when the status actually changes. +// completed_at is set automatically when status transitions to "completed" and +// cleared if status moves away from "completed". It is not settable via params. +func (s *PoamService) Update(id uuid.UUID, params UpdatePoamItemParams) (*PoamItem, error) { + item, err := s.GetByID(id) + if err != nil { + return nil, err + } + + // Detect status change before mutating. + statusChanged := params.Status != nil && *params.Status != item.Status + + if params.Title != nil { + item.Title = *params.Title + } + if params.Description != nil { + item.Description = *params.Description + } + if params.Status != nil { + if !PoamItemStatus(*params.Status).IsValid() { + return nil, fmt.Errorf("invalid status: %s", *params.Status) + } + item.Status = *params.Status + if statusChanged { + item.LastStatusChangeAt = time.Now().UTC() + if *params.Status == string(PoamItemStatusCompleted) { + now := time.Now().UTC() + item.CompletedAt = &now + } else { + // Clear completed_at if moving away from completed. + item.CompletedAt = nil + } + } + } + if params.PrimaryOwnerUserID != nil { + item.PrimaryOwnerUserID = params.PrimaryOwnerUserID + } + if params.PlannedCompletionDate != nil { + item.PlannedCompletionDate = params.PlannedCompletionDate + } + if params.AcceptanceRationale != nil { + item.AcceptanceRationale = params.AcceptanceRationale + } + + hasLinkChanges := len(params.AddRiskIDs) > 0 || len(params.RemoveRiskIDs) > 0 || + len(params.AddEvidenceIDs) > 0 || len(params.RemoveEvidenceIDs) > 0 || + len(params.AddControlRefs) > 0 || len(params.RemoveControlRefs) > 0 || + len(params.AddFindingIDs) > 0 || len(params.RemoveFindingIDs) > 0 + + if !hasLinkChanges { + // Scalar-only update — use a transaction for the Save. + tx, err := beginTx(s.db) + if err != nil { + return nil, err + } + defer rollbackTxOnPanic(tx) + if err := tx.Save(item).Error; err != nil { + tx.Rollback() + return nil, err + } + if err := tx.Commit().Error; err != nil { + return nil, err + } + return s.GetByID(id) + } + + // Combined scalar + link update in a single transaction. + tx, err := beginTx(s.db) + if err != nil { + return nil, err + } + defer rollbackTxOnPanic(tx) + + if err := tx.Save(item).Error; err != nil { + tx.Rollback() + return nil, err + } + + // Risk links. + for _, riskID := range params.AddRiskIDs { + link := PoamItemRiskLink{PoamItemID: id, RiskID: riskID} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&link).Error; err != nil { + tx.Rollback() + return nil, err + } + } + for _, riskID := range params.RemoveRiskIDs { + if err := tx.Where("poam_item_id = ? AND risk_id = ?", id, riskID).Delete(&PoamItemRiskLink{}).Error; err != nil { + tx.Rollback() + return nil, err + } + } + + // Evidence links. + for _, evidenceID := range params.AddEvidenceIDs { + link := PoamItemEvidenceLink{PoamItemID: id, EvidenceID: evidenceID} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&link).Error; err != nil { + tx.Rollback() + return nil, err + } + } + for _, evidenceID := range params.RemoveEvidenceIDs { + if err := tx.Where("poam_item_id = ? AND evidence_id = ?", id, evidenceID).Delete(&PoamItemEvidenceLink{}).Error; err != nil { + tx.Rollback() + return nil, err + } + } + + // Control links. + for _, cr := range params.AddControlRefs { + link := PoamItemControlLink{PoamItemID: id, CatalogID: cr.CatalogID, ControlID: cr.ControlID} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&link).Error; err != nil { + tx.Rollback() + return nil, err + } + } + for _, cr := range params.RemoveControlRefs { + if err := tx.Where("poam_item_id = ? AND catalog_id = ? AND control_id = ?", id, cr.CatalogID, cr.ControlID).Delete(&PoamItemControlLink{}).Error; err != nil { + tx.Rollback() + return nil, err + } + } + + // Finding links. + for _, findingID := range params.AddFindingIDs { + link := PoamItemFindingLink{PoamItemID: id, FindingID: findingID} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&link).Error; err != nil { + tx.Rollback() + return nil, err + } + } + for _, findingID := range params.RemoveFindingIDs { + if err := tx.Where("poam_item_id = ? AND finding_id = ?", id, findingID).Delete(&PoamItemFindingLink{}).Error; err != nil { + tx.Rollback() + return nil, err + } + } + + if err := tx.Commit().Error; err != nil { + return nil, err + } + + return s.GetByID(id) +} + +// Delete removes a POAM item and all its dependent records (milestones, all +// four link tables) inside a single transaction. +func (s *PoamService) Delete(id uuid.UUID) error { + tx, err := beginTx(s.db) + if err != nil { + return err + } + defer rollbackTxOnPanic(tx) + + if err := tx.Where("poam_item_id = ?", id).Delete(&PoamItemRiskLink{}).Error; err != nil { + tx.Rollback() + return err + } + if err := tx.Where("poam_item_id = ?", id).Delete(&PoamItemEvidenceLink{}).Error; err != nil { + tx.Rollback() + return err + } + if err := tx.Where("poam_item_id = ?", id).Delete(&PoamItemControlLink{}).Error; err != nil { + tx.Rollback() + return err + } + if err := tx.Where("poam_item_id = ?", id).Delete(&PoamItemFindingLink{}).Error; err != nil { + tx.Rollback() + return err + } + if err := tx.Where("poam_item_id = ?", id).Delete(&PoamItemMilestone{}).Error; err != nil { + tx.Rollback() + return err + } + + result := tx.Delete(&PoamItem{}, "id = ?", id) + if result.Error != nil { + tx.Rollback() + return result.Error + } + if result.RowsAffected == 0 { + tx.Rollback() + return gorm.ErrRecordNotFound + } + + return tx.Commit().Error +} + +// EnsureExists returns nil if a POAM item with the given id exists, or +// gorm.ErrRecordNotFound if it does not. +func (s *PoamService) EnsureExists(id uuid.UUID) error { + var item PoamItem + return s.db.Select("id").First(&item, "id = ?", id).Error +} + +// EnsureSSPExists returns nil if an SSP with the given id exists, or +// gorm.ErrRecordNotFound if it does not. +func (s *PoamService) EnsureSSPExists(id uuid.UUID) error { + var ssp relational.SystemSecurityPlan + return s.db.Select("id").First(&ssp, "id = ?", id).Error +} + +// --------------------------------------------------------------------------- +// Milestone operations +// --------------------------------------------------------------------------- + +// ListMilestones returns all milestones for the given POAM item, ordered by +// order_index ascending. +func (s *PoamService) ListMilestones(poamItemID uuid.UUID) ([]PoamItemMilestone, error) { + var milestones []PoamItemMilestone + if err := s.db. + Where("poam_item_id = ?", poamItemID). + Order("order_index ASC"). + Find(&milestones).Error; err != nil { + return nil, err + } + return milestones, nil +} + +// AddMilestone inserts a new milestone for the given POAM item. +func (s *PoamService) AddMilestone(poamItemID uuid.UUID, params CreateMilestoneParams) (*PoamItemMilestone, error) { + m := PoamItemMilestone{ + PoamItemID: poamItemID, + Title: params.Title, + Description: params.Description, + Status: params.Status, + ScheduledCompletionDate: params.ScheduledCompletionDate, + OrderIndex: params.OrderIndex, + } + if err := s.db.Create(&m).Error; err != nil { + return nil, err + } + return &m, nil +} + +// UpdateMilestone applies non-nil fields from params to the milestone identified +// by (poamItemID, milestoneID). When status transitions to "completed", +// completion_date is set automatically. Returns gorm.ErrRecordNotFound when the +// milestone does not belong to the given POAM item. +func (s *PoamService) UpdateMilestone(poamItemID, milestoneID uuid.UUID, params UpdateMilestoneParams) (*PoamItemMilestone, error) { + m, err := s.getMilestoneByID(poamItemID, milestoneID) + if err != nil { + return nil, err + } + + statusChanged := params.Status != nil && *params.Status != m.Status + + if params.Title != nil { + m.Title = *params.Title + } + if params.Description != nil { + m.Description = *params.Description + } + if params.Status != nil { + if !MilestoneStatus(*params.Status).IsValid() { + return nil, fmt.Errorf("invalid milestone status: %s", *params.Status) + } + m.Status = *params.Status + if statusChanged && *params.Status == string(MilestoneStatusCompleted) { + now := time.Now().UTC() + m.CompletionDate = &now + } + } + if params.ScheduledCompletionDate != nil { + m.ScheduledCompletionDate = params.ScheduledCompletionDate + } + if params.OrderIndex != nil { + m.OrderIndex = *params.OrderIndex + } + + tx, err := beginTx(s.db) + if err != nil { + return nil, err + } + defer rollbackTxOnPanic(tx) + + if err := tx.Save(m).Error; err != nil { + tx.Rollback() + return nil, err + } + if err := tx.Commit().Error; err != nil { + return nil, err + } + + return s.getMilestoneByID(poamItemID, milestoneID) +} + +// DeleteMilestone removes the milestone identified by (poamItemID, milestoneID). +// Returns gorm.ErrRecordNotFound when the milestone does not exist or does not +// belong to the given POAM item. +func (s *PoamService) DeleteMilestone(poamItemID, milestoneID uuid.UUID) error { + result := s.db. + Where("poam_item_id = ? AND id = ?", poamItemID, milestoneID). + Delete(&PoamItemMilestone{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +// getMilestoneByID is an internal helper that fetches a milestone by its +// composite key (poamItemID, milestoneID). +func (s *PoamService) getMilestoneByID(poamItemID, milestoneID uuid.UUID) (*PoamItemMilestone, error) { + var m PoamItemMilestone + if err := s.db.First(&m, "poam_item_id = ? AND id = ?", poamItemID, milestoneID).Error; err != nil { + return nil, err + } + return &m, nil +} + +// --------------------------------------------------------------------------- +// Link sub-resource operations +// --------------------------------------------------------------------------- + +// ListRiskLinks returns all risk link records for the given POAM item. +func (s *PoamService) ListRiskLinks(poamItemID uuid.UUID) ([]PoamItemRiskLink, error) { + var links []PoamItemRiskLink + if err := s.db.Where("poam_item_id = ?", poamItemID).Find(&links).Error; err != nil { + return nil, err + } + return links, nil +} + +// AddRiskLink creates a risk link for the given POAM item. Duplicate links are +// silently ignored (ON CONFLICT DO NOTHING), matching the Risk service pattern. +func (s *PoamService) AddRiskLink(poamItemID, riskID uuid.UUID) (*PoamItemRiskLink, error) { + link := PoamItemRiskLink{PoamItemID: poamItemID, RiskID: riskID} + result := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&link) + if result.Error != nil { + return nil, result.Error + } + // Re-fetch to ensure we return the persisted record regardless of conflict. + if err := s.db.Where("poam_item_id = ? AND risk_id = ?", poamItemID, riskID).First(&link).Error; err != nil { + return nil, err + } + return &link, nil +} + +// DeleteRiskLink removes the risk link identified by (poamItemID, riskID). +// Returns gorm.ErrRecordNotFound when the link does not exist. +func (s *PoamService) DeleteRiskLink(poamItemID, riskID uuid.UUID) error { + result := s.db. + Where("poam_item_id = ? AND risk_id = ?", poamItemID, riskID). + Delete(&PoamItemRiskLink{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +// ListEvidenceLinks returns all evidence link records for the given POAM item. +func (s *PoamService) ListEvidenceLinks(poamItemID uuid.UUID) ([]PoamItemEvidenceLink, error) { + var links []PoamItemEvidenceLink + if err := s.db.Where("poam_item_id = ?", poamItemID).Find(&links).Error; err != nil { + return nil, err + } + return links, nil +} + +// AddEvidenceLink creates an evidence link for the given POAM item. Duplicate +// links are silently ignored. +func (s *PoamService) AddEvidenceLink(poamItemID, evidenceID uuid.UUID) (*PoamItemEvidenceLink, error) { + link := PoamItemEvidenceLink{PoamItemID: poamItemID, EvidenceID: evidenceID} + result := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&link) + if result.Error != nil { + return nil, result.Error + } + if err := s.db.Where("poam_item_id = ? AND evidence_id = ?", poamItemID, evidenceID).First(&link).Error; err != nil { + return nil, err + } + return &link, nil +} + +// DeleteEvidenceLink removes the evidence link identified by (poamItemID, evidenceID). +func (s *PoamService) DeleteEvidenceLink(poamItemID, evidenceID uuid.UUID) error { + result := s.db. + Where("poam_item_id = ? AND evidence_id = ?", poamItemID, evidenceID). + Delete(&PoamItemEvidenceLink{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +// ListControlLinks returns all control link records for the given POAM item. +func (s *PoamService) ListControlLinks(poamItemID uuid.UUID) ([]PoamItemControlLink, error) { + var links []PoamItemControlLink + if err := s.db.Where("poam_item_id = ?", poamItemID).Find(&links).Error; err != nil { + return nil, err + } + return links, nil +} + +// AddControlLink creates a control link for the given POAM item. Duplicate +// links are silently ignored. +func (s *PoamService) AddControlLink(poamItemID uuid.UUID, ref ControlRef) (*PoamItemControlLink, error) { + link := PoamItemControlLink{PoamItemID: poamItemID, CatalogID: ref.CatalogID, ControlID: ref.ControlID} + result := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&link) + if result.Error != nil { + return nil, result.Error + } + if err := s.db.Where("poam_item_id = ? AND catalog_id = ? AND control_id = ?", poamItemID, ref.CatalogID, ref.ControlID).First(&link).Error; err != nil { + return nil, err + } + return &link, nil +} + +// DeleteControlLink removes the control link identified by (poamItemID, catalogID, controlID). +func (s *PoamService) DeleteControlLink(poamItemID, catalogID uuid.UUID, controlID string) error { + result := s.db. + Where("poam_item_id = ? AND catalog_id = ? AND control_id = ?", poamItemID, catalogID, controlID). + Delete(&PoamItemControlLink{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +// ListFindingLinks returns all finding link records for the given POAM item. +func (s *PoamService) ListFindingLinks(poamItemID uuid.UUID) ([]PoamItemFindingLink, error) { + var links []PoamItemFindingLink + if err := s.db.Where("poam_item_id = ?", poamItemID).Find(&links).Error; err != nil { + return nil, err + } + return links, nil +} + +// AddFindingLink creates a finding link for the given POAM item. Duplicate +// links are silently ignored. +func (s *PoamService) AddFindingLink(poamItemID, findingID uuid.UUID) (*PoamItemFindingLink, error) { + link := PoamItemFindingLink{PoamItemID: poamItemID, FindingID: findingID} + result := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&link) + if result.Error != nil { + return nil, result.Error + } + if err := s.db.Where("poam_item_id = ? AND finding_id = ?", poamItemID, findingID).First(&link).Error; err != nil { + return nil, err + } + return &link, nil +} + +// DeleteFindingLink removes the finding link identified by (poamItemID, findingID). +func (s *PoamService) DeleteFindingLink(poamItemID, findingID uuid.UUID) error { + result := s.db. + Where("poam_item_id = ? AND finding_id = ?", poamItemID, findingID). + Delete(&PoamItemFindingLink{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +// --------------------------------------------------------------------------- +// Transaction helpers +// --------------------------------------------------------------------------- + +func beginTx(db *gorm.DB) (*gorm.DB, error) { + tx := db.Begin() + if tx.Error != nil { + return nil, tx.Error + } + return tx, nil +} + +func rollbackTxOnPanic(tx *gorm.DB) { + if r := recover(); r != nil { + tx.Rollback() + panic(r) + } +} diff --git a/internal/tests/migrate.go b/internal/tests/migrate.go index b8769fba..d8f97bc0 100644 --- a/internal/tests/migrate.go +++ b/internal/tests/migrate.go @@ -5,6 +5,7 @@ package tests import ( "github.com/compliance-framework/api/internal/service" "github.com/compliance-framework/api/internal/service/relational" + poamrel "github.com/compliance-framework/api/internal/service/relational/poam" riskrel "github.com/compliance-framework/api/internal/service/relational/risks" templaterel "github.com/compliance-framework/api/internal/service/relational/templates" "gorm.io/gorm" @@ -165,6 +166,12 @@ func (t *TestMigrator) Up() error { &relational.User{}, &service.Heartbeat{}, + &poamrel.PoamItem{}, + &poamrel.PoamItemMilestone{}, + &poamrel.PoamItemRiskLink{}, + &poamrel.PoamItemEvidenceLink{}, + &poamrel.PoamItemControlLink{}, + &poamrel.PoamItemFindingLink{}, &relational.Evidence{}, &relational.Labels{}, &relational.SelectSubjectById{}, @@ -286,6 +293,12 @@ func (t *TestMigrator) Down() error { "result_risks", "control_selection_assessed_controls_included", "control_selection_assessed_controls_excluded", + &poamrel.PoamItemFindingLink{}, + &poamrel.PoamItemControlLink{}, + &poamrel.PoamItemEvidenceLink{}, + &poamrel.PoamItemRiskLink{}, + &poamrel.PoamItemMilestone{}, + &poamrel.PoamItem{}, &relational.Profile{}, &relational.Import{}, &relational.Merge{},