diff --git a/cmd/run.go b/cmd/run.go index 6c2caa19..9041ea17 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -193,6 +193,7 @@ func RunServer(cmd *cobra.Command, args []string) { NotificationWorkerEnqueuer: workerService, DAGExecutor: workerService.GetDAGExecutor(), EvidenceBatchQueue: workerService, + SSPTemplateRunEnqueuer: workerService, PEP: authzPEP, } diff --git a/docs/docs.go b/docs/docs.go index 31b07d8f..6b0eb173 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -2868,6 +2868,187 @@ const docTemplate = `{ ] } }, + "/admin/ssp-templates/{id}/run": { + "post": { + "description": "Queue a discovery run of the template now. A run already queued for it is not queued twice.", + "tags": [ + "SSP Templates" + ], + "summary": "Run SSP template discovery", + "parameters": [ + { + "type": "string", + "description": "SSP template ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "409": { + "description": "the template is disabled", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "503": { + "description": "background jobs are not available", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/ssp-templates/{id}/ssps": { + "get": { + "description": "List the template's instances, newest first: the SSP (null once deleted), when it was last seen in evidence, whether it is stale, and the last run's error and skipped entries.", + "produces": [ + "application/json" + ], + "tags": [ + "SSP Templates" + ], + "summary": "List the SSPs an SSP template created", + "parameters": [ + { + "type": "string", + "description": "SSP template ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/service.ListResponse-templates_sspTemplateInstanceResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/ssp-templates/{id}/ssps/{keyHash}": { + "delete": { + "description": "Delete the record of an SSP the template created and a user then deleted, so the next discovery run creates it again. Only allowed once the SSP was deleted (409 otherwise).", + "tags": [ + "SSP Templates" + ], + "summary": "Forget a deleted SSP of an SSP template", + "parameters": [ + { + "type": "string", + "description": "SSP template ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Instance key hash", + "name": "keyHash", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "409": { + "description": "Conflict", + "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.", @@ -52242,6 +52423,20 @@ const docTemplate = `{ } } }, + "relational.SkippedScopeEntry": { + "type": "object", + "properties": { + "labels": { + "type": "array", + "items": { + "$ref": "#/definitions/relational.ScopeLabel" + } + }, + "reason": { + "type": "string" + } + } + }, "relational.Statement": { "type": "object", "properties": { @@ -53249,6 +53444,29 @@ const docTemplate = `{ } } }, + "service.ListResponse-templates_sspTemplateInstanceResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/templates.sspTemplateInstanceResponse" + } + }, + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, "service.ListResponse-templates_sspTemplateResponse": { "type": "object", "properties": { @@ -54110,6 +54328,48 @@ const docTemplate = `{ } } }, + "templates.sspTemplateInstanceResponse": { + "type": "object", + "properties": { + "created-at": { + "type": "string" + }, + "key-hash": { + "description": "the id the forget route takes", + "type": "string" + }, + "key-values": { + "type": "object", + "additionalProperties": {} + }, + "last-error": { + "type": "string" + }, + "last-seen-at": { + "type": "string" + }, + "skipped-entries": { + "description": "learned values rejected as invalid on the last run", + "type": "integer" + }, + "skipped-sample": { + "type": "array", + "items": { + "$ref": "#/definitions/relational.SkippedScopeEntry" + } + }, + "ssp-id": { + "description": "null once the SSP was deleted", + "type": "string" + }, + "stale": { + "type": "boolean" + }, + "title": { + "type": "string" + } + } + }, "templates.sspTemplatePreviewRequest": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 7e5b1b2b..9b005b7f 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2862,6 +2862,187 @@ ] } }, + "/admin/ssp-templates/{id}/run": { + "post": { + "description": "Queue a discovery run of the template now. A run already queued for it is not queued twice.", + "tags": [ + "SSP Templates" + ], + "summary": "Run SSP template discovery", + "parameters": [ + { + "type": "string", + "description": "SSP template ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "409": { + "description": "the template is disabled", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "503": { + "description": "background jobs are not available", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/ssp-templates/{id}/ssps": { + "get": { + "description": "List the template's instances, newest first: the SSP (null once deleted), when it was last seen in evidence, whether it is stale, and the last run's error and skipped entries.", + "produces": [ + "application/json" + ], + "tags": [ + "SSP Templates" + ], + "summary": "List the SSPs an SSP template created", + "parameters": [ + { + "type": "string", + "description": "SSP template ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/service.ListResponse-templates_sspTemplateInstanceResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/ssp-templates/{id}/ssps/{keyHash}": { + "delete": { + "description": "Delete the record of an SSP the template created and a user then deleted, so the next discovery run creates it again. Only allowed once the SSP was deleted (409 otherwise).", + "tags": [ + "SSP Templates" + ], + "summary": "Forget a deleted SSP of an SSP template", + "parameters": [ + { + "type": "string", + "description": "SSP template ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Instance key hash", + "name": "keyHash", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "409": { + "description": "Conflict", + "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.", @@ -52236,6 +52417,20 @@ } } }, + "relational.SkippedScopeEntry": { + "type": "object", + "properties": { + "labels": { + "type": "array", + "items": { + "$ref": "#/definitions/relational.ScopeLabel" + } + }, + "reason": { + "type": "string" + } + } + }, "relational.Statement": { "type": "object", "properties": { @@ -53243,6 +53438,29 @@ } } }, + "service.ListResponse-templates_sspTemplateInstanceResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/templates.sspTemplateInstanceResponse" + } + }, + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, "service.ListResponse-templates_sspTemplateResponse": { "type": "object", "properties": { @@ -54104,6 +54322,48 @@ } } }, + "templates.sspTemplateInstanceResponse": { + "type": "object", + "properties": { + "created-at": { + "type": "string" + }, + "key-hash": { + "description": "the id the forget route takes", + "type": "string" + }, + "key-values": { + "type": "object", + "additionalProperties": {} + }, + "last-error": { + "type": "string" + }, + "last-seen-at": { + "type": "string" + }, + "skipped-entries": { + "description": "learned values rejected as invalid on the last run", + "type": "integer" + }, + "skipped-sample": { + "type": "array", + "items": { + "$ref": "#/definitions/relational.SkippedScopeEntry" + } + }, + "ssp-id": { + "description": "null once the SSP was deleted", + "type": "string" + }, + "stale": { + "type": "boolean" + }, + "title": { + "type": "string" + } + } + }, "templates.sspTemplatePreviewRequest": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6ce74b9c..98ea21a3 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -10744,6 +10744,15 @@ definitions: type: string type: array type: object + relational.SkippedScopeEntry: + properties: + labels: + items: + $ref: '#/definitions/relational.ScopeLabel' + type: array + reason: + type: string + type: object relational.Statement: properties: by-components: @@ -11418,6 +11427,21 @@ definitions: totalPages: type: integer type: object + service.ListResponse-templates_sspTemplateInstanceResponse: + properties: + data: + items: + $ref: '#/definitions/templates.sspTemplateInstanceResponse' + type: array + limit: + type: integer + page: + type: integer + total: + type: integer + totalPages: + type: integer + type: object service.ListResponse-templates_sspTemplateResponse: properties: data: @@ -12011,6 +12035,35 @@ definitions: data: $ref: '#/definitions/templates.sspTemplateResponse' type: object + templates.sspTemplateInstanceResponse: + properties: + created-at: + type: string + key-hash: + description: the id the forget route takes + type: string + key-values: + additionalProperties: {} + type: object + last-error: + type: string + last-seen-at: + type: string + skipped-entries: + description: learned values rejected as invalid on the last run + type: integer + skipped-sample: + items: + $ref: '#/definitions/relational.SkippedScopeEntry' + type: array + ssp-id: + description: null once the SSP was deleted + type: string + stale: + type: boolean + title: + type: string + type: object templates.sspTemplatePreviewRequest: properties: description-template: @@ -15156,6 +15209,127 @@ paths: summary: Update SSP template tags: - SSP Templates + /admin/ssp-templates/{id}/run: + post: + description: Queue a discovery run of the template now. A run already queued + for it is not queued twice. + parameters: + - description: SSP template ID + in: path + name: id + required: true + type: string + responses: + "202": + description: Accepted + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "409": + description: the template is disabled + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + "503": + description: background jobs are not available + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Run SSP template discovery + tags: + - SSP Templates + /admin/ssp-templates/{id}/ssps: + get: + description: 'List the template''s instances, newest first: the SSP (null once + deleted), when it was last seen in evidence, whether it is stale, and the + last run''s error and skipped entries.' + parameters: + - description: SSP template ID + in: path + name: id + required: true + type: string + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/service.ListResponse-templates_sspTemplateInstanceResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List the SSPs an SSP template created + tags: + - SSP Templates + /admin/ssp-templates/{id}/ssps/{keyHash}: + delete: + description: Delete the record of an SSP the template created and a user then + deleted, so the next discovery run creates it again. Only allowed once the + SSP was deleted (409 otherwise). + parameters: + - description: SSP template ID + in: path + name: id + required: true + type: string + - description: Instance key hash + in: path + name: keyHash + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "409": + description: Conflict + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Forget a deleted SSP of an SSP template + tags: + - SSP Templates /admin/ssp-templates/preview: post: consumes: diff --git a/internal/api/handler/api.go b/internal/api/handler/api.go index d96eaf00..8223a629 100644 --- a/internal/api/handler/api.go +++ b/internal/api/handler/api.go @@ -37,6 +37,9 @@ type APIServices struct { // EvidenceBatchQueue processes POST /evidence/batches in the background (the worker // service). When nil, batch requests answer 503. EvidenceBatchQueue EvidenceBatchQueue + // SSPTemplateRunEnqueuer queues manual SSP template discovery runs (the worker + // service). When nil, the run route answers 503. + SSPTemplateRunEnqueuer templatehandlers.SSPTemplateRunEnqueuer // PEP is the shared, config-selected Policy Enforcement Point used by every guarded // route. cmd/run.go builds it once (around the configured PDP) and passes it in; when // nil (e.g. test suites) RegisterHandlers falls back to a builtin-backed PEP, which @@ -245,7 +248,7 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB 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) + templatehandlers.NewSSPTemplateHandler(logger, db, services.SSPTemplateRunEnqueuer, config.SSPTemplate).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 index 1a6e4e0c..5f2907f5 100644 --- a/internal/api/handler/templates/ssp_template.go +++ b/internal/api/handler/templates/ssp_template.go @@ -1,11 +1,13 @@ package templates import ( + "context" "errors" "net/http" "time" "github.com/compliance-framework/api/internal/api" + "github.com/compliance-framework/api/internal/config" "github.com/compliance-framework/api/internal/converters/labelfilter" svc "github.com/compliance-framework/api/internal/service" "github.com/compliance-framework/api/internal/service/relational" @@ -16,16 +18,32 @@ import ( "gorm.io/gorm" ) +// SSPTemplateRunEnqueuer queues a discovery run of one SSP template (the worker +// service). +type SSPTemplateRunEnqueuer interface { + EnqueueSSPTemplateDiscovery(ctx context.Context, templateID uuid.UUID) error +} + // SSPTemplateHandler serves /admin/ssp-templates. type SSPTemplateHandler struct { service *ssptemplates.Service + enqueuer SSPTemplateRunEnqueuer + staleAfter time.Duration sugar *zap.SugaredLogger pagination *svc.PaginationConfig } -func NewSSPTemplateHandler(sugar *zap.SugaredLogger, db *gorm.DB) *SSPTemplateHandler { +// NewSSPTemplateHandler builds the handler. A nil enqueuer makes the run route answer +// 503; a nil cfg means the default stale-after. +func NewSSPTemplateHandler(sugar *zap.SugaredLogger, db *gorm.DB, enqueuer SSPTemplateRunEnqueuer, cfg *config.SSPTemplateConfig) *SSPTemplateHandler { + staleAfter := config.DefaultSSPTemplateStaleAfter + if cfg != nil { + staleAfter = cfg.StaleAfter + } return &SSPTemplateHandler{ service: ssptemplates.NewService(db), + enqueuer: enqueuer, + staleAfter: staleAfter, sugar: sugar, pagination: svc.NewPaginationConfig(), } @@ -39,6 +57,9 @@ func (h *SSPTemplateHandler) Register(group *echo.Group) { group.GET("/:id", h.Get) group.PUT("/:id", h.Update) group.DELETE("/:id", h.Delete) + group.POST("/:id/run", h.Run) + group.GET("/:id/ssps", h.ListSSPs) + group.DELETE("/:id/ssps/:keyHash", h.Forget) } // sspTemplateRequest is ssptemplates.Input with JSON tags; it converts to it directly. @@ -273,10 +294,12 @@ func (h *SSPTemplateHandler) Preview(ctx echo.Context) error { func (h *SSPTemplateHandler) serviceError(ctx echo.Context, message string, err error) error { switch { - case errors.Is(err, ssptemplates.ErrNotFound): + case errors.Is(err, ssptemplates.ErrNotFound), errors.Is(err, ssptemplates.ErrInstanceNotFound): return ctx.JSON(http.StatusNotFound, api.NotFound()) case errors.Is(err, ssptemplates.ErrInvalidTemplate): return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + case errors.Is(err, ssptemplates.ErrTemplateDisabled), errors.Is(err, ssptemplates.ErrSSPExists): + return ctx.JSON(http.StatusConflict, api.NewError(err)) default: return templateInternalServerError(ctx, h.sugar, message, err) } diff --git a/internal/api/handler/templates/ssp_template_instances.go b/internal/api/handler/templates/ssp_template_instances.go new file mode 100644 index 00000000..d0e42ac6 --- /dev/null +++ b/internal/api/handler/templates/ssp_template_instances.go @@ -0,0 +1,129 @@ +package templates + +import ( + "fmt" + "net/http" + "time" + + "github.com/compliance-framework/api/internal/api" + svc "github.com/compliance-framework/api/internal/service" + "github.com/compliance-framework/api/internal/service/relational" + "github.com/google/uuid" + "github.com/labstack/echo/v4" +) + +type sspTemplateInstanceResponse struct { + KeyValues map[string]any `json:"key-values"` + KeyHash string `json:"key-hash"` // the id the forget route takes + SSPID *uuid.UUID `json:"ssp-id"` // null once the SSP was deleted + Title *string `json:"title"` + CreatedAt time.Time `json:"created-at"` + LastSeenAt time.Time `json:"last-seen-at"` + Stale bool `json:"stale"` + LastError *string `json:"last-error"` + SkippedEntries int `json:"skipped-entries"` // learned values rejected as invalid on the last run + SkippedSample []relational.SkippedScopeEntry `json:"skipped-sample"` +} + +// Run godoc +// +// @Summary Run SSP template discovery +// @Description Queue a discovery run of the template now. A run already queued for it is not queued twice. +// @Tags SSP Templates +// @Param id path string true "SSP template ID" +// @Success 202 +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 409 {object} api.Error "the template is disabled" +// @Failure 500 {object} api.Error +// @Failure 503 {object} api.Error "background jobs are not available" +// @Security OAuth2Password +// @Router /admin/ssp-templates/{id}/run [post] +func (h *SSPTemplateHandler) Run(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.CheckRunnable(id); err != nil { + return h.serviceError(ctx, "failed to load SSP template", err) + } + if h.enqueuer == nil { + return ctx.JSON(http.StatusServiceUnavailable, api.NewError(fmt.Errorf("background jobs are not available"))) + } + if err := h.enqueuer.EnqueueSSPTemplateDiscovery(ctx.Request().Context(), id); err != nil { + return templateInternalServerError(ctx, h.sugar, "failed to enqueue SSP template discovery", err) + } + return ctx.NoContent(http.StatusAccepted) +} + +// ListSSPs godoc +// +// @Summary List the SSPs an SSP template created +// @Description List the template's instances, newest first: the SSP (null once deleted), when it was last seen in evidence, whether it is stale, and the last run's error and skipped entries. +// @Tags SSP Templates +// @Produce json +// @Param id path string true "SSP template ID" +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} svc.ListResponse[sspTemplateInstanceResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/ssp-templates/{id}/ssps [get] +func (h *SSPTemplateHandler) ListSSPs(ctx echo.Context) error { + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + pagination, err := h.pagination.ParseParams(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + instances, total, err := h.service.Instances(id, pagination.Limit, pagination.Offset) + if err != nil { + return h.serviceError(ctx, "failed to list SSP template instances", err) + } + staleBefore := time.Now().Add(-h.staleAfter) + resp := make([]sspTemplateInstanceResponse, 0, len(instances)) + for _, instance := range instances { + resp = append(resp, sspTemplateInstanceResponse{ + KeyValues: instance.KeyValues, + KeyHash: instance.KeyHash, + SSPID: instance.SSPID, + Title: instance.Title, + CreatedAt: instance.CreatedAt, + LastSeenAt: instance.LastSeenAt, + Stale: instance.SSPID != nil && instance.LastSeenAt.Before(staleBefore), + LastError: instance.LastError, + SkippedEntries: instance.SkippedEntries, + SkippedSample: append([]relational.SkippedScopeEntry{}, instance.SkippedSample...), + }) + } + return ctx.JSON(http.StatusOK, svc.NewListResponse(resp, total, pagination.Page, pagination.Limit)) +} + +// Forget godoc +// +// @Summary Forget a deleted SSP of an SSP template +// @Description Delete the record of an SSP the template created and a user then deleted, so the next discovery run creates it again. Only allowed once the SSP was deleted (409 otherwise). +// @Tags SSP Templates +// @Param id path string true "SSP template ID" +// @Param keyHash path string true "Instance key hash" +// @Success 204 +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 409 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/ssp-templates/{id}/ssps/{keyHash} [delete] +func (h *SSPTemplateHandler) Forget(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.Forget(id, ctx.Param("keyHash")); err != nil { + return h.serviceError(ctx, "failed to forget SSP template instance", err) + } + return ctx.NoContent(http.StatusNoContent) +} diff --git a/internal/api/handler/templates/ssp_template_instances_integration_test.go b/internal/api/handler/templates/ssp_template_instances_integration_test.go new file mode 100644 index 00000000..eceacaf6 --- /dev/null +++ b/internal/api/handler/templates/ssp_template_instances_integration_test.go @@ -0,0 +1,84 @@ +//go:build integration + +package templates_test + +import ( + "context" + "net/http" + "time" + + "github.com/compliance-framework/api/internal/service/relational" + "github.com/google/uuid" + "gorm.io/datatypes" +) + +// recordingEnqueuer records the templates whose discovery run was queued. +type recordingEnqueuer struct{ queued []uuid.UUID } + +func (e *recordingEnqueuer) EnqueueSSPTemplateDiscovery(_ context.Context, templateID uuid.UUID) error { + e.queued = append(e.queued, templateID) + return nil +} + +func (s *SSPTemplateApiIntegrationSuite) TestRun() { + id := s.create(templateBody("Applications", "_app"))["id"].(string) + disabled := templateBody("Disabled", "_app") + disabled["enabled"] = false + disabledID := s.create(disabled)["id"].(string) + + s.Equal(http.StatusAccepted, s.do(s.admin, http.MethodPost, "/"+id+"/run", nil).Code) + s.Equal([]uuid.UUID{uuid.MustParse(id)}, s.runs.queued) + s.Equal(http.StatusConflict, s.do(s.admin, http.MethodPost, "/"+disabledID+"/run", nil).Code) + s.Equal(http.StatusNotFound, s.do(s.admin, http.MethodPost, "/"+uuid.NewString()+"/run", nil).Code) + s.Equal(http.StatusForbidden, s.do(s.contributor, http.MethodPost, "/"+id+"/run", nil).Code) + s.Len(s.runs.queued, 1) + + s.server = s.newServer(nil) + s.Equal(http.StatusServiceUnavailable, s.do(s.admin, http.MethodPost, "/"+id+"/run", nil).Code, "no worker") +} + +func (s *SSPTemplateApiIntegrationSuite) TestInstancesAndForget() { + templateID := uuid.MustParse(s.create(templateBody("Applications", "_app"))["id"].(string)) + sspID := uuid.New() + s.Require().NoError(s.DB.Create(&relational.SystemSecurityPlan{ + UUIDModel: relational.UUIDModel{ID: &sspID}, Metadata: relational.Metadata{Title: "SSP for payments"}, + }).Error) + lastError := "attach profile x: profile not found" + for _, instance := range []relational.SSPTemplateInstance{ + {KeyHash: "live", KeyValues: datatypes.JSONMap{"_app": "payments"}, SSPID: &sspID, LastSeenAt: time.Now(), LastError: &lastError, SkippedEntries: 2, + SkippedSample: []relational.SkippedScopeEntry{{Labels: []relational.ScopeLabel{{Key: "repo", Value: "x"}}, Reason: "too long"}}}, + {KeyHash: "stale", KeyValues: datatypes.JSONMap{"_app": "old"}, SSPID: &sspID, LastSeenAt: time.Now().Add(-8 * 24 * time.Hour)}, + {KeyHash: "deleted", KeyValues: datatypes.JSONMap{"_app": "gone"}, LastSeenAt: time.Now().Add(-30 * 24 * time.Hour)}, + } { + instance.TemplateID = templateID + s.Require().NoError(s.DB.Create(&instance).Error) + } + + path := "/" + templateID.String() + "/ssps" + list := s.decode(s.do(s.admin, http.MethodGet, path, nil), http.StatusOK) + s.Equal(float64(3), list["total"]) + byHash := map[string]map[string]any{} + for _, item := range list["data"].([]any) { + byHash[item.(map[string]any)["key-hash"].(string)] = item.(map[string]any) + } + live := byHash["live"] + s.Equal(map[string]any{"_app": "payments"}, live["key-values"]) + s.Equal(sspID.String(), live["ssp-id"]) + s.Equal("SSP for payments", live["title"]) + s.Equal(false, live["stale"]) + s.Equal(lastError, live["last-error"]) + s.Equal(float64(2), live["skipped-entries"]) + s.Len(live["skipped-sample"], 1) + s.Equal(true, byHash["stale"]["stale"]) + s.Nil(byHash["deleted"]["ssp-id"]) + s.Nil(byHash["deleted"]["title"]) + s.Equal(false, byHash["deleted"]["stale"], "a deleted SSP is not stale") + + s.Equal(http.StatusConflict, s.do(s.admin, http.MethodDelete, path+"/live", nil).Code, "the SSP still exists") + s.Equal(http.StatusNotFound, s.do(s.admin, http.MethodDelete, path+"/nope", nil).Code) + s.Equal(http.StatusForbidden, s.do(s.contributor, http.MethodDelete, path+"/deleted", nil).Code) + s.Equal(http.StatusForbidden, s.do(s.contributor, http.MethodGet, path, nil).Code) + s.Equal(http.StatusNoContent, s.do(s.admin, http.MethodDelete, path+"/deleted", nil).Code) + s.Equal(float64(2), s.decode(s.do(s.admin, http.MethodGet, path, nil), http.StatusOK)["total"]) + s.Equal(http.StatusNotFound, s.do(s.admin, http.MethodGet, "/"+uuid.NewString()+"/ssps", nil).Code) +} diff --git a/internal/api/handler/templates/ssp_template_integration_test.go b/internal/api/handler/templates/ssp_template_integration_test.go index 58df4e8a..38221f12 100644 --- a/internal/api/handler/templates/ssp_template_integration_test.go +++ b/internal/api/handler/templates/ssp_template_integration_test.go @@ -13,6 +13,7 @@ import ( "github.com/compliance-framework/api/internal/api" handlerpkg "github.com/compliance-framework/api/internal/api/handler" + templatehandlers "github.com/compliance-framework/api/internal/api/handler/templates" "github.com/compliance-framework/api/internal/api/middleware" "github.com/compliance-framework/api/internal/authn" "github.com/compliance-framework/api/internal/authz" @@ -33,6 +34,7 @@ type SSPTemplateApiIntegrationSuite struct { server *api.Server admin string contributor string + runs *recordingEnqueuer } func TestSSPTemplateApi(t *testing.T) { @@ -43,14 +45,22 @@ 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") + s.runs = &recordingEnqueuer{} + s.server = s.newServer(s.runs) +} +// newServer serves the API through the embedded Cedar PDP; runs may be nil. +func (s *SSPTemplateApiIntegrationSuite) newServer(runs templatehandlers.SSPTemplateRunEnqueuer) *api.Server { 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), - }) + server := api.NewServer(context.Background(), logger, s.Config, api.NewMetricsHandler(context.Background(), logger)) + services := &handlerpkg.APIServices{PEP: middleware.NewPEP(pdp, authz.FailClosed, logger)} + if runs != nil { + services.SSPTemplateRunEnqueuer = runs + } + handlerpkg.RegisterHandlers(server, logger, s.DB, s.Config, services) + return server } func (s *SSPTemplateApiIntegrationSuite) roledUser(email, role string) string { diff --git a/internal/service/relational/ssptemplates/instances.go b/internal/service/relational/ssptemplates/instances.go new file mode 100644 index 00000000..bf9f29f8 --- /dev/null +++ b/internal/service/relational/ssptemplates/instances.go @@ -0,0 +1,101 @@ +package ssptemplates + +import ( + "errors" + + "github.com/compliance-framework/api/internal/service/relational" + "github.com/google/uuid" + "gorm.io/gorm" +) + +var ( + // ErrTemplateDisabled is returned when a disabled template is asked to run. + ErrTemplateDisabled = errors.New("SSP template is disabled") + // ErrInstanceNotFound is returned when the template has no instance with the key hash. + ErrInstanceNotFound = errors.New("SSP template instance not found") + // ErrSSPExists is returned when forgetting an instance whose SSP still exists. + ErrSSPExists = errors.New("the SSP still exists: only an instance whose SSP was deleted can be forgotten") +) + +// Instance is an SSP a template created, with the SSP's current title (nil once the SSP +// was deleted). +type Instance struct { + relational.SSPTemplateInstance + Title *string +} + +// CheckRunnable returns ErrNotFound when the template does not exist and +// ErrTemplateDisabled when it is disabled. +func (s *Service) CheckRunnable(id uuid.UUID) error { + tmpl, err := get(s.db, id) + if err != nil { + return err + } + if !tmpl.Enabled { + return ErrTemplateDisabled + } + return nil +} + +// Instances returns a page of the template's instances, newest first, and their total. +func (s *Service) Instances(templateID uuid.UUID, limit, offset int) ([]Instance, int64, error) { + if _, err := get(s.db, templateID); err != nil { + return nil, 0, err + } + query := s.db.Model(&relational.SSPTemplateInstance{}).Where("template_id = ?", templateID) + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []relational.SSPTemplateInstance + if err := query.Order("created_at DESC, key_hash ASC").Limit(limit).Offset(offset).Find(&rows).Error; err != nil { + return nil, 0, err + } + sspIDs := []uuid.UUID{} + for _, row := range rows { + if row.SSPID != nil { + sspIDs = append(sspIDs, *row.SSPID) + } + } + titles := map[uuid.UUID]string{} + if len(sspIDs) > 0 { + var ssps []relational.SystemSecurityPlan + if err := s.db.Preload("Metadata").Where("id IN ?", sspIDs).Find(&ssps).Error; err != nil { + return nil, 0, err + } + for _, ssp := range ssps { + titles[*ssp.ID] = ssp.Metadata.Title + } + } + instances := make([]Instance, 0, len(rows)) + for _, row := range rows { + instance := Instance{SSPTemplateInstance: row} + if row.SSPID != nil { + if title, ok := titles[*row.SSPID]; ok { + instance.Title = &title + } + } + instances = append(instances, instance) + } + return instances, total, nil +} + +// Forget deletes the instance with keyHash, so discovery creates its SSP again. Only an +// instance whose SSP was deleted can be forgotten (ErrSSPExists otherwise). +func (s *Service) Forget(templateID uuid.UUID, keyHash string) error { + return s.db.Transaction(func(tx *gorm.DB) error { + var instance relational.SSPTemplateInstance + found := tx.Where("template_id = ? AND key_hash = ?", templateID, keyHash).Limit(1).Find(&instance) + if found.Error != nil { + return found.Error + } + if found.RowsAffected == 0 { + return ErrInstanceNotFound + } + if instance.SSPID != nil { + return ErrSSPExists + } + return tx.Where("template_id = ? AND key_hash = ? AND ssp_id IS NULL", templateID, keyHash). + Delete(&relational.SSPTemplateInstance{}).Error + }) +} diff --git a/internal/service/worker/ssp_template_discovery.go b/internal/service/worker/ssp_template_discovery.go index 6e21f397..be2f5748 100644 --- a/internal/service/worker/ssp_template_discovery.go +++ b/internal/service/worker/ssp_template_discovery.go @@ -11,6 +11,7 @@ import ( "github.com/compliance-framework/api/internal/service/relational/ssptemplates" "github.com/google/uuid" "github.com/riverqueue/river" + "github.com/riverqueue/river/rivertype" "go.uber.org/zap" "gorm.io/gorm" ) @@ -108,3 +109,27 @@ func sspTemplateDiscoverySchedule(cfg *config.Config) string { } return cfg.SSPTemplate.DiscoverySchedule } + +// EnqueueSSPTemplateDiscovery queues a discovery run of one template. A run of that +// template already waiting or running is not queued twice; a finished one does not block +// a new run. +func (s *Service) EnqueueSSPTemplateDiscovery(ctx context.Context, templateID uuid.UUID) error { + if !s.config.Enabled || s.client == nil { + return fmt.Errorf("the worker service is disabled") + } + _, err := s.client.Insert(ctx, SSPTemplateDiscoveryArgs{TemplateID: &templateID}, &river.InsertOpts{ + Queue: "scheduler", + MaxAttempts: 3, + UniqueOpts: river.UniqueOpts{ + ByArgs: true, + ByState: []rivertype.JobState{ + rivertype.JobStateAvailable, rivertype.JobStatePending, rivertype.JobStateRunning, + rivertype.JobStateRetryable, rivertype.JobStateScheduled, + }, + }, + }) + if err != nil { + return fmt.Errorf("enqueue SSP template discovery for %s: %w", templateID, err) + } + return nil +} diff --git a/internal/service/worker/ssp_template_discovery_test.go b/internal/service/worker/ssp_template_discovery_test.go index 28b3d30f..10ac10fe 100644 --- a/internal/service/worker/ssp_template_discovery_test.go +++ b/internal/service/worker/ssp_template_discovery_test.go @@ -33,3 +33,8 @@ func TestControlIDResolverDedupesCaseInsensitively(t *testing.T) { require.NoError(t, err) require.Equal(t, []string{"AC-1", "AC-2"}, ids) } + +func TestEnqueueSSPTemplateDiscoveryNeedsTheWorker(t *testing.T) { + disabled := &Service{config: &config.WorkerConfig{Enabled: false}} + require.Error(t, disabled.EnqueueSSPTemplateDiscovery(context.Background(), uuid.New()), "a run that would never happen is not accepted") +}