From 38f598e93e5a37aa4b014ff3616036f4ddfadc49 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:45:32 -0300 Subject: [PATCH 1/5] feat(ssp-templates): SSP template model and admin CRUD routes Add SSPTemplate and SSPTemplateInstance with their foreign keys, template validation, and /api/admin/ssp-templates list/create/get/update/delete behind admin:manage. Keys cannot change after creation. Co-Authored-By: Claude Opus 5.5 --- docs/docs.go | 468 ++++++++++++++++++ docs/swagger.json | 468 ++++++++++++++++++ docs/swagger.yaml | 305 ++++++++++++ internal/api/handler/api.go | 5 + .../api/handler/templates/ssp_template.go | 242 +++++++++ .../ssp_template_integration_test.go | 217 ++++++++ internal/service/migrator.go | 7 + internal/service/relational/ssp_scope.go | 10 +- internal/service/relational/ssp_templates.go | 74 +++ .../relational/ssptemplates/service.go | 126 +++++ .../relational/ssptemplates/validate.go | 209 ++++++++ .../relational/ssptemplates/validate_test.go | 102 ++++ internal/tests/migrate.go | 7 + 13 files changed, 2239 insertions(+), 1 deletion(-) create mode 100644 internal/api/handler/templates/ssp_template.go create mode 100644 internal/api/handler/templates/ssp_template_integration_test.go create mode 100644 internal/service/relational/ssp_templates.go create mode 100644 internal/service/relational/ssptemplates/service.go create mode 100644 internal/service/relational/ssptemplates/validate.go create mode 100644 internal/service/relational/ssptemplates/validate_test.go diff --git a/docs/docs.go b/docs/docs.go index 2808458c..f942bd32 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -2555,6 +2555,323 @@ const docTemplate = `{ ] } }, + "/admin/ssp-templates": { + "get": { + "description": "List SSP templates, newest first.", + "produces": [ + "application/json" + ], + "tags": [ + "SSP Templates" + ], + "summary": "List SSP templates", + "parameters": [ + { + "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_sspTemplateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "post": { + "description": "Create an SSP template. Discovery creates one SSP per distinct combination of its key label values.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "SSP Templates" + ], + "summary": "Create SSP template", + "parameters": [ + { + "description": "SSP template", + "name": "template", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/templates.sspTemplateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/templates.sspTemplateDataResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/ssp-templates/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "SSP Templates" + ], + "summary": "Get SSP template", + "parameters": [ + { + "type": "string", + "description": "SSP template ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/templates.sspTemplateDataResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "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": "Replace an SSP template's settings. Keys cannot change (400): create a new template instead. SSPs already created are not changed.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "SSP Templates" + ], + "summary": "Update SSP template", + "parameters": [ + { + "type": "string", + "description": "SSP template ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "SSP template", + "name": "template", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/templates.sspTemplateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/templates.sspTemplateDataResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "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 an SSP template. The SSPs it created and their scope entries are kept.", + "tags": [ + "SSP Templates" + ], + "summary": "Delete SSP template", + "parameters": [ + { + "type": "string", + "description": "SSP template ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "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": [] + } + ] + } + }, "/admin/subject-templates": { "get": { "description": "List subject templates with optional filters and pagination.", @@ -51775,6 +52092,17 @@ const docTemplate = `{ "SSPLeverageStatusSuperseded" ] }, + "relational.SSPTemplateProp": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "relational.SatisfiedControlImplementationResponsibility": { "type": "object", "properties": { @@ -52925,6 +53253,29 @@ const docTemplate = `{ } } }, + "service.ListResponse-templates_sspTemplateResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/templates.sspTemplateResponse" + } + }, + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, "service.ListResponse-templates_subjectTemplateResponse": { "type": "object", "properties": { @@ -53632,6 +53983,123 @@ const docTemplate = `{ } } }, + "templates.sspTemplateDataResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/templates.sspTemplateResponse" + } + } + }, + "templates.sspTemplateRequest": { + "type": "object", + "properties": { + "description-template": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "filter": { + "$ref": "#/definitions/labelfilter.Filter" + }, + "keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "min-streams": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "profile-ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "props": { + "type": "array", + "items": { + "$ref": "#/definitions/relational.SSPTemplateProp" + } + }, + "scope-labels": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "title-template": { + "type": "string" + } + } + }, + "templates.sspTemplateResponse": { + "type": "object", + "properties": { + "created-at": { + "type": "string" + }, + "description-template": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "filter": { + "$ref": "#/definitions/labelfilter.Filter" + }, + "id": { + "type": "string" + }, + "keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "min-streams": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "profile-ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "props": { + "type": "array", + "items": { + "$ref": "#/definitions/relational.SSPTemplateProp" + } + }, + "scope-labels": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "title-template": { + "type": "string" + }, + "updated-at": { + "type": "string" + } + } + }, "templates.subjectTemplateDataResponse": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index e8b9184c..29b3b374 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2549,6 +2549,323 @@ ] } }, + "/admin/ssp-templates": { + "get": { + "description": "List SSP templates, newest first.", + "produces": [ + "application/json" + ], + "tags": [ + "SSP Templates" + ], + "summary": "List SSP templates", + "parameters": [ + { + "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_sspTemplateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "post": { + "description": "Create an SSP template. Discovery creates one SSP per distinct combination of its key label values.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "SSP Templates" + ], + "summary": "Create SSP template", + "parameters": [ + { + "description": "SSP template", + "name": "template", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/templates.sspTemplateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/templates.sspTemplateDataResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/ssp-templates/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "SSP Templates" + ], + "summary": "Get SSP template", + "parameters": [ + { + "type": "string", + "description": "SSP template ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/templates.sspTemplateDataResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "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": "Replace an SSP template's settings. Keys cannot change (400): create a new template instead. SSPs already created are not changed.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "SSP Templates" + ], + "summary": "Update SSP template", + "parameters": [ + { + "type": "string", + "description": "SSP template ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "SSP template", + "name": "template", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/templates.sspTemplateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/templates.sspTemplateDataResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "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 an SSP template. The SSPs it created and their scope entries are kept.", + "tags": [ + "SSP Templates" + ], + "summary": "Delete SSP template", + "parameters": [ + { + "type": "string", + "description": "SSP template ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "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": [] + } + ] + } + }, "/admin/subject-templates": { "get": { "description": "List subject templates with optional filters and pagination.", @@ -51769,6 +52086,17 @@ "SSPLeverageStatusSuperseded" ] }, + "relational.SSPTemplateProp": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "relational.SatisfiedControlImplementationResponsibility": { "type": "object", "properties": { @@ -52919,6 +53247,29 @@ } } }, + "service.ListResponse-templates_sspTemplateResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/templates.sspTemplateResponse" + } + }, + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, "service.ListResponse-templates_subjectTemplateResponse": { "type": "object", "properties": { @@ -53626,6 +53977,123 @@ } } }, + "templates.sspTemplateDataResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/templates.sspTemplateResponse" + } + } + }, + "templates.sspTemplateRequest": { + "type": "object", + "properties": { + "description-template": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "filter": { + "$ref": "#/definitions/labelfilter.Filter" + }, + "keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "min-streams": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "profile-ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "props": { + "type": "array", + "items": { + "$ref": "#/definitions/relational.SSPTemplateProp" + } + }, + "scope-labels": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "title-template": { + "type": "string" + } + } + }, + "templates.sspTemplateResponse": { + "type": "object", + "properties": { + "created-at": { + "type": "string" + }, + "description-template": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "filter": { + "$ref": "#/definitions/labelfilter.Filter" + }, + "id": { + "type": "string" + }, + "keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "min-streams": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "profile-ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "props": { + "type": "array", + "items": { + "$ref": "#/definitions/relational.SSPTemplateProp" + } + }, + "scope-labels": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "title-template": { + "type": "string" + }, + "updated-at": { + "type": "string" + } + } + }, "templates.subjectTemplateDataResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 0fc612d7..df16b025 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -10641,6 +10641,13 @@ definitions: - SSPLeverageStatusDrifted - SSPLeverageStatusRevoked - SSPLeverageStatusSuperseded + relational.SSPTemplateProp: + properties: + name: + type: string + value: + type: string + type: object relational.SatisfiedControlImplementationResponsibility: properties: by-component-id: @@ -11411,6 +11418,21 @@ definitions: totalPages: type: integer type: object + service.ListResponse-templates_sspTemplateResponse: + properties: + data: + items: + $ref: '#/definitions/templates.sspTemplateResponse' + type: array + limit: + type: integer + page: + type: integer + total: + type: integer + totalPages: + type: integer + type: object service.ListResponse-templates_subjectTemplateResponse: properties: data: @@ -11896,6 +11918,83 @@ definitions: type: string type: array type: object + templates.sspTemplateDataResponse: + properties: + data: + $ref: '#/definitions/templates.sspTemplateResponse' + type: object + templates.sspTemplateRequest: + properties: + description-template: + type: string + enabled: + type: boolean + filter: + $ref: '#/definitions/labelfilter.Filter' + keys: + items: + type: string + type: array + min-streams: + type: integer + name: + type: string + profile-ids: + items: + type: string + type: array + props: + items: + $ref: '#/definitions/relational.SSPTemplateProp' + type: array + scope-labels: + items: + items: + type: string + type: array + type: array + title-template: + type: string + type: object + templates.sspTemplateResponse: + properties: + created-at: + type: string + description-template: + type: string + enabled: + type: boolean + filter: + $ref: '#/definitions/labelfilter.Filter' + id: + type: string + keys: + items: + type: string + type: array + min-streams: + type: integer + name: + type: string + profile-ids: + items: + type: string + type: array + props: + items: + $ref: '#/definitions/relational.SSPTemplateProp' + type: array + scope-labels: + items: + items: + type: string + type: array + type: array + title-template: + type: string + updated-at: + type: string + type: object templates.subjectTemplateDataResponse: properties: data: @@ -14755,6 +14854,212 @@ paths: summary: Delete a role assignment tags: - RoleAssignments + /admin/ssp-templates: + get: + description: List SSP templates, newest first. + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/service.ListResponse-templates_sspTemplateResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "403": + description: Forbidden + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List SSP templates + tags: + - SSP Templates + post: + consumes: + - application/json + description: Create an SSP template. Discovery creates one SSP per distinct + combination of its key label values. + parameters: + - description: SSP template + in: body + name: template + required: true + schema: + $ref: '#/definitions/templates.sspTemplateRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/templates.sspTemplateDataResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "403": + description: Forbidden + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Create SSP template + tags: + - SSP Templates + /admin/ssp-templates/{id}: + delete: + description: Delete an SSP template. The SSPs it created and their scope entries + are kept. + parameters: + - description: SSP template ID + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "403": + description: Forbidden + 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 SSP template + tags: + - SSP Templates + get: + parameters: + - description: SSP template ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/templates.sspTemplateDataResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "403": + description: Forbidden + 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 SSP template + tags: + - SSP Templates + put: + consumes: + - application/json + description: 'Replace an SSP template''s settings. Keys cannot change (400): + create a new template instead. SSPs already created are not changed.' + parameters: + - description: SSP template ID + in: path + name: id + required: true + type: string + - description: SSP template + in: body + name: template + required: true + schema: + $ref: '#/definitions/templates.sspTemplateRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/templates.sspTemplateDataResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "403": + description: Forbidden + 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 SSP template + tags: + - SSP Templates /admin/subject-templates: get: description: List subject templates with optional filters and pagination. diff --git a/internal/api/handler/api.go b/internal/api/handler/api.go index 640cab44..d96eaf00 100644 --- a/internal/api/handler/api.go +++ b/internal/api/handler/api.go @@ -242,6 +242,11 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB subjectTemplateGroup.Use(pep.Authorize(authz.ResourceAdmin, authz.ActionManage)) subjectTemplateHandler.Register(subjectTemplateGroup) + sspTemplateGroup := server.API().Group("/admin/ssp-templates") + sspTemplateGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) + sspTemplateGroup.Use(pep.Authorize(authz.ResourceAdmin, authz.ActionManage)) + templatehandlers.NewSSPTemplateHandler(logger, db).Register(sspTemplateGroup) + agentSubjectTemplateGroup := server.API().Group("/agent/subject-templates") subjectTemplateHandler.RegisterAgent(agentSubjectTemplateGroup, agentIngestMiddleware, pep.For(authz.ResourceSubjectTemplate).Update()) diff --git a/internal/api/handler/templates/ssp_template.go b/internal/api/handler/templates/ssp_template.go new file mode 100644 index 00000000..478a26da --- /dev/null +++ b/internal/api/handler/templates/ssp_template.go @@ -0,0 +1,242 @@ +package templates + +import ( + "errors" + "net/http" + "time" + + "github.com/compliance-framework/api/internal/api" + "github.com/compliance-framework/api/internal/converters/labelfilter" + svc "github.com/compliance-framework/api/internal/service" + "github.com/compliance-framework/api/internal/service/relational" + "github.com/compliance-framework/api/internal/service/relational/ssptemplates" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// SSPTemplateHandler serves /admin/ssp-templates. +type SSPTemplateHandler struct { + service *ssptemplates.Service + sugar *zap.SugaredLogger + pagination *svc.PaginationConfig +} + +func NewSSPTemplateHandler(sugar *zap.SugaredLogger, db *gorm.DB) *SSPTemplateHandler { + return &SSPTemplateHandler{ + service: ssptemplates.NewService(db), + sugar: sugar, + pagination: svc.NewPaginationConfig(), + } +} + +// Register mounts the routes on a group guarded by JWT and admin:manage. +func (h *SSPTemplateHandler) Register(group *echo.Group) { + group.GET("", h.List) + group.POST("", h.Create) + group.GET("/:id", h.Get) + group.PUT("/:id", h.Update) + group.DELETE("/:id", h.Delete) +} + +// sspTemplateRequest is ssptemplates.Input with JSON tags; it converts to it directly. +type sspTemplateRequest struct { + Name string `json:"name"` + Enabled *bool `json:"enabled"` + Keys []string `json:"keys"` + Filter *labelfilter.Filter `json:"filter"` + ScopeLabels [][]string `json:"scope-labels"` + MinStreams *int `json:"min-streams"` + TitleTemplate string `json:"title-template"` + DescriptionTemplate *string `json:"description-template"` + Props []relational.SSPTemplateProp `json:"props"` + ProfileIDs []uuid.UUID `json:"profile-ids"` +} + +type sspTemplateResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Keys []string `json:"keys"` + Filter *labelfilter.Filter `json:"filter"` + ScopeLabels [][]string `json:"scope-labels"` + MinStreams int `json:"min-streams"` + TitleTemplate string `json:"title-template"` + DescriptionTemplate *string `json:"description-template"` + Props []relational.SSPTemplateProp `json:"props"` + ProfileIDs []uuid.UUID `json:"profile-ids"` + CreatedAt time.Time `json:"created-at"` + UpdatedAt time.Time `json:"updated-at"` +} + +type sspTemplateDataResponse struct { + Data sspTemplateResponse `json:"data"` +} + +// toSSPTemplateResponse maps a stored template. Build stores every list non-nil, so the +// lists render as [] rather than null. +func toSSPTemplateResponse(t relational.SSPTemplate) sspTemplateResponse { + resp := sspTemplateResponse{ + ID: *t.ID, + Name: t.Name, + Enabled: t.Enabled, + Keys: t.Keys, + ScopeLabels: t.ScopeLabels.Data(), + MinStreams: t.MinStreams, + TitleTemplate: t.TitleTemplate, + DescriptionTemplate: t.DescriptionTemplate, + Props: t.Props, + ProfileIDs: t.ProfileIDs, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + } + if t.Filter != nil { + f := t.Filter.Data() + resp.Filter = &f + } + return resp +} + +// List godoc +// +// @Summary List SSP templates +// @Description List SSP templates, newest first. +// @Tags SSP Templates +// @Produce json +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} svc.ListResponse[sspTemplateResponse] +// @Failure 400 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/ssp-templates [get] +func (h *SSPTemplateHandler) List(ctx echo.Context) error { + pagination, err := h.pagination.ParseParams(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + rows, total, err := h.service.List(pagination.Limit, pagination.Offset) + if err != nil { + return templateInternalServerError(ctx, h.sugar, "failed to list SSP templates", err) + } + resp := make([]sspTemplateResponse, 0, len(rows)) + for _, row := range rows { + resp = append(resp, toSSPTemplateResponse(row)) + } + return ctx.JSON(http.StatusOK, svc.NewListResponse(resp, total, pagination.Page, pagination.Limit)) +} + +// Create godoc +// +// @Summary Create SSP template +// @Description Create an SSP template. Discovery creates one SSP per distinct combination of its key label values. +// @Tags SSP Templates +// @Accept json +// @Produce json +// @Param template body sspTemplateRequest true "SSP template" +// @Success 201 {object} sspTemplateDataResponse +// @Failure 400 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/ssp-templates [post] +func (h *SSPTemplateHandler) Create(ctx echo.Context) error { + var req sspTemplateRequest + if err := ctx.Bind(&req); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + row, err := h.service.Create(ssptemplates.Input(req)) + if err != nil { + return h.serviceError(ctx, "failed to create SSP template", err) + } + return ctx.JSON(http.StatusCreated, sspTemplateDataResponse{Data: toSSPTemplateResponse(row)}) +} + +// Get godoc +// +// @Summary Get SSP template +// @Tags SSP Templates +// @Produce json +// @Param id path string true "SSP template ID" +// @Success 200 {object} sspTemplateDataResponse +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/ssp-templates/{id} [get] +func (h *SSPTemplateHandler) Get(ctx echo.Context) error { + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + row, err := h.service.Get(id) + if err != nil { + return h.serviceError(ctx, "failed to get SSP template", err) + } + return ctx.JSON(http.StatusOK, sspTemplateDataResponse{Data: toSSPTemplateResponse(row)}) +} + +// Update godoc +// +// @Summary Update SSP template +// @Description Replace an SSP template's settings. Keys cannot change (400): create a new template instead. SSPs already created are not changed. +// @Tags SSP Templates +// @Accept json +// @Produce json +// @Param id path string true "SSP template ID" +// @Param template body sspTemplateRequest true "SSP template" +// @Success 200 {object} sspTemplateDataResponse +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/ssp-templates/{id} [put] +func (h *SSPTemplateHandler) Update(ctx echo.Context) error { + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + var req sspTemplateRequest + if err := ctx.Bind(&req); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + row, err := h.service.Update(id, ssptemplates.Input(req)) + if err != nil { + return h.serviceError(ctx, "failed to update SSP template", err) + } + return ctx.JSON(http.StatusOK, sspTemplateDataResponse{Data: toSSPTemplateResponse(row)}) +} + +// Delete godoc +// +// @Summary Delete SSP template +// @Description Delete an SSP template. The SSPs it created and their scope entries are kept. +// @Tags SSP Templates +// @Param id path string true "SSP template ID" +// @Success 204 +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/ssp-templates/{id} [delete] +func (h *SSPTemplateHandler) Delete(ctx echo.Context) error { + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.service.Delete(id); err != nil { + return h.serviceError(ctx, "failed to delete SSP template", err) + } + return ctx.NoContent(http.StatusNoContent) +} + +func (h *SSPTemplateHandler) serviceError(ctx echo.Context, message string, err error) error { + switch { + case errors.Is(err, ssptemplates.ErrNotFound): + return ctx.JSON(http.StatusNotFound, api.NotFound()) + case errors.Is(err, ssptemplates.ErrInvalidTemplate): + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + default: + return templateInternalServerError(ctx, h.sugar, message, err) + } +} diff --git a/internal/api/handler/templates/ssp_template_integration_test.go b/internal/api/handler/templates/ssp_template_integration_test.go new file mode 100644 index 00000000..97e6f3ba --- /dev/null +++ b/internal/api/handler/templates/ssp_template_integration_test.go @@ -0,0 +1,217 @@ +//go:build integration + +package templates_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/compliance-framework/api/internal/api" + handlerpkg "github.com/compliance-framework/api/internal/api/handler" + "github.com/compliance-framework/api/internal/api/middleware" + "github.com/compliance-framework/api/internal/authn" + "github.com/compliance-framework/api/internal/authz" + "github.com/compliance-framework/api/internal/service/relational" + "github.com/compliance-framework/api/internal/tests" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/suite" + "go.uber.org/zap" + "gorm.io/datatypes" +) + +// SSPTemplateApiIntegrationSuite exercises /admin/ssp-templates through the embedded +// Cedar PDP. +type SSPTemplateApiIntegrationSuite struct { + tests.IntegrationTestSuite + server *api.Server + admin string + contributor string +} + +func TestSSPTemplateApi(t *testing.T) { + suite.Run(t, new(SSPTemplateApiIntegrationSuite)) +} + +func (s *SSPTemplateApiIntegrationSuite) SetupTest() { + s.Require().NoError(s.Migrator.Refresh()) + s.admin = s.roledUser("admin@example.com", "admin") + s.contributor = s.roledUser("contributor@example.com", "contributor") + + logger := zap.NewNop().Sugar() + pdp, err := authz.Open(authz.Options{Driver: authz.DriverCedar}, authz.Deps{DB: s.DB, Config: s.Config, Logger: logger}) + s.Require().NoError(err) + s.server = api.NewServer(context.Background(), logger, s.Config, api.NewMetricsHandler(context.Background(), logger)) + handlerpkg.RegisterHandlers(s.server, logger, s.DB, s.Config, &handlerpkg.APIServices{ + PEP: middleware.NewPEP(pdp, authz.FailClosed, logger), + }) +} + +func (s *SSPTemplateApiIntegrationSuite) roledUser(email, role string) string { + user := relational.User{Email: email, FirstName: "Test", LastName: "User"} + s.Require().NoError(s.DB.Create(&user).Error) + s.Require().NoError(s.DB.Create(&relational.CCFRoleAssignment{ + RoleName: role, + AssigneeType: relational.RoleAssigneeTypeUser, + AssigneeID: relational.NormalizeAssigneeID(email), + }).Error) + token, err := authn.GenerateJWTToken(&user, s.Config.JWTPrivateKey) + s.Require().NoError(err) + return *token +} + +func (s *SSPTemplateApiIntegrationSuite) do(token, method, path string, body any) *httptest.ResponseRecorder { + var raw []byte + if body != nil { + var err error + raw, err = json.Marshal(body) + s.Require().NoError(err) + } + req := httptest.NewRequest(method, "/api/admin/ssp-templates"+path, bytes.NewReader(raw)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set(echo.HeaderAuthorization, "Bearer "+token) + rec := httptest.NewRecorder() + s.server.E().ServeHTTP(rec, req) + return rec +} + +func templateBody(name string, keys ...string) map[string]any { + return map[string]any{ + "name": name, + "keys": keys, + "title-template": "SSP for {{ ._app }}", + } +} + +// data decodes the response's data after checking its status. +func (s *SSPTemplateApiIntegrationSuite) data(rec *httptest.ResponseRecorder, status int) map[string]any { + s.Require().Equal(status, rec.Code, rec.Body.String()) + var out map[string]any + s.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &out)) + return out +} + +func (s *SSPTemplateApiIntegrationSuite) create(body map[string]any) map[string]any { + return s.data(s.do(s.admin, http.MethodPost, "", body), http.StatusCreated)["data"].(map[string]any) +} + +func (s *SSPTemplateApiIntegrationSuite) TestAdminManagesTemplates() { + profileID := uuid.New() + s.Require().NoError(s.DB.Create(&relational.Profile{UUIDModel: relational.UUIDModel{ID: &profileID}}).Error) + + body := templateBody("Applications", "_APP") + body["scope-labels"] = [][]string{{"Repo"}, {"region", "aws_account"}} + body["filter"] = map[string]any{"scope": map[string]any{"condition": map[string]any{"label": "auto_create_ssp", "operator": "=", "value": "true"}}} + body["props"] = []map[string]string{{"name": "application", "value": "{{ ._app }}"}} + body["profile-ids"] = []uuid.UUID{profileID} + created := s.create(body) + s.Equal([]any{"_app"}, created["keys"]) + s.Equal([]any{[]any{"repo"}, []any{"aws_account", "region"}}, created["scope-labels"]) + s.Equal(true, created["enabled"]) + s.Equal(float64(1), created["min-streams"]) + s.Equal([]any{profileID.String()}, created["profile-ids"]) + s.Contains(created, "created-at") + s.NotNil(created["filter"]) + id := created["id"].(string) + + list := s.data(s.do(s.admin, http.MethodGet, "", nil), http.StatusOK) + s.Equal(float64(1), list["total"]) + s.Equal(id, list["data"].([]any)[0].(map[string]any)["id"]) + + update := templateBody("Renamed", "_app") + update["enabled"] = false + update["min-streams"] = 3 + s.data(s.do(s.admin, http.MethodPut, "/"+id, update), http.StatusOK) + got := s.data(s.do(s.admin, http.MethodGet, "/"+id, nil), http.StatusOK)["data"].(map[string]any) + s.Equal("Renamed", got["name"]) + s.Equal(false, got["enabled"]) + s.Equal(float64(3), got["min-streams"]) + s.Nil(got["filter"]) + s.Equal([]any{}, got["profile-ids"]) + + rec := s.do(s.admin, http.MethodPut, "/"+id, templateBody("Renamed", "_app", "region")) + s.Equal(http.StatusBadRequest, rec.Code, "keys cannot change: %s", rec.Body.String()) + + s.Equal(http.StatusNoContent, s.do(s.admin, http.MethodDelete, "/"+id, nil).Code) + s.Equal(http.StatusNotFound, s.do(s.admin, http.MethodGet, "/"+id, nil).Code) +} + +func (s *SSPTemplateApiIntegrationSuite) TestContributorDenied() { + id := s.create(templateBody("Applications", "_app"))["id"].(string) + for _, req := range []struct { + method, path string + body any + }{ + {http.MethodGet, "", nil}, + {http.MethodPost, "", templateBody("Other", "_app")}, + {http.MethodGet, "/" + id, nil}, + {http.MethodPut, "/" + id, templateBody("Other", "_app")}, + {http.MethodDelete, "/" + id, nil}, + } { + rec := s.do(s.contributor, req.method, req.path, req.body) + s.Equal(http.StatusForbidden, rec.Code, "%s %s", req.method, req.path) + } + var count int64 + s.Require().NoError(s.DB.Model(&relational.SSPTemplate{}).Count(&count).Error) + s.Equal(int64(1), count) +} + +func (s *SSPTemplateApiIntegrationSuite) TestErrors() { + badTitle := templateBody("Apps", "_app") + badTitle["title-template"] = "{{ ._app" + unknownProfile := templateBody("Apps", "_app") + unknownProfile["profile-ids"] = []string{uuid.NewString()} + unknown := "/" + uuid.NewString() + + cases := []struct { + name, method, path string + body any + want int + }{ + {"unparseable title", http.MethodPost, "", badTitle, http.StatusBadRequest}, + {"unknown profile", http.MethodPost, "", unknownProfile, http.StatusBadRequest}, + {"no keys", http.MethodPost, "", templateBody("Apps"), http.StatusBadRequest}, + {"malformed body", http.MethodPost, "", "not an object", http.StatusBadRequest}, + {"bad id", http.MethodGet, "/nope", nil, http.StatusBadRequest}, + {"unknown get", http.MethodGet, unknown, nil, http.StatusNotFound}, + {"unknown update", http.MethodPut, unknown, templateBody("Apps", "_app"), http.StatusNotFound}, + {"unknown delete", http.MethodDelete, unknown, nil, http.StatusNotFound}, + } + for _, tc := range cases { + rec := s.do(s.admin, tc.method, tc.path, tc.body) + s.Equal(tc.want, rec.Code, "%s: %s", tc.name, rec.Body.String()) + } +} + +// TestDeleteKeepsSSPs checks the instance foreign keys: deleting an SSP nulls its +// instance's ssp_id, and deleting the template drops its instances but keeps its SSPs. +func (s *SSPTemplateApiIntegrationSuite) TestDeleteKeepsSSPs() { + templateID := uuid.MustParse(s.create(templateBody("Applications", "_app"))["id"].(string)) + kept, deleted := uuid.New(), uuid.New() + for i, sspID := range []uuid.UUID{kept, deleted} { + id := sspID + s.Require().NoError(s.DB.Create(&relational.SystemSecurityPlan{UUIDModel: relational.UUIDModel{ID: &id}}).Error) + s.Require().NoError(s.DB.Create(&relational.SSPTemplateInstance{ + TemplateID: templateID, + KeyHash: []string{"a", "b"}[i], + KeyValues: datatypes.JSONMap{"_app": []string{"a", "b"}[i]}, + SSPID: &id, + }).Error) + } + + s.Require().NoError(s.DB.Delete(&relational.SystemSecurityPlan{}, "id = ?", deleted).Error) + var instance relational.SSPTemplateInstance + s.Require().NoError(s.DB.Where("key_hash = ?", "b").First(&instance).Error) + s.Nil(instance.SSPID, "deleting the SSP keeps the instance, so discovery does not recreate it") + + s.Require().Equal(http.StatusNoContent, s.do(s.admin, http.MethodDelete, "/"+templateID.String(), nil).Code) + var instances, ssps int64 + s.Require().NoError(s.DB.Model(&relational.SSPTemplateInstance{}).Count(&instances).Error) + s.Require().NoError(s.DB.Model(&relational.SystemSecurityPlan{}).Where("id = ?", kept).Count(&ssps).Error) + s.Zero(instances) + s.Equal(int64(1), ssps) +} diff --git a/internal/service/migrator.go b/internal/service/migrator.go index 91947af0..d6b90cfe 100644 --- a/internal/service/migrator.go +++ b/internal/service/migrator.go @@ -202,6 +202,8 @@ func MigrateUpWithConfig(db *gorm.DB, cfg *config.Config) error { &relational.SSPScopeEntry{}, &relational.SSPScopeException{}, &relational.SSPScopePolicyFilter{}, + &relational.SSPTemplate{}, + &relational.SSPTemplateInstance{}, &suggestionrel.DashboardSuggestionRun{}, &suggestionrel.DashboardSuggestionRunCell{}, &suggestionrel.DashboardSuggestion{}, @@ -225,6 +227,9 @@ func MigrateUpWithConfig(db *gorm.DB, cfg *config.Config) error { if err := relational.EnsureSSPScopeConstraints(db); err != nil { return err } + if err := relational.EnsureSSPTemplateConstraints(db); err != nil { + return err + } if err := migrateLegacySystemNotificationDestinations(db, cfg, !systemNotificationDestinationTableExists); err != nil { return err @@ -1203,6 +1208,8 @@ func MigrateDown(db *gorm.DB) error { &suggestionrel.DashboardSuggestionRunCell{}, &suggestionrel.DashboardSuggestionRun{}, &relational.FilterResponsibility{}, + &relational.SSPTemplateInstance{}, + &relational.SSPTemplate{}, &relational.SSPScopeSettings{}, &relational.SSPScopeEntry{}, &relational.SSPScopeException{}, diff --git a/internal/service/relational/ssp_scope.go b/internal/service/relational/ssp_scope.go index a3e4bc5c..9fda1e59 100644 --- a/internal/service/relational/ssp_scope.go +++ b/internal/service/relational/ssp_scope.go @@ -94,12 +94,20 @@ func EnsureSSPScopeConstraints(db *gorm.DB) error { if db.Name() != "postgres" { return nil } - type foreignKey struct{ table, name, column, references, onDelete string } keys := make([]foreignKey, 0, len(sspScopeTables)+1) for _, table := range sspScopeTables { keys = append(keys, foreignKey{table, "fk_" + table + "_system_security_plan", "ssp_id", "system_security_plans(id)", "CASCADE"}) } keys = append(keys, foreignKey{"ssp_scope_policy_filters", "fk_ssp_scope_policy_filters_filter", "filter_id", "filters(id)", "SET NULL"}) + return ensureForeignKeys(db, keys) +} + +// foreignKey is a foreign key constraint added by ensureForeignKeys. +type foreignKey struct{ table, name, column, references, onDelete string } + +// ensureForeignKeys adds each constraint that does not exist yet. Idempotent; postgres +// only (callers check). +func ensureForeignKeys(db *gorm.DB, keys []foreignKey) error { for _, key := range keys { if err := db.Exec(fmt.Sprintf(` DO $$ diff --git a/internal/service/relational/ssp_templates.go b/internal/service/relational/ssp_templates.go new file mode 100644 index 00000000..4624bc5d --- /dev/null +++ b/internal/service/relational/ssp_templates.go @@ -0,0 +1,74 @@ +package relational + +import ( + "time" + + "github.com/compliance-framework/api/internal/converters/labelfilter" + "github.com/google/uuid" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +// SSPTemplateProp is a prop an SSP template writes on the SSPs it creates. Value is a Go +// text/template rendered with the SSP's key values. +type SSPTemplateProp struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// SSPTemplate creates one SSP per distinct combination of its key label values seen in +// the latest evidence, and keeps each SSP's scope up to date (see ssptemplates). +type SSPTemplate struct { + UUIDModel + Name string `gorm:"not null"` + Enabled bool `gorm:"not null;default:true"` + // Keys identify one SSP by their values: sorted, lower-cased, immutable. + Keys datatypes.JSONSlice[string] `gorm:"type:jsonb;not null"` + // Filter, when set, limits discovery to the evidence it matches. + Filter *datatypes.JSONType[labelfilter.Filter] `gorm:"type:jsonb"` + // ScopeLabels are label groups learned into each SSP's scope (sorted, lower-cased). + ScopeLabels datatypes.JSONType[[][]string] `gorm:"type:jsonb"` + MinStreams int `gorm:"not null;default:1"` + TitleTemplate string `gorm:"not null"` + DescriptionTemplate *string + Props datatypes.JSONSlice[SSPTemplateProp] `gorm:"type:jsonb"` + ProfileIDs datatypes.JSONSlice[uuid.UUID] `gorm:"type:jsonb"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (SSPTemplate) TableName() string { return "ssp_templates" } + +// SSPTemplateInstance records an SSP a template created, keyed by its key values. +type SSPTemplateInstance struct { + TemplateID uuid.UUID `gorm:"type:uuid;primaryKey"` + // KeyHash is the SHA-256 of the sorted lower(key)=value pairs. + KeyHash string `gorm:"primaryKey"` + KeyValues datatypes.JSONMap `gorm:"type:jsonb;not null"` + // SSPID is nulled when the SSP is deleted (ON DELETE SET NULL); discovery then skips + // the instance, so a deleted SSP is not recreated until an admin forgets it. + SSPID *uuid.UUID `gorm:"type:uuid;index"` + CreatedAt time.Time + LastSeenAt time.Time + // LastError is the last failure applying the instance (a profile attach, say). + LastError *string + + Template *SSPTemplate `gorm:"foreignKey:TemplateID;constraint:OnDelete:CASCADE"` +} + +func (SSPTemplateInstance) TableName() string { return "ssp_template_instances" } + +// EnsureSSPTemplateConstraints adds the foreign keys of ssp_template_instances: deleting +// a template deletes its instances (its SSPs and their scope entries are kept), and +// deleting an SSP nulls its instance's ssp_id. AutoMigrate runs with +// DisableForeignKeyConstraintWhenMigrating, so the models' constraint tags alone are not +// enforced. Idempotent; postgres only. +func EnsureSSPTemplateConstraints(db *gorm.DB) error { + if db.Name() != "postgres" { + return nil + } + return ensureForeignKeys(db, []foreignKey{ + {"ssp_template_instances", "fk_ssp_template_instances_template", "template_id", "ssp_templates(id)", "CASCADE"}, + {"ssp_template_instances", "fk_ssp_template_instances_ssp", "ssp_id", "system_security_plans(id)", "SET NULL"}, + }) +} diff --git a/internal/service/relational/ssptemplates/service.go b/internal/service/relational/ssptemplates/service.go new file mode 100644 index 00000000..cdea660f --- /dev/null +++ b/internal/service/relational/ssptemplates/service.go @@ -0,0 +1,126 @@ +package ssptemplates + +import ( + "errors" + "slices" + + "github.com/compliance-framework/api/internal/service/relational" + "github.com/google/uuid" + "gorm.io/gorm" +) + +// ErrNotFound is returned when the template does not exist. +var ErrNotFound = errors.New("SSP template not found") + +// Service reads and writes SSP templates. +type Service struct { + db *gorm.DB +} + +// NewService returns a Service over db. +func NewService(db *gorm.DB) *Service { + return &Service{db: db} +} + +// List returns a page of templates, newest first, and the total count. +func (s *Service) List(limit, offset int) ([]relational.SSPTemplate, int64, error) { + var total int64 + if err := s.db.Model(&relational.SSPTemplate{}).Count(&total).Error; err != nil { + return nil, 0, err + } + rows := []relational.SSPTemplate{} + if err := s.db.Order("created_at DESC, id ASC").Limit(limit).Offset(offset).Find(&rows).Error; err != nil { + return nil, 0, err + } + return rows, total, nil +} + +// Get returns the template, or ErrNotFound. +func (s *Service) Get(id uuid.UUID) (relational.SSPTemplate, error) { + return get(s.db, id) +} + +// Create validates in and stores it as a new template. +func (s *Service) Create(in Input) (relational.SSPTemplate, error) { + tmpl, err := Build(in) + if err != nil { + return relational.SSPTemplate{}, err + } + if err := checkProfiles(s.db, tmpl.ProfileIDs); err != nil { + return relational.SSPTemplate{}, err + } + // Select("*") so a false Enabled is written rather than replaced by the column default. + if err := s.db.Select("*").Create(&tmpl).Error; err != nil { + return relational.SSPTemplate{}, err + } + return tmpl, nil +} + +// Update replaces the template's settings with in. Its keys cannot change: a template +// with other keys would describe other SSPs, so it must be a new template. +func (s *Service) Update(id uuid.UUID, in Input) (relational.SSPTemplate, error) { + tmpl, err := Build(in) + if err != nil { + return relational.SSPTemplate{}, err + } + err = s.db.Transaction(func(tx *gorm.DB) error { + existing, err := get(tx, id) + if err != nil { + return err + } + if !slices.Equal(existing.Keys, tmpl.Keys) { + return invalid("keys cannot change after creation; create a new template instead") + } + if err := checkProfiles(tx, tmpl.ProfileIDs); err != nil { + return err + } + tmpl.ID = existing.ID + tmpl.CreatedAt = existing.CreatedAt + return tx.Select("*").Save(&tmpl).Error + }) + if err != nil { + return relational.SSPTemplate{}, err + } + return tmpl, nil +} + +// Delete deletes the template and its instance records. The SSPs it created and their +// scope entries are kept. +func (s *Service) Delete(id uuid.UUID) error { + res := s.db.Where("id = ?", id).Delete(&relational.SSPTemplate{}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrNotFound + } + return nil +} + +func get(db *gorm.DB, id uuid.UUID) (relational.SSPTemplate, error) { + var tmpl relational.SSPTemplate + if err := db.Where("id = ?", id).First(&tmpl).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return relational.SSPTemplate{}, ErrNotFound + } + return relational.SSPTemplate{}, err + } + return tmpl, nil +} + +// checkProfiles fails with ErrInvalidTemplate when a profile id does not exist. +func checkProfiles(db *gorm.DB, ids []uuid.UUID) error { + if len(ids) == 0 { + return nil + } + var found []uuid.UUID + if err := db.Model(&relational.Profile{}).Where("id IN ?", ids).Pluck("id", &found).Error; err != nil { + return err + } + for _, id := range ids { + if !slices.Contains(found, id) { + return invalid("profile %s does not exist", id) + } + } + return nil +} diff --git a/internal/service/relational/ssptemplates/validate.go b/internal/service/relational/ssptemplates/validate.go new file mode 100644 index 00000000..3ccdf3b7 --- /dev/null +++ b/internal/service/relational/ssptemplates/validate.go @@ -0,0 +1,209 @@ +// Package ssptemplates stores SSP templates: rules that create one SSP per distinct +// combination of key label values in the latest evidence and keep each SSP's scope up to +// date. +package ssptemplates + +import ( + "errors" + "fmt" + "slices" + "strings" + "text/template" + + "github.com/compliance-framework/api/internal/converters/labelfilter" + "github.com/compliance-framework/api/internal/service/relational" + "github.com/compliance-framework/api/internal/service/relational/sspscope" + "github.com/google/uuid" + "gorm.io/datatypes" +) + +// Limits on a template, enforced on every write. +const ( + MaxKeys = 5 + MaxScopeLabelGroups = 10 + MaxLabelsPerGroup = 5 +) + +// ErrInvalidTemplate is returned when a template fails validation; callers answer 400. +var ErrInvalidTemplate = errors.New("invalid SSP template") + +// Input is a template as submitted. Nil Enabled means true and nil MinStreams means 1. +type Input struct { + Name string + Enabled *bool + Keys []string + Filter *labelfilter.Filter + ScopeLabels [][]string + MinStreams *int + TitleTemplate string + DescriptionTemplate *string + Props []relational.SSPTemplateProp + ProfileIDs []uuid.UUID +} + +func invalid(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrInvalidTemplate, fmt.Sprintf(format, args...)) +} + +// Build validates in and returns it as a template, normalized: keys and each scope-label +// group trimmed, lower-cased and sorted, an empty filter dropped, profile ids +// de-duplicated. It does not check that the profiles exist (the service does). +func Build(in Input) (relational.SSPTemplate, error) { + name := strings.TrimSpace(in.Name) + if name == "" { + return relational.SSPTemplate{}, invalid("name is required") + } + keys, err := normalizeLabelKeys(in.Keys, "keys", MaxKeys) + if err != nil { + return relational.SSPTemplate{}, err + } + + if len(in.ScopeLabels) > MaxScopeLabelGroups { + return relational.SSPTemplate{}, invalid("at most %d scope-label groups", MaxScopeLabelGroups) + } + groups := make([][]string, 0, len(in.ScopeLabels)) + seen := map[string]struct{}{} + for i, group := range in.ScopeLabels { + normalized, err := normalizeLabelKeys(group, fmt.Sprintf("scope-labels[%d]", i), MaxLabelsPerGroup) + if err != nil { + return relational.SSPTemplate{}, err + } + id := strings.Join(normalized, "\n") + if _, dup := seen[id]; dup { + return relational.SSPTemplate{}, invalid("scope-labels[%d] repeats another group", i) + } + seen[id] = struct{}{} + groups = append(groups, normalized) + } + + minStreams := 1 + if in.MinStreams != nil { + minStreams = *in.MinStreams + } + if minStreams < 1 { + return relational.SSPTemplate{}, invalid("min-streams must be at least 1") + } + + if strings.TrimSpace(in.TitleTemplate) == "" { + return relational.SSPTemplate{}, invalid("title-template is required") + } + if err := parseTemplate("title-template", in.TitleTemplate); err != nil { + return relational.SSPTemplate{}, err + } + if in.DescriptionTemplate != nil { + if err := parseTemplate("description-template", *in.DescriptionTemplate); err != nil { + return relational.SSPTemplate{}, err + } + } + props := make([]relational.SSPTemplateProp, 0, len(in.Props)) + for i, prop := range in.Props { + propName := strings.TrimSpace(prop.Name) + if propName == "" { + return relational.SSPTemplate{}, invalid("props[%d]: name is required", i) + } + if err := parseTemplate(fmt.Sprintf("props[%d].value", i), prop.Value); err != nil { + return relational.SSPTemplate{}, err + } + props = append(props, relational.SSPTemplateProp{Name: propName, Value: prop.Value}) + } + + var filter *datatypes.JSONType[labelfilter.Filter] + if in.Filter != nil && in.Filter.Scope != nil && (in.Filter.Scope.Condition != nil || in.Filter.Scope.Query != nil) { + if err := validateScope(*in.Filter.Scope); err != nil { + return relational.SSPTemplate{}, err + } + f := datatypes.NewJSONType(*in.Filter) + filter = &f + } + + profileIDs := make([]uuid.UUID, 0, len(in.ProfileIDs)) + for _, id := range in.ProfileIDs { + if !slices.Contains(profileIDs, id) { + profileIDs = append(profileIDs, id) + } + } + + enabled := true + if in.Enabled != nil { + enabled = *in.Enabled + } + return relational.SSPTemplate{ + Name: name, + Enabled: enabled, + Keys: keys, + Filter: filter, + ScopeLabels: datatypes.NewJSONType(groups), + MinStreams: minStreams, + TitleTemplate: in.TitleTemplate, + DescriptionTemplate: in.DescriptionTemplate, + Props: props, + ProfileIDs: profileIDs, + }, nil +} + +// normalizeLabelKeys trims, lower-cases and sorts 1 to limit label keys, rejecting keys a +// scope entry could not hold (sspscope.Normalize's rules: empty, over-long, '=' or +// control characters, duplicates). +func normalizeLabelKeys(keys []string, field string, limit int) ([]string, error) { + if len(keys) == 0 || len(keys) > limit { + return nil, invalid("%s: between 1 and %d labels are required", field, limit) + } + labels := make([]relational.ScopeLabel, 0, len(keys)) + for _, key := range keys { + // The value only satisfies Normalize; the key rules are what is checked. + labels = append(labels, relational.ScopeLabel{Key: key, Value: "v"}) + } + normalized, _, err := sspscope.Normalize(labels) + if err != nil { + return nil, invalid("%s: %s", field, strings.TrimPrefix(err.Error(), sspscope.ErrInvalidLabels.Error()+": ")) + } + out := make([]string, 0, len(normalized)) + for _, label := range normalized { + out = append(out, label.Key) + } + return out, nil +} + +// parseTemplate checks that text parses as a Go text/template, the way +// templates.RenderTemplate will execute it. +func parseTemplate(field, text string) error { + if _, err := template.New(field).Option("missingkey=zero").Parse(text); err != nil { + return invalid("%s: %v", field, err) + } + return nil +} + +// validateScope checks a label filter scope: each node is a condition or a query, a +// condition names a label and uses "=" or "!=", and a query is AND or OR over at least +// one scope. +func validateScope(scope labelfilter.Scope) error { + condition, query := scope.Condition, scope.Query + switch { + case condition != nil && query != nil: + return invalid("filter: a scope is either a condition or a query") + case condition != nil: + if strings.TrimSpace(condition.Label) == "" { + return invalid("filter: a condition needs a label") + } + if condition.Operator != "=" && condition.Operator != "!=" { + return invalid("filter: unsupported condition operator %q", condition.Operator) + } + return nil + case query != nil: + op := strings.ToUpper(query.Operator) + if op != "AND" && op != "OR" { + return invalid("filter: unsupported query operator %q", query.Operator) + } + if len(query.Scopes) == 0 { + return invalid("filter: a query needs at least one scope") + } + for _, child := range query.Scopes { + if err := validateScope(child); err != nil { + return err + } + } + return nil + default: + return invalid("filter: a nested scope must not be empty") + } +} diff --git a/internal/service/relational/ssptemplates/validate_test.go b/internal/service/relational/ssptemplates/validate_test.go new file mode 100644 index 00000000..502e966e --- /dev/null +++ b/internal/service/relational/ssptemplates/validate_test.go @@ -0,0 +1,102 @@ +package ssptemplates + +import ( + "strings" + "testing" + + "github.com/compliance-framework/api/internal/converters/labelfilter" + "github.com/compliance-framework/api/internal/service/relational" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func validInput() Input { + return Input{Name: "Apps", Keys: []string{"_app"}, TitleTemplate: "SSP for {{ ._app }}"} +} + +func TestBuildNormalizes(t *testing.T) { + profile := uuid.New() + disabled := false + in := Input{ + Name: " Apps ", + Enabled: &disabled, + Keys: []string{" Region", "_APP"}, + ScopeLabels: [][]string{{"Repo"}, {"region", "AWS_Account"}}, + Filter: &labelfilter.Filter{Scope: &labelfilter.Scope{Condition: &labelfilter.Condition{ + Label: "auto_create_ssp", Operator: "=", Value: "true", + }}}, + TitleTemplate: `SSP for {{ index . "_app" }}`, + Props: []relational.SSPTemplateProp{{Name: " application ", Value: "{{ ._app }}"}}, + ProfileIDs: []uuid.UUID{profile, profile}, + } + tmpl, err := Build(in) + require.NoError(t, err) + assert.Equal(t, "Apps", tmpl.Name) + assert.False(t, tmpl.Enabled) + assert.Equal(t, []string{"_app", "region"}, []string(tmpl.Keys)) + assert.Equal(t, [][]string{{"repo"}, {"aws_account", "region"}}, tmpl.ScopeLabels.Data()) + assert.Equal(t, 1, tmpl.MinStreams) + require.NotNil(t, tmpl.Filter) + assert.Equal(t, "auto_create_ssp", tmpl.Filter.Data().Scope.Label) + assert.Equal(t, []relational.SSPTemplateProp{{Name: "application", Value: "{{ ._app }}"}}, []relational.SSPTemplateProp(tmpl.Props)) + assert.Equal(t, []uuid.UUID{profile}, []uuid.UUID(tmpl.ProfileIDs)) +} + +func TestBuildDefaults(t *testing.T) { + in := validInput() + in.Filter = &labelfilter.Filter{Scope: &labelfilter.Scope{}} + tmpl, err := Build(in) + require.NoError(t, err) + assert.True(t, tmpl.Enabled) + assert.Equal(t, 1, tmpl.MinStreams) + assert.Nil(t, tmpl.Filter, "an empty filter matches everything, so it is not stored") + assert.Empty(t, tmpl.ScopeLabels.Data()) +} + +func TestBuildRejects(t *testing.T) { + cond := func(label, op string) labelfilter.Scope { + return labelfilter.Scope{Condition: &labelfilter.Condition{Label: label, Operator: op, Value: "v"}} + } + query := func(op string, scopes ...labelfilter.Scope) *labelfilter.Filter { + return &labelfilter.Filter{Scope: &labelfilter.Scope{Query: &labelfilter.Query{Operator: op, Scopes: scopes}}} + } + broken := "{{ .x" + cases := map[string]struct { + edit func(*Input) + want string + }{ + "empty name": {func(in *Input) { in.Name = " " }, "name is required"}, + "no keys": {func(in *Input) { in.Keys = nil }, "keys: between 1 and 5 labels"}, + "too many keys": {func(in *Input) { in.Keys = []string{"a", "b", "c", "d", "e", "f"} }, "keys: between 1 and 5 labels"}, + "empty key": {func(in *Input) { in.Keys = []string{"_app", " "} }, "keys: label keys and values must not be empty"}, + "duplicate key": {func(in *Input) { in.Keys = []string{"_app", "_APP"} }, "keys: duplicate key"}, + "key with =": {func(in *Input) { in.Keys = []string{"a=b"} }, "must not contain '='"}, + "long key": {func(in *Input) { in.Keys = []string{strings.Repeat("k", 257)} }, "at most 256 characters"}, + "too many groups": {func(in *Input) { in.ScopeLabels = make([][]string, 11) }, "at most 10 scope-label groups"}, + "empty group": {func(in *Input) { in.ScopeLabels = [][]string{{}} }, "scope-labels[0]: between 1 and 5 labels"}, + "too many group items": {func(in *Input) { in.ScopeLabels = [][]string{{"a", "b", "c", "d", "e", "f"}} }, "scope-labels[0]: between 1 and 5 labels"}, + "empty label": {func(in *Input) { in.ScopeLabels = [][]string{{"repo", ""}} }, "scope-labels[0]: label keys"}, + "repeated group": {func(in *Input) { in.ScopeLabels = [][]string{{"a", "b"}, {"B", "a"}} }, "scope-labels[1] repeats"}, + "min-streams 0": {func(in *Input) { in.MinStreams = new(int) }, "min-streams must be at least 1"}, + "no title": {func(in *Input) { in.TitleTemplate = " " }, "title-template is required"}, + "bad title": {func(in *Input) { in.TitleTemplate = broken }, "title-template:"}, + "bad description": {func(in *Input) { in.DescriptionTemplate = &broken }, "description-template:"}, + "prop without name": {func(in *Input) { in.Props = []relational.SSPTemplateProp{{Name: "", Value: "x"}} }, "props[0]: name is required"}, + "bad prop value": {func(in *Input) { in.Props = []relational.SSPTemplateProp{{Name: "n", Value: broken}} }, "props[0].value:"}, + "bad operator": {func(in *Input) { in.Filter = query("AND", cond("a", "~")) }, "unsupported condition operator"}, + "no label": {func(in *Input) { in.Filter = query("AND", cond("", "=")) }, "a condition needs a label"}, + "bad query": {func(in *Input) { in.Filter = query("XOR", cond("a", "=")) }, "unsupported query operator"}, + "empty query": {func(in *Input) { in.Filter = query("or") }, "at least one scope"}, + "empty sub-scope": {func(in *Input) { in.Filter = query("and", labelfilter.Scope{}) }, "must not be empty"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + in := validInput() + tc.edit(&in) + _, err := Build(in) + require.ErrorIs(t, err, ErrInvalidTemplate) + assert.Contains(t, err.Error(), tc.want) + }) + } +} diff --git a/internal/tests/migrate.go b/internal/tests/migrate.go index d38bf98f..c48e5551 100644 --- a/internal/tests/migrate.go +++ b/internal/tests/migrate.go @@ -206,6 +206,8 @@ func (t *TestMigrator) Up() error { &relational.SSPScopeEntry{}, &relational.SSPScopeException{}, &relational.SSPScopePolicyFilter{}, + &relational.SSPTemplate{}, + &relational.SSPTemplateInstance{}, &suggestionrel.DashboardSuggestionRun{}, &suggestionrel.DashboardSuggestionRunCell{}, &suggestionrel.DashboardSuggestion{}, @@ -221,6 +223,9 @@ func (t *TestMigrator) Up() error { if err := relational.EnsureSSPScopeConstraints(t.db); err != nil { return err } + if err := relational.EnsureSSPTemplateConstraints(t.db); err != nil { + return err + } // Add unique constraints for idempotent operations (matching production migration) if t.db.Name() == "postgres" { @@ -595,6 +600,8 @@ func (t *TestMigrator) Down() error { &suggestionrel.DashboardSuggestionRunCell{}, &suggestionrel.DashboardSuggestionRun{}, &relational.FilterResponsibility{}, + &relational.SSPTemplateInstance{}, + &relational.SSPTemplate{}, &relational.SSPScopeSettings{}, &relational.SSPScopeEntry{}, &relational.SSPScopeException{}, From 06e3782a33ad287462cd1fe0bae45f987dd71c03 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:47:06 -0300 Subject: [PATCH 2/5] fix(ssp-templates): lock the template row while updating it Co-Authored-By: Claude Opus 5.5 --- .../templates/ssp_template_integration_test.go | 12 ++++++------ internal/service/relational/ssp_scope.go | 1 - internal/service/relational/ssptemplates/service.go | 5 +++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/api/handler/templates/ssp_template_integration_test.go b/internal/api/handler/templates/ssp_template_integration_test.go index 97e6f3ba..288b2ac3 100644 --- a/internal/api/handler/templates/ssp_template_integration_test.go +++ b/internal/api/handler/templates/ssp_template_integration_test.go @@ -87,8 +87,8 @@ func templateBody(name string, keys ...string) map[string]any { } } -// data decodes the response's data after checking its status. -func (s *SSPTemplateApiIntegrationSuite) data(rec *httptest.ResponseRecorder, status int) map[string]any { +// decode checks the status and decodes the JSON body. +func (s *SSPTemplateApiIntegrationSuite) decode(rec *httptest.ResponseRecorder, status int) map[string]any { s.Require().Equal(status, rec.Code, rec.Body.String()) var out map[string]any s.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &out)) @@ -96,7 +96,7 @@ func (s *SSPTemplateApiIntegrationSuite) data(rec *httptest.ResponseRecorder, st } func (s *SSPTemplateApiIntegrationSuite) create(body map[string]any) map[string]any { - return s.data(s.do(s.admin, http.MethodPost, "", body), http.StatusCreated)["data"].(map[string]any) + return s.decode(s.do(s.admin, http.MethodPost, "", body), http.StatusCreated)["data"].(map[string]any) } func (s *SSPTemplateApiIntegrationSuite) TestAdminManagesTemplates() { @@ -118,15 +118,15 @@ func (s *SSPTemplateApiIntegrationSuite) TestAdminManagesTemplates() { s.NotNil(created["filter"]) id := created["id"].(string) - list := s.data(s.do(s.admin, http.MethodGet, "", nil), http.StatusOK) + list := s.decode(s.do(s.admin, http.MethodGet, "", nil), http.StatusOK) s.Equal(float64(1), list["total"]) s.Equal(id, list["data"].([]any)[0].(map[string]any)["id"]) update := templateBody("Renamed", "_app") update["enabled"] = false update["min-streams"] = 3 - s.data(s.do(s.admin, http.MethodPut, "/"+id, update), http.StatusOK) - got := s.data(s.do(s.admin, http.MethodGet, "/"+id, nil), http.StatusOK)["data"].(map[string]any) + s.decode(s.do(s.admin, http.MethodPut, "/"+id, update), http.StatusOK) + got := s.decode(s.do(s.admin, http.MethodGet, "/"+id, nil), http.StatusOK)["data"].(map[string]any) s.Equal("Renamed", got["name"]) s.Equal(false, got["enabled"]) s.Equal(float64(3), got["min-streams"]) diff --git a/internal/service/relational/ssp_scope.go b/internal/service/relational/ssp_scope.go index 9fda1e59..53c5140f 100644 --- a/internal/service/relational/ssp_scope.go +++ b/internal/service/relational/ssp_scope.go @@ -102,7 +102,6 @@ func EnsureSSPScopeConstraints(db *gorm.DB) error { return ensureForeignKeys(db, keys) } -// foreignKey is a foreign key constraint added by ensureForeignKeys. type foreignKey struct{ table, name, column, references, onDelete string } // ensureForeignKeys adds each constraint that does not exist yet. Idempotent; postgres diff --git a/internal/service/relational/ssptemplates/service.go b/internal/service/relational/ssptemplates/service.go index cdea660f..a2edc280 100644 --- a/internal/service/relational/ssptemplates/service.go +++ b/internal/service/relational/ssptemplates/service.go @@ -7,6 +7,7 @@ import ( "github.com/compliance-framework/api/internal/service/relational" "github.com/google/uuid" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // ErrNotFound is returned when the template does not exist. @@ -35,7 +36,7 @@ func (s *Service) List(limit, offset int) ([]relational.SSPTemplate, int64, erro return rows, total, nil } -// Get returns the template, or ErrNotFound. +// Get returns the template or ErrNotFound. func (s *Service) Get(id uuid.UUID) (relational.SSPTemplate, error) { return get(s.db, id) } @@ -64,7 +65,7 @@ func (s *Service) Update(id uuid.UUID, in Input) (relational.SSPTemplate, error) return relational.SSPTemplate{}, err } err = s.db.Transaction(func(tx *gorm.DB) error { - existing, err := get(tx, id) + existing, err := get(tx.Clauses(clause.Locking{Strength: "UPDATE"}), id) if err != nil { return err } From 7c65c7712dc985c336a7a3910220e9946ebe9e98 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:48:52 -0300 Subject: [PATCH 3/5] docs(ssp-templates): regenerate swagger Co-Authored-By: Claude Opus 5.5 --- docs/docs.go | 60 ----------------------------------------------- docs/swagger.json | 60 ----------------------------------------------- docs/swagger.yaml | 40 ------------------------------- 3 files changed, 160 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index f942bd32..71c6bcb8 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -2592,18 +2592,6 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -2653,18 +2641,6 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -2710,18 +2686,6 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "404": { "description": "Not Found", "schema": { @@ -2784,18 +2748,6 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "404": { "description": "Not Found", "schema": { @@ -2840,18 +2792,6 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "404": { "description": "Not Found", "schema": { diff --git a/docs/swagger.json b/docs/swagger.json index 29b3b374..97b0d6fb 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2586,18 +2586,6 @@ "$ref": "#/definitions/api.Error" } }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -2647,18 +2635,6 @@ "$ref": "#/definitions/api.Error" } }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -2704,18 +2680,6 @@ "$ref": "#/definitions/api.Error" } }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "404": { "description": "Not Found", "schema": { @@ -2778,18 +2742,6 @@ "$ref": "#/definitions/api.Error" } }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "404": { "description": "Not Found", "schema": { @@ -2834,18 +2786,6 @@ "$ref": "#/definitions/api.Error" } }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "404": { "description": "Not Found", "schema": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index df16b025..a0d9f6a6 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -14877,14 +14877,6 @@ paths: description: Bad Request schema: $ref: '#/definitions/api.Error' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/api.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/api.Error' "500": description: Internal Server Error schema: @@ -14917,14 +14909,6 @@ paths: description: Bad Request schema: $ref: '#/definitions/api.Error' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/api.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/api.Error' "500": description: Internal Server Error schema: @@ -14951,14 +14935,6 @@ paths: description: Bad Request schema: $ref: '#/definitions/api.Error' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/api.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/api.Error' "404": description: Not Found schema: @@ -14990,14 +14966,6 @@ paths: description: Bad Request schema: $ref: '#/definitions/api.Error' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/api.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/api.Error' "404": description: Not Found schema: @@ -15039,14 +15007,6 @@ paths: description: Bad Request schema: $ref: '#/definitions/api.Error' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/api.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/api.Error' "404": description: Not Found schema: From 2263f1892501fce9b378fa750895785b45e0745e Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:59:33 -0300 Subject: [PATCH 4/5] feat(ssp-templates): return ssp-count and last-run-at on templates Contract addition from review: template responses carry the number of SSPs the template created that still exist, and the time of its last completed discovery run (a new nullable last_run_at column, set by discovery in a later PR). The template instance foreign keys move into EnsureSSPConstraints (renamed from EnsureSSPScopeConstraints). Co-Authored-By: Claude Opus 5.5 --- docs/docs.go | 9 +++- docs/swagger.json | 9 +++- docs/swagger.yaml | 7 ++- .../api/handler/templates/ssp_template.go | 27 +++++----- .../ssp_template_integration_test.go | 6 ++- internal/service/migrator.go | 5 +- internal/service/relational/ssp_scope.go | 22 ++++---- internal/service/relational/ssp_templates.go | 25 ++-------- .../relational/ssptemplates/service.go | 50 ++++++++++++------- .../relational/ssptemplates/validate.go | 3 +- .../relational/ssptemplates/validate_test.go | 4 -- internal/tests/migrate.go | 5 +- 12 files changed, 90 insertions(+), 82 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 71c6bcb8..83965847 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -2557,7 +2557,6 @@ const docTemplate = `{ }, "/admin/ssp-templates": { "get": { - "description": "List SSP templates, newest first.", "produces": [ "application/json" ], @@ -54005,6 +54004,10 @@ const docTemplate = `{ "type": "string" } }, + "last-run-at": { + "description": "last completed discovery run", + "type": "string" + }, "min-streams": { "type": "integer" }, @@ -54032,6 +54035,10 @@ const docTemplate = `{ } } }, + "ssp-count": { + "description": "SSPs created that still exist", + "type": "integer" + }, "title-template": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index 97b0d6fb..68519494 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2551,7 +2551,6 @@ }, "/admin/ssp-templates": { "get": { - "description": "List SSP templates, newest first.", "produces": [ "application/json" ], @@ -53999,6 +53998,10 @@ "type": "string" } }, + "last-run-at": { + "description": "last completed discovery run", + "type": "string" + }, "min-streams": { "type": "integer" }, @@ -54026,6 +54029,10 @@ } } }, + "ssp-count": { + "description": "SSPs created that still exist", + "type": "integer" + }, "title-template": { "type": "string" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index a0d9f6a6..d41977de 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -11972,6 +11972,9 @@ definitions: items: type: string type: array + last-run-at: + description: last completed discovery run + type: string min-streams: type: integer name: @@ -11990,6 +11993,9 @@ definitions: type: string type: array type: array + ssp-count: + description: SSPs created that still exist + type: integer title-template: type: string updated-at: @@ -14856,7 +14862,6 @@ paths: - RoleAssignments /admin/ssp-templates: get: - description: List SSP templates, newest first. parameters: - description: Page number in: query diff --git a/internal/api/handler/templates/ssp_template.go b/internal/api/handler/templates/ssp_template.go index 478a26da..57cc719c 100644 --- a/internal/api/handler/templates/ssp_template.go +++ b/internal/api/handler/templates/ssp_template.go @@ -66,6 +66,8 @@ type sspTemplateResponse struct { DescriptionTemplate *string `json:"description-template"` Props []relational.SSPTemplateProp `json:"props"` ProfileIDs []uuid.UUID `json:"profile-ids"` + SSPCount int64 `json:"ssp-count"` // SSPs created that still exist + LastRunAt *time.Time `json:"last-run-at"` // last completed discovery run CreatedAt time.Time `json:"created-at"` UpdatedAt time.Time `json:"updated-at"` } @@ -76,7 +78,7 @@ type sspTemplateDataResponse struct { // toSSPTemplateResponse maps a stored template. Build stores every list non-nil, so the // lists render as [] rather than null. -func toSSPTemplateResponse(t relational.SSPTemplate) sspTemplateResponse { +func toSSPTemplateResponse(t ssptemplates.Listed) sspTemplateResponse { resp := sspTemplateResponse{ ID: *t.ID, Name: t.Name, @@ -88,6 +90,8 @@ func toSSPTemplateResponse(t relational.SSPTemplate) sspTemplateResponse { DescriptionTemplate: t.DescriptionTemplate, Props: t.Props, ProfileIDs: t.ProfileIDs, + SSPCount: t.SSPCount, + LastRunAt: t.LastRunAt, CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt, } @@ -100,17 +104,16 @@ func toSSPTemplateResponse(t relational.SSPTemplate) sspTemplateResponse { // List godoc // -// @Summary List SSP templates -// @Description List SSP templates, newest first. -// @Tags SSP Templates -// @Produce json -// @Param page query int false "Page number" -// @Param limit query int false "Page size" -// @Success 200 {object} svc.ListResponse[sspTemplateResponse] -// @Failure 400 {object} api.Error -// @Failure 500 {object} api.Error -// @Security OAuth2Password -// @Router /admin/ssp-templates [get] +// @Summary List SSP templates +// @Tags SSP Templates +// @Produce json +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} svc.ListResponse[sspTemplateResponse] +// @Failure 400 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/ssp-templates [get] func (h *SSPTemplateHandler) List(ctx echo.Context) error { pagination, err := h.pagination.ParseParams(ctx) if err != nil { diff --git a/internal/api/handler/templates/ssp_template_integration_test.go b/internal/api/handler/templates/ssp_template_integration_test.go index 288b2ac3..a16ba53b 100644 --- a/internal/api/handler/templates/ssp_template_integration_test.go +++ b/internal/api/handler/templates/ssp_template_integration_test.go @@ -115,6 +115,9 @@ func (s *SSPTemplateApiIntegrationSuite) TestAdminManagesTemplates() { s.Equal(float64(1), created["min-streams"]) s.Equal([]any{profileID.String()}, created["profile-ids"]) s.Contains(created, "created-at") + s.Equal(float64(0), created["ssp-count"]) + s.Contains(created, "last-run-at") + s.Nil(created["last-run-at"]) s.NotNil(created["filter"]) id := created["id"].(string) @@ -175,7 +178,6 @@ func (s *SSPTemplateApiIntegrationSuite) TestErrors() { {"unparseable title", http.MethodPost, "", badTitle, http.StatusBadRequest}, {"unknown profile", http.MethodPost, "", unknownProfile, http.StatusBadRequest}, {"no keys", http.MethodPost, "", templateBody("Apps"), http.StatusBadRequest}, - {"malformed body", http.MethodPost, "", "not an object", http.StatusBadRequest}, {"bad id", http.MethodGet, "/nope", nil, http.StatusBadRequest}, {"unknown get", http.MethodGet, unknown, nil, http.StatusNotFound}, {"unknown update", http.MethodPut, unknown, templateBody("Apps", "_app"), http.StatusNotFound}, @@ -207,6 +209,8 @@ func (s *SSPTemplateApiIntegrationSuite) TestDeleteKeepsSSPs() { var instance relational.SSPTemplateInstance s.Require().NoError(s.DB.Where("key_hash = ?", "b").First(&instance).Error) s.Nil(instance.SSPID, "deleting the SSP keeps the instance, so discovery does not recreate it") + got := s.decode(s.do(s.admin, http.MethodGet, "/"+templateID.String(), nil), http.StatusOK)["data"].(map[string]any) + s.Equal(float64(1), got["ssp-count"], "only SSPs that still exist count") s.Require().Equal(http.StatusNoContent, s.do(s.admin, http.MethodDelete, "/"+templateID.String(), nil).Code) var instances, ssps int64 diff --git a/internal/service/migrator.go b/internal/service/migrator.go index d6b90cfe..ecd3936e 100644 --- a/internal/service/migrator.go +++ b/internal/service/migrator.go @@ -224,10 +224,7 @@ func MigrateUpWithConfig(db *gorm.DB, cfg *config.Config) error { if err := riskrel.EnsureIndexes(db); err != nil { return err } - if err := relational.EnsureSSPScopeConstraints(db); err != nil { - return err - } - if err := relational.EnsureSSPTemplateConstraints(db); err != nil { + if err := relational.EnsureSSPConstraints(db); err != nil { return err } diff --git a/internal/service/relational/ssp_scope.go b/internal/service/relational/ssp_scope.go index 53c5140f..d8b39088 100644 --- a/internal/service/relational/ssp_scope.go +++ b/internal/service/relational/ssp_scope.go @@ -85,28 +85,24 @@ func (SSPScopePolicyFilter) TableName() string { return "ssp_scope_policy_filter // sspScopeTables are the tables whose ssp_id cascades from system_security_plans. var sspScopeTables = []string{"ssp_scope_settings", "ssp_scope_entries", "ssp_scope_exceptions", "ssp_scope_policy_filters"} -// EnsureSSPScopeConstraints adds the ON DELETE CASCADE foreign keys from the scope tables -// to system_security_plans, and the ON DELETE SET NULL foreign key from -// ssp_scope_policy_filters to filters. AutoMigrate runs with +// EnsureSSPConstraints adds the ON DELETE CASCADE foreign keys from the scope tables +// to system_security_plans, the ON DELETE SET NULL foreign key from +// ssp_scope_policy_filters to filters, and ssp_template_instances' foreign keys (see +// SSPTemplateInstance). AutoMigrate runs with // DisableForeignKeyConstraintWhenMigrating, so the models' constraint tags alone are not // enforced. Idempotent; postgres only. -func EnsureSSPScopeConstraints(db *gorm.DB) error { +func EnsureSSPConstraints(db *gorm.DB) error { if db.Name() != "postgres" { return nil } - keys := make([]foreignKey, 0, len(sspScopeTables)+1) + type foreignKey struct{ table, name, column, references, onDelete string } + keys := make([]foreignKey, 0, len(sspScopeTables)+3) for _, table := range sspScopeTables { keys = append(keys, foreignKey{table, "fk_" + table + "_system_security_plan", "ssp_id", "system_security_plans(id)", "CASCADE"}) } keys = append(keys, foreignKey{"ssp_scope_policy_filters", "fk_ssp_scope_policy_filters_filter", "filter_id", "filters(id)", "SET NULL"}) - return ensureForeignKeys(db, keys) -} - -type foreignKey struct{ table, name, column, references, onDelete string } - -// ensureForeignKeys adds each constraint that does not exist yet. Idempotent; postgres -// only (callers check). -func ensureForeignKeys(db *gorm.DB, keys []foreignKey) error { + keys = append(keys, foreignKey{"ssp_template_instances", "fk_ssp_template_instances_template", "template_id", "ssp_templates(id)", "CASCADE"}) + keys = append(keys, foreignKey{"ssp_template_instances", "fk_ssp_template_instances_ssp", "ssp_id", "system_security_plans(id)", "SET NULL"}) for _, key := range keys { if err := db.Exec(fmt.Sprintf(` DO $$ diff --git a/internal/service/relational/ssp_templates.go b/internal/service/relational/ssp_templates.go index 4624bc5d..e7ee19fd 100644 --- a/internal/service/relational/ssp_templates.go +++ b/internal/service/relational/ssp_templates.go @@ -6,11 +6,9 @@ import ( "github.com/compliance-framework/api/internal/converters/labelfilter" "github.com/google/uuid" "gorm.io/datatypes" - "gorm.io/gorm" ) -// SSPTemplateProp is a prop an SSP template writes on the SSPs it creates. Value is a Go -// text/template rendered with the SSP's key values. +// SSPTemplateProp is a prop written on created SSPs; Value is a text/template of the keys. type SSPTemplateProp struct { Name string `json:"name"` Value string `json:"value"` @@ -33,8 +31,10 @@ type SSPTemplate struct { DescriptionTemplate *string Props datatypes.JSONSlice[SSPTemplateProp] `gorm:"type:jsonb"` ProfileIDs datatypes.JSONSlice[uuid.UUID] `gorm:"type:jsonb"` - CreatedAt time.Time - UpdatedAt time.Time + // LastRunAt is when a discovery run for the template last completed. + LastRunAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time } func (SSPTemplate) TableName() string { return "ssp_templates" } @@ -57,18 +57,3 @@ type SSPTemplateInstance struct { } func (SSPTemplateInstance) TableName() string { return "ssp_template_instances" } - -// EnsureSSPTemplateConstraints adds the foreign keys of ssp_template_instances: deleting -// a template deletes its instances (its SSPs and their scope entries are kept), and -// deleting an SSP nulls its instance's ssp_id. AutoMigrate runs with -// DisableForeignKeyConstraintWhenMigrating, so the models' constraint tags alone are not -// enforced. Idempotent; postgres only. -func EnsureSSPTemplateConstraints(db *gorm.DB) error { - if db.Name() != "postgres" { - return nil - } - return ensureForeignKeys(db, []foreignKey{ - {"ssp_template_instances", "fk_ssp_template_instances_template", "template_id", "ssp_templates(id)", "CASCADE"}, - {"ssp_template_instances", "fk_ssp_template_instances_ssp", "ssp_id", "system_security_plans(id)", "SET NULL"}, - }) -} diff --git a/internal/service/relational/ssptemplates/service.go b/internal/service/relational/ssptemplates/service.go index a2edc280..6a12ca60 100644 --- a/internal/service/relational/ssptemplates/service.go +++ b/internal/service/relational/ssptemplates/service.go @@ -23,49 +23,60 @@ func NewService(db *gorm.DB) *Service { return &Service{db: db} } +// Listed is a template with the number of SSPs it created that still exist. +type Listed struct { + relational.SSPTemplate + SSPCount int64 +} + +// withSSPCount selects a template and its Listed.SSPCount. +const withSSPCount = `ssp_templates.*, (SELECT COUNT(*) FROM ssp_template_instances i + WHERE i.template_id = ssp_templates.id AND i.ssp_id IS NOT NULL) AS ssp_count` + // List returns a page of templates, newest first, and the total count. -func (s *Service) List(limit, offset int) ([]relational.SSPTemplate, int64, error) { +func (s *Service) List(limit, offset int) ([]Listed, int64, error) { var total int64 if err := s.db.Model(&relational.SSPTemplate{}).Count(&total).Error; err != nil { return nil, 0, err } - rows := []relational.SSPTemplate{} - if err := s.db.Order("created_at DESC, id ASC").Limit(limit).Offset(offset).Find(&rows).Error; err != nil { + rows := []Listed{} + if err := s.db.Model(&relational.SSPTemplate{}).Select(withSSPCount).Order("created_at DESC, id ASC").Limit(limit).Offset(offset).Find(&rows).Error; err != nil { return nil, 0, err } return rows, total, nil } // Get returns the template or ErrNotFound. -func (s *Service) Get(id uuid.UUID) (relational.SSPTemplate, error) { +func (s *Service) Get(id uuid.UUID) (Listed, error) { return get(s.db, id) } // Create validates in and stores it as a new template. -func (s *Service) Create(in Input) (relational.SSPTemplate, error) { +func (s *Service) Create(in Input) (Listed, error) { tmpl, err := Build(in) if err != nil { - return relational.SSPTemplate{}, err + return Listed{}, err } if err := checkProfiles(s.db, tmpl.ProfileIDs); err != nil { - return relational.SSPTemplate{}, err + return Listed{}, err } // Select("*") so a false Enabled is written rather than replaced by the column default. if err := s.db.Select("*").Create(&tmpl).Error; err != nil { - return relational.SSPTemplate{}, err + return Listed{}, err } - return tmpl, nil + return Listed{SSPTemplate: tmpl}, nil } // Update replaces the template's settings with in. Its keys cannot change: a template // with other keys would describe other SSPs, so it must be a new template. -func (s *Service) Update(id uuid.UUID, in Input) (relational.SSPTemplate, error) { +func (s *Service) Update(id uuid.UUID, in Input) (Listed, error) { tmpl, err := Build(in) if err != nil { - return relational.SSPTemplate{}, err + return Listed{}, err } + var existing Listed err = s.db.Transaction(func(tx *gorm.DB) error { - existing, err := get(tx.Clauses(clause.Locking{Strength: "UPDATE"}), id) + existing, err = get(tx.Clauses(clause.Locking{Strength: "UPDATE"}), id) if err != nil { return err } @@ -77,12 +88,13 @@ func (s *Service) Update(id uuid.UUID, in Input) (relational.SSPTemplate, error) } tmpl.ID = existing.ID tmpl.CreatedAt = existing.CreatedAt + tmpl.LastRunAt = existing.LastRunAt return tx.Select("*").Save(&tmpl).Error }) if err != nil { - return relational.SSPTemplate{}, err + return Listed{}, err } - return tmpl, nil + return Listed{SSPTemplate: tmpl, SSPCount: existing.SSPCount}, nil } // Delete deletes the template and its instance records. The SSPs it created and their @@ -98,13 +110,13 @@ func (s *Service) Delete(id uuid.UUID) error { return nil } -func get(db *gorm.DB, id uuid.UUID) (relational.SSPTemplate, error) { - var tmpl relational.SSPTemplate - if err := db.Where("id = ?", id).First(&tmpl).Error; err != nil { +func get(db *gorm.DB, id uuid.UUID) (Listed, error) { + var tmpl Listed + if err := db.Model(&relational.SSPTemplate{}).Select(withSSPCount).Where("ssp_templates.id = ?", id).Take(&tmpl).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return relational.SSPTemplate{}, ErrNotFound + return Listed{}, ErrNotFound } - return relational.SSPTemplate{}, err + return Listed{}, err } return tmpl, nil } diff --git a/internal/service/relational/ssptemplates/validate.go b/internal/service/relational/ssptemplates/validate.go index 3ccdf3b7..296d5ec6 100644 --- a/internal/service/relational/ssptemplates/validate.go +++ b/internal/service/relational/ssptemplates/validate.go @@ -1,6 +1,5 @@ // Package ssptemplates stores SSP templates: rules that create one SSP per distinct -// combination of key label values in the latest evidence and keep each SSP's scope up to -// date. +// combination of key label values in the latest evidence and keep its scope up to date. package ssptemplates import ( diff --git a/internal/service/relational/ssptemplates/validate_test.go b/internal/service/relational/ssptemplates/validate_test.go index 502e966e..9ce144d8 100644 --- a/internal/service/relational/ssptemplates/validate_test.go +++ b/internal/service/relational/ssptemplates/validate_test.go @@ -1,7 +1,6 @@ package ssptemplates import ( - "strings" "testing" "github.com/compliance-framework/api/internal/converters/labelfilter" @@ -71,12 +70,9 @@ func TestBuildRejects(t *testing.T) { "too many keys": {func(in *Input) { in.Keys = []string{"a", "b", "c", "d", "e", "f"} }, "keys: between 1 and 5 labels"}, "empty key": {func(in *Input) { in.Keys = []string{"_app", " "} }, "keys: label keys and values must not be empty"}, "duplicate key": {func(in *Input) { in.Keys = []string{"_app", "_APP"} }, "keys: duplicate key"}, - "key with =": {func(in *Input) { in.Keys = []string{"a=b"} }, "must not contain '='"}, - "long key": {func(in *Input) { in.Keys = []string{strings.Repeat("k", 257)} }, "at most 256 characters"}, "too many groups": {func(in *Input) { in.ScopeLabels = make([][]string, 11) }, "at most 10 scope-label groups"}, "empty group": {func(in *Input) { in.ScopeLabels = [][]string{{}} }, "scope-labels[0]: between 1 and 5 labels"}, "too many group items": {func(in *Input) { in.ScopeLabels = [][]string{{"a", "b", "c", "d", "e", "f"}} }, "scope-labels[0]: between 1 and 5 labels"}, - "empty label": {func(in *Input) { in.ScopeLabels = [][]string{{"repo", ""}} }, "scope-labels[0]: label keys"}, "repeated group": {func(in *Input) { in.ScopeLabels = [][]string{{"a", "b"}, {"B", "a"}} }, "scope-labels[1] repeats"}, "min-streams 0": {func(in *Input) { in.MinStreams = new(int) }, "min-streams must be at least 1"}, "no title": {func(in *Input) { in.TitleTemplate = " " }, "title-template is required"}, diff --git a/internal/tests/migrate.go b/internal/tests/migrate.go index c48e5551..67b71872 100644 --- a/internal/tests/migrate.go +++ b/internal/tests/migrate.go @@ -220,10 +220,7 @@ func (t *TestMigrator) Up() error { if err := riskrel.EnsureIndexes(t.db); err != nil { return err } - if err := relational.EnsureSSPScopeConstraints(t.db); err != nil { - return err - } - if err := relational.EnsureSSPTemplateConstraints(t.db); err != nil { + if err := relational.EnsureSSPConstraints(t.db); err != nil { return err } From 3c2a199661a013987c3bcfd5fb0ce241c1a7a286 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:21:13 -0300 Subject: [PATCH 5/5] fix(ssp-templates): store disabled templates as disabled The enabled column's default made gorm write true over a false Enabled on create. Drop the column default; Build always sets Enabled. Co-Authored-By: Claude Opus 5.5 --- .../handler/templates/ssp_template_integration_test.go | 9 +++++---- internal/service/relational/ssp_templates.go | 2 +- internal/service/relational/ssptemplates/service.go | 3 +-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/api/handler/templates/ssp_template_integration_test.go b/internal/api/handler/templates/ssp_template_integration_test.go index a16ba53b..fd91711d 100644 --- a/internal/api/handler/templates/ssp_template_integration_test.go +++ b/internal/api/handler/templates/ssp_template_integration_test.go @@ -108,10 +108,11 @@ func (s *SSPTemplateApiIntegrationSuite) TestAdminManagesTemplates() { body["filter"] = map[string]any{"scope": map[string]any{"condition": map[string]any{"label": "auto_create_ssp", "operator": "=", "value": "true"}}} body["props"] = []map[string]string{{"name": "application", "value": "{{ ._app }}"}} body["profile-ids"] = []uuid.UUID{profileID} + body["enabled"] = false created := s.create(body) s.Equal([]any{"_app"}, created["keys"]) s.Equal([]any{[]any{"repo"}, []any{"aws_account", "region"}}, created["scope-labels"]) - s.Equal(true, created["enabled"]) + s.Equal(false, created["enabled"]) s.Equal(float64(1), created["min-streams"]) s.Equal([]any{profileID.String()}, created["profile-ids"]) s.Contains(created, "created-at") @@ -124,14 +125,15 @@ func (s *SSPTemplateApiIntegrationSuite) TestAdminManagesTemplates() { list := s.decode(s.do(s.admin, http.MethodGet, "", nil), http.StatusOK) s.Equal(float64(1), list["total"]) s.Equal(id, list["data"].([]any)[0].(map[string]any)["id"]) + s.Equal(false, list["data"].([]any)[0].(map[string]any)["enabled"], "a disabled template is stored disabled") update := templateBody("Renamed", "_app") - update["enabled"] = false + update["enabled"] = true update["min-streams"] = 3 s.decode(s.do(s.admin, http.MethodPut, "/"+id, update), http.StatusOK) got := s.decode(s.do(s.admin, http.MethodGet, "/"+id, nil), http.StatusOK)["data"].(map[string]any) s.Equal("Renamed", got["name"]) - s.Equal(false, got["enabled"]) + s.Equal(true, got["enabled"]) s.Equal(float64(3), got["min-streams"]) s.Nil(got["filter"]) s.Equal([]any{}, got["profile-ids"]) @@ -177,7 +179,6 @@ func (s *SSPTemplateApiIntegrationSuite) TestErrors() { }{ {"unparseable title", http.MethodPost, "", badTitle, http.StatusBadRequest}, {"unknown profile", http.MethodPost, "", unknownProfile, http.StatusBadRequest}, - {"no keys", http.MethodPost, "", templateBody("Apps"), http.StatusBadRequest}, {"bad id", http.MethodGet, "/nope", nil, http.StatusBadRequest}, {"unknown get", http.MethodGet, unknown, nil, http.StatusNotFound}, {"unknown update", http.MethodPut, unknown, templateBody("Apps", "_app"), http.StatusNotFound}, diff --git a/internal/service/relational/ssp_templates.go b/internal/service/relational/ssp_templates.go index e7ee19fd..0a0b928f 100644 --- a/internal/service/relational/ssp_templates.go +++ b/internal/service/relational/ssp_templates.go @@ -19,7 +19,7 @@ type SSPTemplateProp struct { type SSPTemplate struct { UUIDModel Name string `gorm:"not null"` - Enabled bool `gorm:"not null;default:true"` + Enabled bool `gorm:"not null"` // no column default: gorm would write it over a false Enabled // Keys identify one SSP by their values: sorted, lower-cased, immutable. Keys datatypes.JSONSlice[string] `gorm:"type:jsonb;not null"` // Filter, when set, limits discovery to the evidence it matches. diff --git a/internal/service/relational/ssptemplates/service.go b/internal/service/relational/ssptemplates/service.go index 6a12ca60..ffd153f4 100644 --- a/internal/service/relational/ssptemplates/service.go +++ b/internal/service/relational/ssptemplates/service.go @@ -60,8 +60,7 @@ func (s *Service) Create(in Input) (Listed, error) { if err := checkProfiles(s.db, tmpl.ProfileIDs); err != nil { return Listed{}, err } - // Select("*") so a false Enabled is written rather than replaced by the column default. - if err := s.db.Select("*").Create(&tmpl).Error; err != nil { + if err := s.db.Create(&tmpl).Error; err != nil { return Listed{}, err } return Listed{SSPTemplate: tmpl}, nil