diff --git a/cmd/run.go b/cmd/run.go index 2f57d2f2..10c7a10c 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -163,12 +163,13 @@ func RunServer(cmd *cobra.Command, args []string) { // Create services struct for API handlers services := &handler.APIServices{ - EvidenceService: evidenceService, - RiskEnqueuer: workerService, - DigestService: digestService, - WorkflowManager: workflowManager, - NotificationEnqueuer: workerService, - DAGExecutor: workerService.GetDAGExecutor(), + EvidenceService: evidenceService, + RiskEnqueuer: workerService, + DigestService: digestService, + WorkflowManager: workflowManager, + NotificationEnqueuer: workerService, + NotificationWorkerEnqueuer: workerService, + DAGExecutor: workerService.GetDAGExecutor(), } handler.RegisterHandlers(server, sugar, db, cfg, services) diff --git a/docs/docs.go b/docs/docs.go index e2aff90f..a69b4769 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -137,21 +137,21 @@ const docTemplate = `{ ] } }, - "/admin/notifications/providers": { + "/admin/notifications/health": { "get": { - "description": "Returns notification providers registered in the backend", + "description": "Returns provider, worker, queue, subscriber, destination, and schedule health for admin notification troubleshooting", "produces": [ "application/json" ], "tags": [ "Notifications" ], - "summary": "List available notification providers", + "summary": "Get notification troubleshooting health", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataListResponse-handler_availableNotificationProviderResponse" + "$ref": "#/definitions/handler.GenericDataResponse-notificationtroubleshooting_HealthResponse" } }, "401": { @@ -174,42 +174,90 @@ const docTemplate = `{ ] } }, - "/admin/notifications/{notificationName}/destinations": { - "post": { - "description": "Creates a new system notification destination configuration for an admin-managed notification", - "consumes": [ - "application/json" - ], + "/admin/notifications/jobs": { + "get": { + "description": "Lists recent notification-related River jobs with sanitized notification metadata", "produces": [ "application/json" ], "tags": [ "Notifications" ], - "summary": "Create system notification destination", + "summary": "List notification River jobs", "parameters": [ { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Queue filter; repeat or comma-separate values", + "name": "queue", + "in": "query" + }, + { + "enum": [ + "email", + "slack" + ], "type": "string", - "description": "Notification name", - "name": "notificationName", - "in": "path", - "required": true + "description": "Provider filter: email or slack", + "name": "provider", + "in": "query" }, { - "description": "Destination details", - "name": "destination", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handler.createSystemNotificationDestinationRequest" - } + "type": "string", + "description": "Notification kind filter", + "name": "notificationKind", + "in": "query" + }, + { + "type": "array", + "items": { + "enum": [ + "available", + "cancelled", + "completed", + "discarded", + "pending", + "retryable", + "running", + "scheduled" + ], + "type": "string" + }, + "collectionFormat": "csv", + "description": "River state filter; repeat or comma-separate values", + "name": "state", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "description": "RFC3339 lower bound for job creation time", + "name": "since", + "in": "query" + }, + { + "maximum": 200, + "minimum": 1, + "type": "integer", + "description": "Page size, default 50, max 200", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Opaque pagination cursor", + "name": "cursor", + "in": "query" } ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-handler_configuredSystemDestinationResponse" + "$ref": "#/definitions/notificationtroubleshooting.JobsListResponse" } }, "400": { @@ -224,12 +272,6 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, - "409": { - "description": "Conflict", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -242,40 +284,33 @@ const docTemplate = `{ "OAuth2Password": [] } ] - }, - "delete": { - "description": "Deletes a stored system notification destination configuration for an admin-managed notification", - "consumes": [ - "application/json" - ], + } + }, + "/admin/notifications/jobs/{id}": { + "get": { + "description": "Returns one sanitized notification-related River job with attempt errors", "produces": [ "application/json" ], "tags": [ "Notifications" ], - "summary": "Delete system notification destination", + "summary": "Get notification River job detail", "parameters": [ { - "type": "string", - "description": "Notification name", - "name": "notificationName", + "type": "integer", + "description": "River job ID", + "name": "id", "in": "path", "required": true - }, - { - "description": "Destination details", - "name": "destination", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handler.createSystemNotificationDestinationRequest" - } } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-notificationtroubleshooting_JobDetail" + } }, "400": { "description": "Bad Request", @@ -309,57 +344,25 @@ const docTemplate = `{ ] } }, - "/admin/risk-templates": { + "/admin/notifications/providers": { "get": { - "description": "List risk templates with optional filters and pagination.", + "description": "Returns notification providers registered in the backend", "produces": [ "application/json" ], "tags": [ - "Risk Templates" - ], - "summary": "List risk templates", - "parameters": [ - { - "type": "string", - "description": "Plugin ID", - "name": "plugin-id", - "in": "query" - }, - { - "type": "string", - "description": "Policy package", - "name": "policy-package", - "in": "query" - }, - { - "type": "boolean", - "description": "Active flag", - "name": "is-active", - "in": "query" - }, - { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Page size", - "name": "limit", - "in": "query" - } + "Notifications" ], + "summary": "List available notification providers", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/service.ListResponse-templates_riskTemplateResponse" + "$ref": "#/definitions/handler.GenericDataListResponse-handler_availableNotificationProviderResponse" } }, - "400": { - "description": "Bad Request", + "401": { + "description": "Unauthorized", "schema": { "$ref": "#/definitions/api.Error" } @@ -376,9 +379,11 @@ const docTemplate = `{ "OAuth2Password": [] } ] - }, + } + }, + "/admin/notifications/test": { "post": { - "description": "Create a risk template with threat references and remediation template/tasks.", + "description": "Enqueues a fixed server-side test notification to a validated admin-supplied destination", "consumes": [ "application/json" ], @@ -386,25 +391,25 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Risk Templates" + "Notifications" ], - "summary": "Create risk template", + "summary": "Enqueue fixed test notification", "parameters": [ { - "description": "Risk template payload", - "name": "template", + "description": "Test destination", + "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/templates.upsertRiskTemplateRequest" + "$ref": "#/definitions/handler.testNotificationRequest" } } ], "responses": { - "201": { - "description": "Created", + "202": { + "description": "Accepted", "schema": { - "$ref": "#/definitions/templates.riskTemplateDataResponse" + "$ref": "#/definitions/handler.GenericDataResponse-handler_testNotificationResponse" } }, "400": { @@ -413,11 +418,23 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/api.Error" } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/api.Error" + } } }, "security": [ @@ -427,30 +444,42 @@ const docTemplate = `{ ] } }, - "/admin/risk-templates/{id}": { - "get": { - "description": "Get a risk template by ID.", + "/admin/notifications/{notificationName}/destinations": { + "post": { + "description": "Creates a new system notification destination configuration for an admin-managed notification", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "Risk Templates" + "Notifications" ], - "summary": "Get risk template", + "summary": "Create system notification destination", "parameters": [ { "type": "string", - "description": "Risk Template ID", - "name": "id", + "description": "Notification name", + "name": "notificationName", "in": "path", "required": true + }, + { + "description": "Destination details", + "name": "destination", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.createSystemNotificationDestinationRequest" + } } ], "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "schema": { - "$ref": "#/definitions/templates.riskTemplateDataResponse" + "$ref": "#/definitions/handler.GenericDataResponse-handler_configuredSystemDestinationResponse" } }, "400": { @@ -459,8 +488,14 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, - "404": { - "description": "Not Found", + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "409": { + "description": "Conflict", "schema": { "$ref": "#/definitions/api.Error" } @@ -478,8 +513,8 @@ const docTemplate = `{ } ] }, - "put": { - "description": "Update a risk template and atomically replace threat refs and remediation tasks.", + "delete": { + "description": "Deletes a stored system notification destination configuration for an admin-managed notification", "consumes": [ "application/json" ], @@ -487,33 +522,30 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Risk Templates" + "Notifications" ], - "summary": "Update risk template", + "summary": "Delete system notification destination", "parameters": [ { "type": "string", - "description": "Risk Template ID", - "name": "id", + "description": "Notification name", + "name": "notificationName", "in": "path", "required": true }, { - "description": "Risk template payload", - "name": "template", + "description": "Destination details", + "name": "destination", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/templates.upsertRiskTemplateRequest" + "$ref": "#/definitions/handler.createSystemNotificationDestinationRequest" } } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/templates.riskTemplateDataResponse" - } + "204": { + "description": "No Content" }, "400": { "description": "Bad Request", @@ -521,6 +553,12 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "404": { "description": "Not Found", "schema": { @@ -539,28 +577,33 @@ const docTemplate = `{ "OAuth2Password": [] } ] - }, - "delete": { - "description": "Delete a risk template and its associated threat references and remediation data.", + } + }, + "/admin/notifications/{notificationName}/diagnostics": { + "get": { + "description": "Runs read-only diagnostics for evidence digest, workflow, risk, or POAM notifications", "produces": [ "application/json" ], "tags": [ - "Risk Templates" + "Notifications" ], - "summary": "Delete risk template", + "summary": "Get notification diagnostics", "parameters": [ { "type": "string", - "description": "Risk Template ID", - "name": "id", + "description": "Notification name or family", + "name": "notificationName", "in": "path", "required": true } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-notificationtroubleshooting_DiagnosticsResponse" + } }, "400": { "description": "Bad Request", @@ -568,9 +611,15 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, - "404": { - "description": "Not Found", - "schema": { + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { "$ref": "#/definitions/api.Error" } }, @@ -588,27 +637,33 @@ const docTemplate = `{ ] } }, - "/admin/subject-templates": { + "/admin/risk-templates": { "get": { - "description": "List subject templates with optional filters and pagination.", + "description": "List risk templates with optional filters and pagination.", "produces": [ "application/json" ], "tags": [ - "Subject Templates" + "Risk Templates" ], - "summary": "List subject templates", + "summary": "List risk templates", "parameters": [ { "type": "string", - "description": "Subject type", - "name": "type", + "description": "Plugin ID", + "name": "plugin-id", "in": "query" }, { "type": "string", - "description": "Source mode", - "name": "source-mode", + "description": "Policy package", + "name": "policy-package", + "in": "query" + }, + { + "type": "boolean", + "description": "Active flag", + "name": "is-active", "in": "query" }, { @@ -628,7 +683,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/service.ListResponse-templates_subjectTemplateResponse" + "$ref": "#/definitions/service.ListResponse-templates_riskTemplateResponse" } }, "400": { @@ -651,7 +706,7 @@ const docTemplate = `{ ] }, "post": { - "description": "Create a subject template with selector labels and label schema.", + "description": "Create a risk template with threat references and remediation template/tasks.", "consumes": [ "application/json" ], @@ -659,17 +714,17 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Subject Templates" + "Risk Templates" ], - "summary": "Create subject template", + "summary": "Create risk template", "parameters": [ { - "description": "Subject template payload", + "description": "Risk template payload", "name": "template", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/templates.upsertSubjectTemplateRequest" + "$ref": "#/definitions/templates.upsertRiskTemplateRequest" } } ], @@ -677,7 +732,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/templates.subjectTemplateDataResponse" + "$ref": "#/definitions/templates.riskTemplateDataResponse" } }, "400": { @@ -700,20 +755,20 @@ const docTemplate = `{ ] } }, - "/admin/subject-templates/{id}": { + "/admin/risk-templates/{id}": { "get": { - "description": "Get a subject template by ID.", + "description": "Get a risk template by ID.", "produces": [ "application/json" ], "tags": [ - "Subject Templates" + "Risk Templates" ], - "summary": "Get subject template", + "summary": "Get risk template", "parameters": [ { "type": "string", - "description": "Subject Template ID", + "description": "Risk Template ID", "name": "id", "in": "path", "required": true @@ -723,7 +778,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/templates.subjectTemplateDataResponse" + "$ref": "#/definitions/templates.riskTemplateDataResponse" } }, "400": { @@ -752,7 +807,7 @@ const docTemplate = `{ ] }, "put": { - "description": "Update a subject template and atomically replace selector labels and label schema.", + "description": "Update a risk template and atomically replace threat refs and remediation tasks.", "consumes": [ "application/json" ], @@ -760,24 +815,24 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Subject Templates" + "Risk Templates" ], - "summary": "Update subject template", + "summary": "Update risk template", "parameters": [ { "type": "string", - "description": "Subject Template ID", + "description": "Risk Template ID", "name": "id", "in": "path", "required": true }, { - "description": "Subject template payload", + "description": "Risk template payload", "name": "template", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/templates.upsertSubjectTemplateRequest" + "$ref": "#/definitions/templates.upsertRiskTemplateRequest" } } ], @@ -785,8 +840,55 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/templates.subjectTemplateDataResponse" + "$ref": "#/definitions/templates.riskTemplateDataResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "delete": { + "description": "Delete a risk template and its associated threat references and remediation data.", + "produces": [ + "application/json" + ], + "tags": [ + "Risk Templates" + ], + "summary": "Delete risk template", + "parameters": [ + { + "type": "string", + "description": "Risk Template ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" }, "400": { "description": "Bad Request", @@ -814,25 +916,51 @@ const docTemplate = `{ ] } }, - "/admin/users": { + "/admin/subject-templates": { "get": { - "description": "Lists all users in the system", + "description": "List subject templates with optional filters and pagination.", "produces": [ "application/json" ], "tags": [ - "Users" + "Subject Templates" + ], + "summary": "List subject templates", + "parameters": [ + { + "type": "string", + "description": "Subject type", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "Source mode", + "name": "source-mode", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } ], - "summary": "List all users", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataListResponse-relational_User" + "$ref": "#/definitions/service.ListResponse-templates_subjectTemplateResponse" } }, - "401": { - "description": "Unauthorized", + "400": { + "description": "Bad Request", "schema": { "$ref": "#/definitions/api.Error" } @@ -851,7 +979,7 @@ const docTemplate = `{ ] }, "post": { - "description": "Creates a new user in the system", + "description": "Create a subject template with selector labels and label schema.", "consumes": [ "application/json" ], @@ -859,17 +987,17 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Users" + "Subject Templates" ], - "summary": "Create a new user", + "summary": "Create subject template", "parameters": [ { - "description": "User details", - "name": "user", + "description": "Subject template payload", + "name": "template", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handler.UserHandler" + "$ref": "#/definitions/templates.upsertSubjectTemplateRequest" } } ], @@ -877,7 +1005,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-relational_User" + "$ref": "#/definitions/templates.subjectTemplateDataResponse" } }, "400": { @@ -886,18 +1014,6 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, - "409": { - "description": "Conflict", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -912,20 +1028,20 @@ const docTemplate = `{ ] } }, - "/admin/users/{id}": { + "/admin/subject-templates/{id}": { "get": { - "description": "Get user details by user ID", + "description": "Get a subject template by ID.", "produces": [ "application/json" ], "tags": [ - "Users" + "Subject Templates" ], - "summary": "Get user by ID", + "summary": "Get subject template", "parameters": [ { "type": "string", - "description": "User ID", + "description": "Subject Template ID", "name": "id", "in": "path", "required": true @@ -935,7 +1051,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-relational_User" + "$ref": "#/definitions/templates.subjectTemplateDataResponse" } }, "400": { @@ -944,12 +1060,6 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "404": { "description": "Not Found", "schema": { @@ -970,7 +1080,7 @@ const docTemplate = `{ ] }, "put": { - "description": "Updates the details of an existing user", + "description": "Update a subject template and atomically replace selector labels and label schema.", "consumes": [ "application/json" ], @@ -978,24 +1088,24 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Users" + "Subject Templates" ], - "summary": "Update user details", + "summary": "Update subject template", "parameters": [ { "type": "string", - "description": "User ID", + "description": "Subject Template ID", "name": "id", "in": "path", "required": true }, { - "description": "User details", - "name": "user", + "description": "Subject template payload", + "name": "template", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handler.UserHandler" + "$ref": "#/definitions/templates.upsertSubjectTemplateRequest" } } ], @@ -1003,7 +1113,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-relational_User" + "$ref": "#/definitions/templates.subjectTemplateDataResponse" } }, "400": { @@ -1012,12 +1122,6 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "404": { "description": "Not Found", "schema": { @@ -1036,30 +1140,23 @@ const docTemplate = `{ "OAuth2Password": [] } ] - }, - "delete": { - "description": "Deletes a user from the system", + } + }, + "/admin/users": { + "get": { + "description": "Lists all users in the system", + "produces": [ + "application/json" + ], "tags": [ "Users" ], - "summary": "Delete a user", - "parameters": [ - { - "type": "string", - "description": "User ID", - "name": "id", - "in": "path", - "required": true - } - ], + "summary": "List all users", "responses": { - "204": { - "description": "No Content" - }, - "400": { - "description": "Bad Request", + "200": { + "description": "OK", "schema": { - "$ref": "#/definitions/api.Error" + "$ref": "#/definitions/handler.GenericDataListResponse-relational_User" } }, "401": { @@ -1068,7 +1165,238 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, - "404": { + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "post": { + "description": "Creates a new user in the system", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Create a new user", + "parameters": [ + { + "description": "User details", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.UserHandler" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-relational_User" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "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/users/{id}": { + "get": { + "description": "Get user details by user ID", + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get user by ID", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-relational_User" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "put": { + "description": "Updates the details of an existing user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Update user details", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "User details", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.UserHandler" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-relational_User" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "delete": { + "description": "Deletes a user from the system", + "tags": [ + "Users" + ], + "summary": "Delete a user", + "parameters": [ + { + "type": "string", + "description": "User 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" + } + }, + "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/api.Error" @@ -27201,7 +27529,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/evidence.SignatureDetail" @@ -27214,7 +27542,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/evidence.VerificationResult" @@ -27985,7 +28313,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "type": "array", "items": { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentAssets" @@ -27997,7 +28325,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "type": "array", "items": { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentSubject" @@ -28009,7 +28337,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "type": "array", "items": { "$ref": "#/definitions/oscalTypes_1_1_3.Task" @@ -28021,7 +28349,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/auth.AuthHandler" @@ -28034,7 +28362,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/digest.EvidenceSummary" @@ -28047,7 +28375,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.CreatedEvidenceResponse" @@ -28060,7 +28388,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.FilterImportResponse" @@ -28073,7 +28401,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.FilterWithAssociations" @@ -28086,7 +28414,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.PublicEvidenceResponse" @@ -28099,7 +28427,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.SubscriptionsResponse" @@ -28112,7 +28440,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.configuredSystemDestinationResponse" @@ -28125,7 +28453,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.milestoneResponse" @@ -28138,7 +28466,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.poamItemResponse" @@ -28151,7 +28479,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.publicUserResponse" @@ -28164,7 +28492,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.remediationTemplateResponse" @@ -28177,7 +28505,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.riskResponse" @@ -28186,24 +28514,76 @@ const docTemplate = `{ } } }, - "handler.GenericDataResponse-handler_threatIDResponse": { + "handler.GenericDataResponse-handler_testNotificationResponse": { "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { - "$ref": "#/definitions/handler.threatIDResponse" + "$ref": "#/definitions/handler.testNotificationResponse" } ] } } }, - "handler.GenericDataResponse-oscalTypes_1_1_3_Activity": { + "handler.GenericDataResponse-handler_threatIDResponse": { "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/handler.threatIDResponse" + } + ] + } + } + }, + "handler.GenericDataResponse-notificationtroubleshooting_DiagnosticsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/notificationtroubleshooting.DiagnosticsResponse" + } + ] + } + } + }, + "handler.GenericDataResponse-notificationtroubleshooting_HealthResponse": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/notificationtroubleshooting.HealthResponse" + } + ] + } + } + }, + "handler.GenericDataResponse-notificationtroubleshooting_JobDetail": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/notificationtroubleshooting.JobDetail" + } + ] + } + } + }, + "handler.GenericDataResponse-oscalTypes_1_1_3_Activity": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Activity" @@ -28216,7 +28596,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentAssets" @@ -28229,7 +28609,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentPlan" @@ -28242,7 +28622,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentPlanTermsAndConditions" @@ -28255,7 +28635,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentResults" @@ -28268,7 +28648,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentSubject" @@ -28281,7 +28661,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AttestationStatements" @@ -28294,7 +28674,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AuthorizationBoundary" @@ -28307,7 +28687,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.BackMatter" @@ -28320,7 +28700,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ByComponent" @@ -28333,7 +28713,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Capability" @@ -28346,7 +28726,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Catalog" @@ -28359,7 +28739,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ComponentDefinition" @@ -28372,7 +28752,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Control" @@ -28385,7 +28765,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementation" @@ -28398,7 +28778,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementationSet" @@ -28411,7 +28791,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.DataFlow" @@ -28424,7 +28804,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.DefinedComponent" @@ -28437,7 +28817,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Diagram" @@ -28450,7 +28830,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Finding" @@ -28463,7 +28843,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Group" @@ -28476,7 +28856,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ImplementedRequirement" @@ -28489,7 +28869,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Import" @@ -28502,7 +28882,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ImportAp" @@ -28515,7 +28895,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ImportProfile" @@ -28528,7 +28908,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ImportSsp" @@ -28541,7 +28921,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.InventoryItem" @@ -28554,7 +28934,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.LeveragedAuthorization" @@ -28567,7 +28947,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.LocalDefinitions" @@ -28580,7 +28960,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Merge" @@ -28593,7 +28973,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Metadata" @@ -28606,7 +28986,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Modify" @@ -28619,7 +28999,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.NetworkArchitecture" @@ -28632,7 +29012,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Observation" @@ -28645,7 +29025,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Party" @@ -28658,7 +29038,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestones" @@ -28671,7 +29051,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions" @@ -28684,7 +29064,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.PoamItem" @@ -28697,7 +29077,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Profile" @@ -28710,7 +29090,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Resource" @@ -28723,7 +29103,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Result" @@ -28736,7 +29116,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Risk" @@ -28749,7 +29129,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Role" @@ -28762,7 +29142,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Statement" @@ -28775,7 +29155,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemCharacteristics" @@ -28788,7 +29168,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemComponent" @@ -28801,7 +29181,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemId" @@ -28814,7 +29194,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemImplementation" @@ -28827,7 +29207,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemSecurityPlan" @@ -28840,7 +29220,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemUser" @@ -28853,7 +29233,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Task" @@ -28866,7 +29246,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.BuildByPropsResponse" @@ -28879,7 +29259,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.ImportResponse" @@ -28892,7 +29272,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.InventoryItemWithSource" @@ -28905,7 +29285,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.ProfileComplianceProgress" @@ -28918,7 +29298,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.ProfileHandler" @@ -28931,7 +29311,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/poam.PoamItemControlLink" @@ -28944,7 +29324,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/poam.PoamItemEvidenceLink" @@ -28957,7 +29337,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/poam.PoamItemFindingLink" @@ -28970,7 +29350,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/poam.PoamItemRiskLink" @@ -28983,7 +29363,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/relational.Filter" @@ -28996,7 +29376,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/relational.User" @@ -29009,7 +29389,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/risks.RiskComponentLink" @@ -29022,7 +29402,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/risks.RiskControlLink" @@ -29035,7 +29415,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/risks.RiskEvidenceLink" @@ -29048,7 +29428,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/risks.RiskSubjectLink" @@ -29061,7 +29441,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "type": "string" } } @@ -30068,6 +30448,57 @@ const docTemplate = `{ } } }, + "handler.testNotificationRequest": { + "type": "object", + "required": [ + "destinationTarget", + "providerType" + ], + "properties": { + "destinationTarget": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "enqueue" + ] + }, + "providerType": { + "type": "string", + "enum": [ + "email", + "slack" + ] + } + } + }, + "handler.testNotificationResponse": { + "type": "object", + "properties": { + "accepted": { + "type": "boolean" + }, + "destinationTarget": { + "type": "string" + }, + "jobIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "providerType": { + "type": "string" + } + } + }, "handler.threatIDRequest": { "type": "object", "properties": { @@ -30308,6 +30739,462 @@ const docTemplate = `{ } } }, + "notificationtroubleshooting.ConfiguredSystemDestinationResponse": { + "type": "object", + "properties": { + "destinationTarget": { + "type": "string" + }, + "providerType": { + "type": "string" + } + } + }, + "notificationtroubleshooting.DiagnosticCheck": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "jobId": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "notificationtroubleshooting.DiagnosticsResponse": { + "type": "object", + "properties": { + "checks": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.DiagnosticCheck" + } + }, + "configuredDestinations": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.ConfiguredSystemDestinationResponse" + } + }, + "notificationName": { + "type": "string" + }, + "recommendedActions": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "subscriberCounts": { + "$ref": "#/definitions/notificationtroubleshooting.SubscriberCounts" + } + } + }, + "notificationtroubleshooting.HealthResponse": { + "type": "object", + "properties": { + "notifications": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.NotificationHealth" + } + }, + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.ProviderStatus" + } + }, + "schedules": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.ScheduleHealth" + } + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.TroubleshootingWarning" + } + }, + "worker": { + "$ref": "#/definitions/notificationtroubleshooting.WorkerHealth" + } + } + }, + "notificationtroubleshooting.JobDetail": { + "type": "object", + "properties": { + "args": { + "type": "object", + "additionalProperties": {} + }, + "attempt": { + "type": "integer" + }, + "attemptedAt": { + "type": "string", + "format": "date-time" + }, + "correlationId": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.SanitizedAttemptError" + } + }, + "finalizedAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "kind": { + "type": "string" + }, + "lastError": { + "type": "string" + }, + "maxAttempts": { + "type": "integer" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "notificationKind": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "queue": { + "type": "string" + }, + "scheduledAt": { + "type": "string", + "format": "date-time" + }, + "sourceJobId": { + "type": "string" + }, + "sourceJobKind": { + "type": "string" + }, + "stale": { + "type": "boolean" + }, + "state": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, + "notificationtroubleshooting.JobListItem": { + "type": "object", + "properties": { + "attempt": { + "type": "integer" + }, + "attemptedAt": { + "type": "string", + "format": "date-time" + }, + "correlationId": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "finalizedAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "kind": { + "type": "string" + }, + "lastError": { + "type": "string" + }, + "maxAttempts": { + "type": "integer" + }, + "notificationKind": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "queue": { + "type": "string" + }, + "scheduledAt": { + "type": "string", + "format": "date-time" + }, + "sourceJobId": { + "type": "string" + }, + "sourceJobKind": { + "type": "string" + }, + "stale": { + "type": "boolean" + }, + "state": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, + "notificationtroubleshooting.JobSummary": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time" + }, + "finalizedAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "state": { + "type": "string" + } + } + }, + "notificationtroubleshooting.JobsListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.JobListItem" + } + }, + "pagination": { + "$ref": "#/definitions/notificationtroubleshooting.Pagination" + } + } + }, + "notificationtroubleshooting.NotificationHealth": { + "type": "object", + "properties": { + "configuredDestinations": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.ConfiguredSystemDestinationResponse" + } + }, + "name": { + "type": "string" + }, + "subscriberCounts": { + "$ref": "#/definitions/notificationtroubleshooting.SubscriberCounts" + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.TroubleshootingWarning" + } + } + } + }, + "notificationtroubleshooting.Pagination": { + "type": "object", + "properties": { + "nextCursor": { + "type": "string" + } + } + }, + "notificationtroubleshooting.ProviderStatus": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "providerType": { + "type": "string" + } + } + }, + "notificationtroubleshooting.QueueSummary": { + "type": "object", + "properties": { + "available": { + "type": "integer" + }, + "completed24h": { + "type": "integer" + }, + "discarded24h": { + "type": "integer" + }, + "maxWorkers": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "oldestAvailableAt": { + "type": "string" + }, + "retryable": { + "type": "integer" + }, + "running": { + "type": "integer" + }, + "scheduled": { + "type": "integer" + }, + "staleCount": { + "type": "integer" + }, + "staleThresholdSeconds": { + "type": "integer" + } + } + }, + "notificationtroubleshooting.SanitizedAttemptError": { + "type": "object", + "properties": { + "at": { + "type": "string" + }, + "attempt": { + "type": "integer" + }, + "error": { + "type": "string" + } + } + }, + "notificationtroubleshooting.ScheduleHealth": { + "type": "object", + "properties": { + "deliveryKind": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "jobKind": { + "type": "string" + }, + "lastJob": { + "$ref": "#/definitions/notificationtroubleshooting.JobSummary" + }, + "name": { + "type": "string" + }, + "nextRunAt": { + "type": "string" + }, + "queue": { + "type": "string" + }, + "schedule": { + "type": "string" + } + } + }, + "notificationtroubleshooting.SubscriberCounts": { + "type": "object", + "properties": { + "email": { + "type": "integer" + }, + "slack": { + "type": "integer" + }, + "totalUsers": { + "type": "integer" + } + } + }, + "notificationtroubleshooting.TroubleshootingWarning": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, + "notificationtroubleshooting.WorkerHealth": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "mode": { + "type": "string" + }, + "pollOnly": { + "type": "boolean" + }, + "queues": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.QueueSummary" + } + } + } + }, "oscal.ApplySuggestionRequest": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index a6d12f38..9f937e1c 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -131,21 +131,21 @@ ] } }, - "/admin/notifications/providers": { + "/admin/notifications/health": { "get": { - "description": "Returns notification providers registered in the backend", + "description": "Returns provider, worker, queue, subscriber, destination, and schedule health for admin notification troubleshooting", "produces": [ "application/json" ], "tags": [ "Notifications" ], - "summary": "List available notification providers", + "summary": "Get notification troubleshooting health", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataListResponse-handler_availableNotificationProviderResponse" + "$ref": "#/definitions/handler.GenericDataResponse-notificationtroubleshooting_HealthResponse" } }, "401": { @@ -168,42 +168,90 @@ ] } }, - "/admin/notifications/{notificationName}/destinations": { - "post": { - "description": "Creates a new system notification destination configuration for an admin-managed notification", - "consumes": [ - "application/json" - ], + "/admin/notifications/jobs": { + "get": { + "description": "Lists recent notification-related River jobs with sanitized notification metadata", "produces": [ "application/json" ], "tags": [ "Notifications" ], - "summary": "Create system notification destination", + "summary": "List notification River jobs", "parameters": [ { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Queue filter; repeat or comma-separate values", + "name": "queue", + "in": "query" + }, + { + "enum": [ + "email", + "slack" + ], "type": "string", - "description": "Notification name", - "name": "notificationName", - "in": "path", - "required": true + "description": "Provider filter: email or slack", + "name": "provider", + "in": "query" }, { - "description": "Destination details", - "name": "destination", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handler.createSystemNotificationDestinationRequest" - } + "type": "string", + "description": "Notification kind filter", + "name": "notificationKind", + "in": "query" + }, + { + "type": "array", + "items": { + "enum": [ + "available", + "cancelled", + "completed", + "discarded", + "pending", + "retryable", + "running", + "scheduled" + ], + "type": "string" + }, + "collectionFormat": "csv", + "description": "River state filter; repeat or comma-separate values", + "name": "state", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "description": "RFC3339 lower bound for job creation time", + "name": "since", + "in": "query" + }, + { + "maximum": 200, + "minimum": 1, + "type": "integer", + "description": "Page size, default 50, max 200", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Opaque pagination cursor", + "name": "cursor", + "in": "query" } ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-handler_configuredSystemDestinationResponse" + "$ref": "#/definitions/notificationtroubleshooting.JobsListResponse" } }, "400": { @@ -218,12 +266,6 @@ "$ref": "#/definitions/api.Error" } }, - "409": { - "description": "Conflict", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -236,40 +278,33 @@ "OAuth2Password": [] } ] - }, - "delete": { - "description": "Deletes a stored system notification destination configuration for an admin-managed notification", - "consumes": [ - "application/json" - ], + } + }, + "/admin/notifications/jobs/{id}": { + "get": { + "description": "Returns one sanitized notification-related River job with attempt errors", "produces": [ "application/json" ], "tags": [ "Notifications" ], - "summary": "Delete system notification destination", + "summary": "Get notification River job detail", "parameters": [ { - "type": "string", - "description": "Notification name", - "name": "notificationName", + "type": "integer", + "description": "River job ID", + "name": "id", "in": "path", "required": true - }, - { - "description": "Destination details", - "name": "destination", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handler.createSystemNotificationDestinationRequest" - } } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-notificationtroubleshooting_JobDetail" + } }, "400": { "description": "Bad Request", @@ -303,57 +338,25 @@ ] } }, - "/admin/risk-templates": { + "/admin/notifications/providers": { "get": { - "description": "List risk templates with optional filters and pagination.", + "description": "Returns notification providers registered in the backend", "produces": [ "application/json" ], "tags": [ - "Risk Templates" - ], - "summary": "List risk templates", - "parameters": [ - { - "type": "string", - "description": "Plugin ID", - "name": "plugin-id", - "in": "query" - }, - { - "type": "string", - "description": "Policy package", - "name": "policy-package", - "in": "query" - }, - { - "type": "boolean", - "description": "Active flag", - "name": "is-active", - "in": "query" - }, - { - "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Page size", - "name": "limit", - "in": "query" - } + "Notifications" ], + "summary": "List available notification providers", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/service.ListResponse-templates_riskTemplateResponse" + "$ref": "#/definitions/handler.GenericDataListResponse-handler_availableNotificationProviderResponse" } }, - "400": { - "description": "Bad Request", + "401": { + "description": "Unauthorized", "schema": { "$ref": "#/definitions/api.Error" } @@ -370,9 +373,11 @@ "OAuth2Password": [] } ] - }, + } + }, + "/admin/notifications/test": { "post": { - "description": "Create a risk template with threat references and remediation template/tasks.", + "description": "Enqueues a fixed server-side test notification to a validated admin-supplied destination", "consumes": [ "application/json" ], @@ -380,25 +385,25 @@ "application/json" ], "tags": [ - "Risk Templates" + "Notifications" ], - "summary": "Create risk template", + "summary": "Enqueue fixed test notification", "parameters": [ { - "description": "Risk template payload", - "name": "template", + "description": "Test destination", + "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/templates.upsertRiskTemplateRequest" + "$ref": "#/definitions/handler.testNotificationRequest" } } ], "responses": { - "201": { - "description": "Created", + "202": { + "description": "Accepted", "schema": { - "$ref": "#/definitions/templates.riskTemplateDataResponse" + "$ref": "#/definitions/handler.GenericDataResponse-handler_testNotificationResponse" } }, "400": { @@ -407,11 +412,23 @@ "$ref": "#/definitions/api.Error" } }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/api.Error" } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/api.Error" + } } }, "security": [ @@ -421,30 +438,42 @@ ] } }, - "/admin/risk-templates/{id}": { - "get": { - "description": "Get a risk template by ID.", + "/admin/notifications/{notificationName}/destinations": { + "post": { + "description": "Creates a new system notification destination configuration for an admin-managed notification", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "Risk Templates" + "Notifications" ], - "summary": "Get risk template", + "summary": "Create system notification destination", "parameters": [ { "type": "string", - "description": "Risk Template ID", - "name": "id", + "description": "Notification name", + "name": "notificationName", "in": "path", "required": true + }, + { + "description": "Destination details", + "name": "destination", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.createSystemNotificationDestinationRequest" + } } ], "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "schema": { - "$ref": "#/definitions/templates.riskTemplateDataResponse" + "$ref": "#/definitions/handler.GenericDataResponse-handler_configuredSystemDestinationResponse" } }, "400": { @@ -453,8 +482,14 @@ "$ref": "#/definitions/api.Error" } }, - "404": { - "description": "Not Found", + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "409": { + "description": "Conflict", "schema": { "$ref": "#/definitions/api.Error" } @@ -472,8 +507,8 @@ } ] }, - "put": { - "description": "Update a risk template and atomically replace threat refs and remediation tasks.", + "delete": { + "description": "Deletes a stored system notification destination configuration for an admin-managed notification", "consumes": [ "application/json" ], @@ -481,33 +516,30 @@ "application/json" ], "tags": [ - "Risk Templates" + "Notifications" ], - "summary": "Update risk template", + "summary": "Delete system notification destination", "parameters": [ { "type": "string", - "description": "Risk Template ID", - "name": "id", + "description": "Notification name", + "name": "notificationName", "in": "path", "required": true }, { - "description": "Risk template payload", - "name": "template", + "description": "Destination details", + "name": "destination", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/templates.upsertRiskTemplateRequest" + "$ref": "#/definitions/handler.createSystemNotificationDestinationRequest" } } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/templates.riskTemplateDataResponse" - } + "204": { + "description": "No Content" }, "400": { "description": "Bad Request", @@ -515,6 +547,12 @@ "$ref": "#/definitions/api.Error" } }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "404": { "description": "Not Found", "schema": { @@ -533,28 +571,33 @@ "OAuth2Password": [] } ] - }, - "delete": { - "description": "Delete a risk template and its associated threat references and remediation data.", + } + }, + "/admin/notifications/{notificationName}/diagnostics": { + "get": { + "description": "Runs read-only diagnostics for evidence digest, workflow, risk, or POAM notifications", "produces": [ "application/json" ], "tags": [ - "Risk Templates" + "Notifications" ], - "summary": "Delete risk template", + "summary": "Get notification diagnostics", "parameters": [ { "type": "string", - "description": "Risk Template ID", - "name": "id", + "description": "Notification name or family", + "name": "notificationName", "in": "path", "required": true } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-notificationtroubleshooting_DiagnosticsResponse" + } }, "400": { "description": "Bad Request", @@ -562,9 +605,15 @@ "$ref": "#/definitions/api.Error" } }, - "404": { - "description": "Not Found", - "schema": { + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { "$ref": "#/definitions/api.Error" } }, @@ -582,27 +631,33 @@ ] } }, - "/admin/subject-templates": { + "/admin/risk-templates": { "get": { - "description": "List subject templates with optional filters and pagination.", + "description": "List risk templates with optional filters and pagination.", "produces": [ "application/json" ], "tags": [ - "Subject Templates" + "Risk Templates" ], - "summary": "List subject templates", + "summary": "List risk templates", "parameters": [ { "type": "string", - "description": "Subject type", - "name": "type", + "description": "Plugin ID", + "name": "plugin-id", "in": "query" }, { "type": "string", - "description": "Source mode", - "name": "source-mode", + "description": "Policy package", + "name": "policy-package", + "in": "query" + }, + { + "type": "boolean", + "description": "Active flag", + "name": "is-active", "in": "query" }, { @@ -622,7 +677,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/service.ListResponse-templates_subjectTemplateResponse" + "$ref": "#/definitions/service.ListResponse-templates_riskTemplateResponse" } }, "400": { @@ -645,7 +700,7 @@ ] }, "post": { - "description": "Create a subject template with selector labels and label schema.", + "description": "Create a risk template with threat references and remediation template/tasks.", "consumes": [ "application/json" ], @@ -653,17 +708,17 @@ "application/json" ], "tags": [ - "Subject Templates" + "Risk Templates" ], - "summary": "Create subject template", + "summary": "Create risk template", "parameters": [ { - "description": "Subject template payload", + "description": "Risk template payload", "name": "template", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/templates.upsertSubjectTemplateRequest" + "$ref": "#/definitions/templates.upsertRiskTemplateRequest" } } ], @@ -671,7 +726,7 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/templates.subjectTemplateDataResponse" + "$ref": "#/definitions/templates.riskTemplateDataResponse" } }, "400": { @@ -694,20 +749,20 @@ ] } }, - "/admin/subject-templates/{id}": { + "/admin/risk-templates/{id}": { "get": { - "description": "Get a subject template by ID.", + "description": "Get a risk template by ID.", "produces": [ "application/json" ], "tags": [ - "Subject Templates" + "Risk Templates" ], - "summary": "Get subject template", + "summary": "Get risk template", "parameters": [ { "type": "string", - "description": "Subject Template ID", + "description": "Risk Template ID", "name": "id", "in": "path", "required": true @@ -717,7 +772,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/templates.subjectTemplateDataResponse" + "$ref": "#/definitions/templates.riskTemplateDataResponse" } }, "400": { @@ -746,7 +801,7 @@ ] }, "put": { - "description": "Update a subject template and atomically replace selector labels and label schema.", + "description": "Update a risk template and atomically replace threat refs and remediation tasks.", "consumes": [ "application/json" ], @@ -754,24 +809,24 @@ "application/json" ], "tags": [ - "Subject Templates" + "Risk Templates" ], - "summary": "Update subject template", + "summary": "Update risk template", "parameters": [ { "type": "string", - "description": "Subject Template ID", + "description": "Risk Template ID", "name": "id", "in": "path", "required": true }, { - "description": "Subject template payload", + "description": "Risk template payload", "name": "template", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/templates.upsertSubjectTemplateRequest" + "$ref": "#/definitions/templates.upsertRiskTemplateRequest" } } ], @@ -779,8 +834,55 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/templates.subjectTemplateDataResponse" + "$ref": "#/definitions/templates.riskTemplateDataResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "delete": { + "description": "Delete a risk template and its associated threat references and remediation data.", + "produces": [ + "application/json" + ], + "tags": [ + "Risk Templates" + ], + "summary": "Delete risk template", + "parameters": [ + { + "type": "string", + "description": "Risk Template ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" }, "400": { "description": "Bad Request", @@ -808,25 +910,51 @@ ] } }, - "/admin/users": { + "/admin/subject-templates": { "get": { - "description": "Lists all users in the system", + "description": "List subject templates with optional filters and pagination.", "produces": [ "application/json" ], "tags": [ - "Users" + "Subject Templates" + ], + "summary": "List subject templates", + "parameters": [ + { + "type": "string", + "description": "Subject type", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "Source mode", + "name": "source-mode", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } ], - "summary": "List all users", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataListResponse-relational_User" + "$ref": "#/definitions/service.ListResponse-templates_subjectTemplateResponse" } }, - "401": { - "description": "Unauthorized", + "400": { + "description": "Bad Request", "schema": { "$ref": "#/definitions/api.Error" } @@ -845,7 +973,7 @@ ] }, "post": { - "description": "Creates a new user in the system", + "description": "Create a subject template with selector labels and label schema.", "consumes": [ "application/json" ], @@ -853,17 +981,17 @@ "application/json" ], "tags": [ - "Users" + "Subject Templates" ], - "summary": "Create a new user", + "summary": "Create subject template", "parameters": [ { - "description": "User details", - "name": "user", + "description": "Subject template payload", + "name": "template", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handler.UserHandler" + "$ref": "#/definitions/templates.upsertSubjectTemplateRequest" } } ], @@ -871,7 +999,7 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-relational_User" + "$ref": "#/definitions/templates.subjectTemplateDataResponse" } }, "400": { @@ -880,18 +1008,6 @@ "$ref": "#/definitions/api.Error" } }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, - "409": { - "description": "Conflict", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -906,20 +1022,20 @@ ] } }, - "/admin/users/{id}": { + "/admin/subject-templates/{id}": { "get": { - "description": "Get user details by user ID", + "description": "Get a subject template by ID.", "produces": [ "application/json" ], "tags": [ - "Users" + "Subject Templates" ], - "summary": "Get user by ID", + "summary": "Get subject template", "parameters": [ { "type": "string", - "description": "User ID", + "description": "Subject Template ID", "name": "id", "in": "path", "required": true @@ -929,7 +1045,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-relational_User" + "$ref": "#/definitions/templates.subjectTemplateDataResponse" } }, "400": { @@ -938,12 +1054,6 @@ "$ref": "#/definitions/api.Error" } }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "404": { "description": "Not Found", "schema": { @@ -964,7 +1074,7 @@ ] }, "put": { - "description": "Updates the details of an existing user", + "description": "Update a subject template and atomically replace selector labels and label schema.", "consumes": [ "application/json" ], @@ -972,24 +1082,24 @@ "application/json" ], "tags": [ - "Users" + "Subject Templates" ], - "summary": "Update user details", + "summary": "Update subject template", "parameters": [ { "type": "string", - "description": "User ID", + "description": "Subject Template ID", "name": "id", "in": "path", "required": true }, { - "description": "User details", - "name": "user", + "description": "Subject template payload", + "name": "template", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handler.UserHandler" + "$ref": "#/definitions/templates.upsertSubjectTemplateRequest" } } ], @@ -997,7 +1107,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataResponse-relational_User" + "$ref": "#/definitions/templates.subjectTemplateDataResponse" } }, "400": { @@ -1006,12 +1116,6 @@ "$ref": "#/definitions/api.Error" } }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/api.Error" - } - }, "404": { "description": "Not Found", "schema": { @@ -1030,30 +1134,23 @@ "OAuth2Password": [] } ] - }, - "delete": { - "description": "Deletes a user from the system", + } + }, + "/admin/users": { + "get": { + "description": "Lists all users in the system", + "produces": [ + "application/json" + ], "tags": [ "Users" ], - "summary": "Delete a user", - "parameters": [ - { - "type": "string", - "description": "User ID", - "name": "id", - "in": "path", - "required": true - } - ], + "summary": "List all users", "responses": { - "204": { - "description": "No Content" - }, - "400": { - "description": "Bad Request", + "200": { + "description": "OK", "schema": { - "$ref": "#/definitions/api.Error" + "$ref": "#/definitions/handler.GenericDataListResponse-relational_User" } }, "401": { @@ -1062,7 +1159,238 @@ "$ref": "#/definitions/api.Error" } }, - "404": { + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "post": { + "description": "Creates a new user in the system", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Create a new user", + "parameters": [ + { + "description": "User details", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.UserHandler" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-relational_User" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "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/users/{id}": { + "get": { + "description": "Get user details by user ID", + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get user by ID", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-relational_User" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "put": { + "description": "Updates the details of an existing user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Update user details", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "User details", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.UserHandler" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-relational_User" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "delete": { + "description": "Deletes a user from the system", + "tags": [ + "Users" + ], + "summary": "Delete a user", + "parameters": [ + { + "type": "string", + "description": "User 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" + } + }, + "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/api.Error" @@ -27195,7 +27523,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/evidence.SignatureDetail" @@ -27208,7 +27536,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/evidence.VerificationResult" @@ -27979,7 +28307,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "type": "array", "items": { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentAssets" @@ -27991,7 +28319,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "type": "array", "items": { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentSubject" @@ -28003,7 +28331,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "type": "array", "items": { "$ref": "#/definitions/oscalTypes_1_1_3.Task" @@ -28015,7 +28343,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/auth.AuthHandler" @@ -28028,7 +28356,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/digest.EvidenceSummary" @@ -28041,7 +28369,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.CreatedEvidenceResponse" @@ -28054,7 +28382,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.FilterImportResponse" @@ -28067,7 +28395,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.FilterWithAssociations" @@ -28080,7 +28408,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.PublicEvidenceResponse" @@ -28093,7 +28421,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.SubscriptionsResponse" @@ -28106,7 +28434,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.configuredSystemDestinationResponse" @@ -28119,7 +28447,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.milestoneResponse" @@ -28132,7 +28460,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.poamItemResponse" @@ -28145,7 +28473,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.publicUserResponse" @@ -28158,7 +28486,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.remediationTemplateResponse" @@ -28171,7 +28499,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.riskResponse" @@ -28180,24 +28508,76 @@ } } }, - "handler.GenericDataResponse-handler_threatIDResponse": { + "handler.GenericDataResponse-handler_testNotificationResponse": { "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { - "$ref": "#/definitions/handler.threatIDResponse" + "$ref": "#/definitions/handler.testNotificationResponse" } ] } } }, - "handler.GenericDataResponse-oscalTypes_1_1_3_Activity": { + "handler.GenericDataResponse-handler_threatIDResponse": { "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/handler.threatIDResponse" + } + ] + } + } + }, + "handler.GenericDataResponse-notificationtroubleshooting_DiagnosticsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/notificationtroubleshooting.DiagnosticsResponse" + } + ] + } + } + }, + "handler.GenericDataResponse-notificationtroubleshooting_HealthResponse": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/notificationtroubleshooting.HealthResponse" + } + ] + } + } + }, + "handler.GenericDataResponse-notificationtroubleshooting_JobDetail": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/notificationtroubleshooting.JobDetail" + } + ] + } + } + }, + "handler.GenericDataResponse-oscalTypes_1_1_3_Activity": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Activity" @@ -28210,7 +28590,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentAssets" @@ -28223,7 +28603,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentPlan" @@ -28236,7 +28616,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentPlanTermsAndConditions" @@ -28249,7 +28629,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentResults" @@ -28262,7 +28642,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentSubject" @@ -28275,7 +28655,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AttestationStatements" @@ -28288,7 +28668,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AuthorizationBoundary" @@ -28301,7 +28681,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.BackMatter" @@ -28314,7 +28694,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ByComponent" @@ -28327,7 +28707,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Capability" @@ -28340,7 +28720,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Catalog" @@ -28353,7 +28733,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ComponentDefinition" @@ -28366,7 +28746,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Control" @@ -28379,7 +28759,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementation" @@ -28392,7 +28772,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementationSet" @@ -28405,7 +28785,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.DataFlow" @@ -28418,7 +28798,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.DefinedComponent" @@ -28431,7 +28811,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Diagram" @@ -28444,7 +28824,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Finding" @@ -28457,7 +28837,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Group" @@ -28470,7 +28850,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ImplementedRequirement" @@ -28483,7 +28863,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Import" @@ -28496,7 +28876,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ImportAp" @@ -28509,7 +28889,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ImportProfile" @@ -28522,7 +28902,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ImportSsp" @@ -28535,7 +28915,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.InventoryItem" @@ -28548,7 +28928,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.LeveragedAuthorization" @@ -28561,7 +28941,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.LocalDefinitions" @@ -28574,7 +28954,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Merge" @@ -28587,7 +28967,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Metadata" @@ -28600,7 +28980,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Modify" @@ -28613,7 +28993,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.NetworkArchitecture" @@ -28626,7 +29006,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Observation" @@ -28639,7 +29019,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Party" @@ -28652,7 +29032,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestones" @@ -28665,7 +29045,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions" @@ -28678,7 +29058,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.PoamItem" @@ -28691,7 +29071,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Profile" @@ -28704,7 +29084,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Resource" @@ -28717,7 +29097,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Result" @@ -28730,7 +29110,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Risk" @@ -28743,7 +29123,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Role" @@ -28756,7 +29136,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Statement" @@ -28769,7 +29149,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemCharacteristics" @@ -28782,7 +29162,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemComponent" @@ -28795,7 +29175,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemId" @@ -28808,7 +29188,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemImplementation" @@ -28821,7 +29201,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemSecurityPlan" @@ -28834,7 +29214,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemUser" @@ -28847,7 +29227,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Task" @@ -28860,7 +29240,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.BuildByPropsResponse" @@ -28873,7 +29253,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.ImportResponse" @@ -28886,7 +29266,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.InventoryItemWithSource" @@ -28899,7 +29279,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.ProfileComplianceProgress" @@ -28912,7 +29292,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.ProfileHandler" @@ -28925,7 +29305,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/poam.PoamItemControlLink" @@ -28938,7 +29318,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/poam.PoamItemEvidenceLink" @@ -28951,7 +29331,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/poam.PoamItemFindingLink" @@ -28964,7 +29344,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/poam.PoamItemRiskLink" @@ -28977,7 +29357,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/relational.Filter" @@ -28990,7 +29370,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/relational.User" @@ -29003,7 +29383,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/risks.RiskComponentLink" @@ -29016,7 +29396,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/risks.RiskControlLink" @@ -29029,7 +29409,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/risks.RiskEvidenceLink" @@ -29042,7 +29422,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/risks.RiskSubjectLink" @@ -29055,7 +29435,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "type": "string" } } @@ -30062,6 +30442,57 @@ } } }, + "handler.testNotificationRequest": { + "type": "object", + "required": [ + "destinationTarget", + "providerType" + ], + "properties": { + "destinationTarget": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "enqueue" + ] + }, + "providerType": { + "type": "string", + "enum": [ + "email", + "slack" + ] + } + } + }, + "handler.testNotificationResponse": { + "type": "object", + "properties": { + "accepted": { + "type": "boolean" + }, + "destinationTarget": { + "type": "string" + }, + "jobIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "providerType": { + "type": "string" + } + } + }, "handler.threatIDRequest": { "type": "object", "properties": { @@ -30302,6 +30733,462 @@ } } }, + "notificationtroubleshooting.ConfiguredSystemDestinationResponse": { + "type": "object", + "properties": { + "destinationTarget": { + "type": "string" + }, + "providerType": { + "type": "string" + } + } + }, + "notificationtroubleshooting.DiagnosticCheck": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "jobId": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "notificationtroubleshooting.DiagnosticsResponse": { + "type": "object", + "properties": { + "checks": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.DiagnosticCheck" + } + }, + "configuredDestinations": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.ConfiguredSystemDestinationResponse" + } + }, + "notificationName": { + "type": "string" + }, + "recommendedActions": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "subscriberCounts": { + "$ref": "#/definitions/notificationtroubleshooting.SubscriberCounts" + } + } + }, + "notificationtroubleshooting.HealthResponse": { + "type": "object", + "properties": { + "notifications": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.NotificationHealth" + } + }, + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.ProviderStatus" + } + }, + "schedules": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.ScheduleHealth" + } + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.TroubleshootingWarning" + } + }, + "worker": { + "$ref": "#/definitions/notificationtroubleshooting.WorkerHealth" + } + } + }, + "notificationtroubleshooting.JobDetail": { + "type": "object", + "properties": { + "args": { + "type": "object", + "additionalProperties": {} + }, + "attempt": { + "type": "integer" + }, + "attemptedAt": { + "type": "string", + "format": "date-time" + }, + "correlationId": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.SanitizedAttemptError" + } + }, + "finalizedAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "kind": { + "type": "string" + }, + "lastError": { + "type": "string" + }, + "maxAttempts": { + "type": "integer" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "notificationKind": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "queue": { + "type": "string" + }, + "scheduledAt": { + "type": "string", + "format": "date-time" + }, + "sourceJobId": { + "type": "string" + }, + "sourceJobKind": { + "type": "string" + }, + "stale": { + "type": "boolean" + }, + "state": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, + "notificationtroubleshooting.JobListItem": { + "type": "object", + "properties": { + "attempt": { + "type": "integer" + }, + "attemptedAt": { + "type": "string", + "format": "date-time" + }, + "correlationId": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "finalizedAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "kind": { + "type": "string" + }, + "lastError": { + "type": "string" + }, + "maxAttempts": { + "type": "integer" + }, + "notificationKind": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "queue": { + "type": "string" + }, + "scheduledAt": { + "type": "string", + "format": "date-time" + }, + "sourceJobId": { + "type": "string" + }, + "sourceJobKind": { + "type": "string" + }, + "stale": { + "type": "boolean" + }, + "state": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, + "notificationtroubleshooting.JobSummary": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time" + }, + "finalizedAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "state": { + "type": "string" + } + } + }, + "notificationtroubleshooting.JobsListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.JobListItem" + } + }, + "pagination": { + "$ref": "#/definitions/notificationtroubleshooting.Pagination" + } + } + }, + "notificationtroubleshooting.NotificationHealth": { + "type": "object", + "properties": { + "configuredDestinations": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.ConfiguredSystemDestinationResponse" + } + }, + "name": { + "type": "string" + }, + "subscriberCounts": { + "$ref": "#/definitions/notificationtroubleshooting.SubscriberCounts" + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.TroubleshootingWarning" + } + } + } + }, + "notificationtroubleshooting.Pagination": { + "type": "object", + "properties": { + "nextCursor": { + "type": "string" + } + } + }, + "notificationtroubleshooting.ProviderStatus": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "providerType": { + "type": "string" + } + } + }, + "notificationtroubleshooting.QueueSummary": { + "type": "object", + "properties": { + "available": { + "type": "integer" + }, + "completed24h": { + "type": "integer" + }, + "discarded24h": { + "type": "integer" + }, + "maxWorkers": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "oldestAvailableAt": { + "type": "string" + }, + "retryable": { + "type": "integer" + }, + "running": { + "type": "integer" + }, + "scheduled": { + "type": "integer" + }, + "staleCount": { + "type": "integer" + }, + "staleThresholdSeconds": { + "type": "integer" + } + } + }, + "notificationtroubleshooting.SanitizedAttemptError": { + "type": "object", + "properties": { + "at": { + "type": "string" + }, + "attempt": { + "type": "integer" + }, + "error": { + "type": "string" + } + } + }, + "notificationtroubleshooting.ScheduleHealth": { + "type": "object", + "properties": { + "deliveryKind": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "jobKind": { + "type": "string" + }, + "lastJob": { + "$ref": "#/definitions/notificationtroubleshooting.JobSummary" + }, + "name": { + "type": "string" + }, + "nextRunAt": { + "type": "string" + }, + "queue": { + "type": "string" + }, + "schedule": { + "type": "string" + } + } + }, + "notificationtroubleshooting.SubscriberCounts": { + "type": "object", + "properties": { + "email": { + "type": "integer" + }, + "slack": { + "type": "integer" + }, + "totalUsers": { + "type": "integer" + } + } + }, + "notificationtroubleshooting.TroubleshootingWarning": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, + "notificationtroubleshooting.WorkerHealth": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "mode": { + "type": "string" + }, + "pollOnly": { + "type": "boolean" + }, + "queues": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.QueueSummary" + } + } + } + }, "oscal.ApplySuggestionRequest": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d3023b94..081d2fac 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -424,14 +424,14 @@ definitions: data: allOf: - $ref: '#/definitions/evidence.SignatureDetail' - description: Items from the list response + description: Wrapped response data type: object handler.EvidenceSignatureVerificationResponse: properties: data: allOf: - $ref: '#/definitions/evidence.VerificationResult' - description: Items from the list response + description: Wrapped response data type: object handler.EvidenceSubject: properties: @@ -942,7 +942,7 @@ definitions: handler.GenericDataResponse-array_oscalTypes_1_1_3_AssessmentAssets: properties: data: - description: Items from the list response + description: Wrapped response data items: $ref: '#/definitions/oscalTypes_1_1_3.AssessmentAssets' type: array @@ -950,7 +950,7 @@ definitions: handler.GenericDataResponse-array_oscalTypes_1_1_3_AssessmentSubject: properties: data: - description: Items from the list response + description: Wrapped response data items: $ref: '#/definitions/oscalTypes_1_1_3.AssessmentSubject' type: array @@ -958,7 +958,7 @@ definitions: handler.GenericDataResponse-array_oscalTypes_1_1_3_Task: properties: data: - description: Items from the list response + description: Wrapped response data items: $ref: '#/definitions/oscalTypes_1_1_3.Task' type: array @@ -968,565 +968,593 @@ definitions: data: allOf: - $ref: '#/definitions/auth.AuthHandler' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-digest_EvidenceSummary: properties: data: allOf: - $ref: '#/definitions/digest.EvidenceSummary' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_CreatedEvidenceResponse: properties: data: allOf: - $ref: '#/definitions/handler.CreatedEvidenceResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_FilterImportResponse: properties: data: allOf: - $ref: '#/definitions/handler.FilterImportResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_FilterWithAssociations: properties: data: allOf: - $ref: '#/definitions/handler.FilterWithAssociations' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_PublicEvidenceResponse: properties: data: allOf: - $ref: '#/definitions/handler.PublicEvidenceResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_SubscriptionsResponse: properties: data: allOf: - $ref: '#/definitions/handler.SubscriptionsResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_configuredSystemDestinationResponse: properties: data: allOf: - $ref: '#/definitions/handler.configuredSystemDestinationResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_milestoneResponse: properties: data: allOf: - $ref: '#/definitions/handler.milestoneResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_poamItemResponse: properties: data: allOf: - $ref: '#/definitions/handler.poamItemResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_publicUserResponse: properties: data: allOf: - $ref: '#/definitions/handler.publicUserResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_remediationTemplateResponse: properties: data: allOf: - $ref: '#/definitions/handler.remediationTemplateResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_riskResponse: properties: data: allOf: - $ref: '#/definitions/handler.riskResponse' - description: Items from the list response + description: Wrapped response data + type: object + handler.GenericDataResponse-handler_testNotificationResponse: + properties: + data: + allOf: + - $ref: '#/definitions/handler.testNotificationResponse' + description: Wrapped response data type: object handler.GenericDataResponse-handler_threatIDResponse: properties: data: allOf: - $ref: '#/definitions/handler.threatIDResponse' - description: Items from the list response + description: Wrapped response data + type: object + handler.GenericDataResponse-notificationtroubleshooting_DiagnosticsResponse: + properties: + data: + allOf: + - $ref: '#/definitions/notificationtroubleshooting.DiagnosticsResponse' + description: Wrapped response data + type: object + handler.GenericDataResponse-notificationtroubleshooting_HealthResponse: + properties: + data: + allOf: + - $ref: '#/definitions/notificationtroubleshooting.HealthResponse' + description: Wrapped response data + type: object + handler.GenericDataResponse-notificationtroubleshooting_JobDetail: + properties: + data: + allOf: + - $ref: '#/definitions/notificationtroubleshooting.JobDetail' + description: Wrapped response data type: object handler.GenericDataResponse-oscal_BuildByPropsResponse: properties: data: allOf: - $ref: '#/definitions/oscal.BuildByPropsResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscal_ImportResponse: properties: data: allOf: - $ref: '#/definitions/oscal.ImportResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscal_InventoryItemWithSource: properties: data: allOf: - $ref: '#/definitions/oscal.InventoryItemWithSource' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscal_ProfileComplianceProgress: properties: data: allOf: - $ref: '#/definitions/oscal.ProfileComplianceProgress' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscal_ProfileHandler: properties: data: allOf: - $ref: '#/definitions/oscal.ProfileHandler' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Activity: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Activity' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentAssets: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.AssessmentAssets' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentPlan: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.AssessmentPlan' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentPlanTermsAndConditions: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.AssessmentPlanTermsAndConditions' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentResults: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.AssessmentResults' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentSubject: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.AssessmentSubject' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_AttestationStatements: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.AttestationStatements' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_AuthorizationBoundary: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.AuthorizationBoundary' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_BackMatter: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.BackMatter' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_ByComponent: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.ByComponent' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Capability: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Capability' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Catalog: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Catalog' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_ComponentDefinition: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.ComponentDefinition' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Control: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Control' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_ControlImplementation: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.ControlImplementation' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_ControlImplementationSet: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.ControlImplementationSet' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_DataFlow: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.DataFlow' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_DefinedComponent: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.DefinedComponent' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Diagram: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Diagram' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Finding: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Finding' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Group: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Group' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_ImplementedRequirement: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.ImplementedRequirement' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Import: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Import' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_ImportAp: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.ImportAp' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_ImportProfile: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.ImportProfile' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_ImportSsp: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.ImportSsp' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_InventoryItem: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.InventoryItem' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_LeveragedAuthorization: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.LeveragedAuthorization' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_LocalDefinitions: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.LocalDefinitions' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Merge: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Merge' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Metadata: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Metadata' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Modify: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Modify' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_NetworkArchitecture: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.NetworkArchitecture' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Observation: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Observation' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Party: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Party' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_PlanOfActionAndMilestones: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestones' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_PlanOfActionAndMilestonesLocalDefinitions: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_PoamItem: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.PoamItem' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Profile: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Profile' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Resource: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Resource' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Result: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Result' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Risk: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Risk' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Role: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Role' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Statement: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Statement' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_SystemCharacteristics: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.SystemCharacteristics' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_SystemComponent: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.SystemComponent' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_SystemId: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.SystemId' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_SystemImplementation: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.SystemImplementation' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_SystemSecurityPlan: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.SystemSecurityPlan' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_SystemUser: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.SystemUser' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Task: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Task' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-poam_PoamItemControlLink: properties: data: allOf: - $ref: '#/definitions/poam.PoamItemControlLink' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-poam_PoamItemEvidenceLink: properties: data: allOf: - $ref: '#/definitions/poam.PoamItemEvidenceLink' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-poam_PoamItemFindingLink: properties: data: allOf: - $ref: '#/definitions/poam.PoamItemFindingLink' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-poam_PoamItemRiskLink: properties: data: allOf: - $ref: '#/definitions/poam.PoamItemRiskLink' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-relational_Filter: properties: data: allOf: - $ref: '#/definitions/relational.Filter' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-relational_User: properties: data: allOf: - $ref: '#/definitions/relational.User' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-risks_RiskComponentLink: properties: data: allOf: - $ref: '#/definitions/risks.RiskComponentLink' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-risks_RiskControlLink: properties: data: allOf: - $ref: '#/definitions/risks.RiskControlLink' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-risks_RiskEvidenceLink: properties: data: allOf: - $ref: '#/definitions/risks.RiskEvidenceLink' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-risks_RiskSubjectLink: properties: data: allOf: - $ref: '#/definitions/risks.RiskSubjectLink' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-string: properties: data: - description: Items from the list response + description: Wrapped response data type: string type: object handler.HeartbeatCreateRequest: @@ -2200,6 +2228,40 @@ definitions: name: type: string type: object + handler.testNotificationRequest: + properties: + destinationTarget: + type: string + mode: + enum: + - enqueue + type: string + providerType: + enum: + - email + - slack + type: string + required: + - destinationTarget + - providerType + type: object + handler.testNotificationResponse: + properties: + accepted: + type: boolean + destinationTarget: + type: string + jobIds: + items: + type: integer + type: array + message: + type: string + mode: + type: string + providerType: + type: string + type: object handler.threatIDRequest: properties: id: @@ -2359,6 +2421,308 @@ definitions: query: $ref: '#/definitions/labelfilter.Query' type: object + notificationtroubleshooting.ConfiguredSystemDestinationResponse: + properties: + destinationTarget: + type: string + providerType: + type: string + type: object + notificationtroubleshooting.DiagnosticCheck: + properties: + code: + type: string + jobId: + type: integer + label: + type: string + message: + type: string + status: + type: string + type: object + notificationtroubleshooting.DiagnosticsResponse: + properties: + checks: + items: + $ref: '#/definitions/notificationtroubleshooting.DiagnosticCheck' + type: array + configuredDestinations: + items: + $ref: '#/definitions/notificationtroubleshooting.ConfiguredSystemDestinationResponse' + type: array + notificationName: + type: string + recommendedActions: + items: + type: string + type: array + status: + type: string + subscriberCounts: + $ref: '#/definitions/notificationtroubleshooting.SubscriberCounts' + type: object + notificationtroubleshooting.HealthResponse: + properties: + notifications: + items: + $ref: '#/definitions/notificationtroubleshooting.NotificationHealth' + type: array + providers: + items: + $ref: '#/definitions/notificationtroubleshooting.ProviderStatus' + type: array + schedules: + items: + $ref: '#/definitions/notificationtroubleshooting.ScheduleHealth' + type: array + warnings: + items: + $ref: '#/definitions/notificationtroubleshooting.TroubleshootingWarning' + type: array + worker: + $ref: '#/definitions/notificationtroubleshooting.WorkerHealth' + type: object + notificationtroubleshooting.JobDetail: + properties: + args: + additionalProperties: {} + type: object + attempt: + type: integer + attemptedAt: + format: date-time + type: string + correlationId: + type: string + createdAt: + format: date-time + type: string + errors: + items: + $ref: '#/definitions/notificationtroubleshooting.SanitizedAttemptError' + type: array + finalizedAt: + format: date-time + type: string + id: + type: integer + kind: + type: string + lastError: + type: string + maxAttempts: + type: integer + metadata: + additionalProperties: + type: string + type: object + notificationKind: + type: string + provider: + type: string + queue: + type: string + scheduledAt: + format: date-time + type: string + sourceJobId: + type: string + sourceJobKind: + type: string + stale: + type: boolean + state: + type: string + target: + type: string + type: object + notificationtroubleshooting.JobListItem: + properties: + attempt: + type: integer + attemptedAt: + format: date-time + type: string + correlationId: + type: string + createdAt: + format: date-time + type: string + finalizedAt: + format: date-time + type: string + id: + type: integer + kind: + type: string + lastError: + type: string + maxAttempts: + type: integer + notificationKind: + type: string + provider: + type: string + queue: + type: string + scheduledAt: + format: date-time + type: string + sourceJobId: + type: string + sourceJobKind: + type: string + stale: + type: boolean + state: + type: string + target: + type: string + type: object + notificationtroubleshooting.JobSummary: + properties: + createdAt: + format: date-time + type: string + finalizedAt: + format: date-time + type: string + id: + type: integer + state: + type: string + type: object + notificationtroubleshooting.JobsListResponse: + properties: + data: + items: + $ref: '#/definitions/notificationtroubleshooting.JobListItem' + type: array + pagination: + $ref: '#/definitions/notificationtroubleshooting.Pagination' + type: object + notificationtroubleshooting.NotificationHealth: + properties: + configuredDestinations: + items: + $ref: '#/definitions/notificationtroubleshooting.ConfiguredSystemDestinationResponse' + type: array + name: + type: string + subscriberCounts: + $ref: '#/definitions/notificationtroubleshooting.SubscriberCounts' + warnings: + items: + $ref: '#/definitions/notificationtroubleshooting.TroubleshootingWarning' + type: array + type: object + notificationtroubleshooting.Pagination: + properties: + nextCursor: + type: string + type: object + notificationtroubleshooting.ProviderStatus: + properties: + description: + type: string + displayName: + type: string + enabled: + type: boolean + metadata: + additionalProperties: + type: string + type: object + providerType: + type: string + type: object + notificationtroubleshooting.QueueSummary: + properties: + available: + type: integer + completed24h: + type: integer + discarded24h: + type: integer + maxWorkers: + type: integer + name: + type: string + oldestAvailableAt: + type: string + retryable: + type: integer + running: + type: integer + scheduled: + type: integer + staleCount: + type: integer + staleThresholdSeconds: + type: integer + type: object + notificationtroubleshooting.SanitizedAttemptError: + properties: + at: + type: string + attempt: + type: integer + error: + type: string + type: object + notificationtroubleshooting.ScheduleHealth: + properties: + deliveryKind: + type: string + enabled: + type: boolean + jobKind: + type: string + lastJob: + $ref: '#/definitions/notificationtroubleshooting.JobSummary' + name: + type: string + nextRunAt: + type: string + queue: + type: string + schedule: + type: string + type: object + notificationtroubleshooting.SubscriberCounts: + properties: + email: + type: integer + slack: + type: integer + totalUsers: + type: integer + type: object + notificationtroubleshooting.TroubleshootingWarning: + properties: + code: + type: string + message: + type: string + severity: + type: string + target: + type: string + type: object + notificationtroubleshooting.WorkerHealth: + properties: + enabled: + type: boolean + mode: + type: string + pollOnly: + type: boolean + queues: + items: + $ref: '#/definitions/notificationtroubleshooting.QueueSummary' + type: array + type: object oscal.ApplySuggestionRequest: properties: component-definition-id: @@ -9310,6 +9674,184 @@ paths: summary: Create system notification destination tags: - Notifications + /admin/notifications/{notificationName}/diagnostics: + get: + description: Runs read-only diagnostics for evidence digest, workflow, risk, + or POAM notifications + parameters: + - description: Notification name or family + in: path + name: notificationName + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-notificationtroubleshooting_DiagnosticsResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + 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 notification diagnostics + tags: + - Notifications + /admin/notifications/health: + get: + description: Returns provider, worker, queue, subscriber, destination, and schedule + health for admin notification troubleshooting + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-notificationtroubleshooting_HealthResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Get notification troubleshooting health + tags: + - Notifications + /admin/notifications/jobs: + get: + description: Lists recent notification-related River jobs with sanitized notification + metadata + parameters: + - collectionFormat: csv + description: Queue filter; repeat or comma-separate values + in: query + items: + type: string + name: queue + type: array + - description: 'Provider filter: email or slack' + enum: + - email + - slack + in: query + name: provider + type: string + - description: Notification kind filter + in: query + name: notificationKind + type: string + - collectionFormat: csv + description: River state filter; repeat or comma-separate values + in: query + items: + enum: + - available + - cancelled + - completed + - discarded + - pending + - retryable + - running + - scheduled + type: string + name: state + type: array + - description: RFC3339 lower bound for job creation time + format: date-time + in: query + name: since + type: string + - description: Page size, default 50, max 200 + in: query + maximum: 200 + minimum: 1 + name: limit + type: integer + - description: Opaque pagination cursor + in: query + name: cursor + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/notificationtroubleshooting.JobsListResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List notification River jobs + tags: + - Notifications + /admin/notifications/jobs/{id}: + get: + description: Returns one sanitized notification-related River job with attempt + errors + parameters: + - description: River job ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-notificationtroubleshooting_JobDetail' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + 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 notification River job detail + tags: + - Notifications /admin/notifications/providers: get: description: Returns notification providers registered in the backend @@ -9333,6 +9875,47 @@ paths: summary: List available notification providers tags: - Notifications + /admin/notifications/test: + post: + consumes: + - application/json + description: Enqueues a fixed server-side test notification to a validated admin-supplied + destination + parameters: + - description: Test destination + in: body + name: request + required: true + schema: + $ref: '#/definitions/handler.testNotificationRequest' + produces: + - application/json + responses: + "202": + description: Accepted + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_testNotificationResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Enqueue fixed test notification + tags: + - Notifications /admin/risk-templates: get: description: List risk templates with optional filters and pagination. diff --git a/internal/api/handler/api.go b/internal/api/handler/api.go index 0939a926..48665f6b 100644 --- a/internal/api/handler/api.go +++ b/internal/api/handler/api.go @@ -9,6 +9,7 @@ import ( "github.com/compliance-framework/api/internal/api/middleware" "github.com/compliance-framework/api/internal/config" "github.com/compliance-framework/api/internal/service/digest" + "github.com/compliance-framework/api/internal/service/notification" evidencesvc "github.com/compliance-framework/api/internal/service/relational/evidence" poamsvc "github.com/compliance-framework/api/internal/service/relational/poam" riskrel "github.com/compliance-framework/api/internal/service/relational/risks" @@ -21,12 +22,13 @@ import ( // APIServices contains all services needed by API handlers type APIServices struct { - EvidenceService *evidencesvc.EvidenceService - RiskEnqueuer evidencesvc.RiskJobEnqueuer - DigestService *digest.Service - WorkflowManager *workflow.Manager - NotificationEnqueuer workflow.NotificationEnqueuer - DAGExecutor *workflow.DAGExecutor + EvidenceService *evidencesvc.EvidenceService + RiskEnqueuer evidencesvc.RiskJobEnqueuer + DigestService *digest.Service + WorkflowManager *workflow.Manager + NotificationEnqueuer workflow.NotificationEnqueuer + NotificationWorkerEnqueuer notification.WorkerEnqueuer + DAGExecutor *workflow.DAGExecutor } func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB, config *config.Config, services *APIServices) { @@ -121,7 +123,7 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB userGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) userHandler.RegisterPublicRoutes(userGroup) - notificationsHandler := NewNotificationsHandler(logger, db, config) + notificationsHandler := NewNotificationsHandler(logger, db, config, services.NotificationWorkerEnqueuer) notificationsPublicGroup := server.API().Group("/notifications") notificationsPublicGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) notificationsHandler.RegisterPublic(notificationsPublicGroup) diff --git a/internal/api/handler/notifications.go b/internal/api/handler/notifications.go index af7d88b5..ecd2bd8e 100644 --- a/internal/api/handler/notifications.go +++ b/internal/api/handler/notifications.go @@ -8,12 +8,17 @@ import ( "net/http" "reflect" "sort" + "strconv" "strings" + "time" "github.com/compliance-framework/api/internal/api" "github.com/compliance-framework/api/internal/config" "github.com/compliance-framework/api/internal/service/notification" notificationproviders "github.com/compliance-framework/api/internal/service/notification/providers" + emailprovider "github.com/compliance-framework/api/internal/service/notification/providers/email" + slackprovider "github.com/compliance-framework/api/internal/service/notification/providers/slack" + notificationtroubleshooting "github.com/compliance-framework/api/internal/service/notificationtroubleshooting" "github.com/compliance-framework/api/internal/service/relational" "github.com/labstack/echo/v4" "go.uber.org/zap" @@ -22,9 +27,18 @@ import ( ) type NotificationsHandler struct { - sugar *zap.SugaredLogger - db *gorm.DB - providers notification.ProviderLookup + sugar *zap.SugaredLogger + db *gorm.DB + cfg *config.Config + providers notification.ProviderLookup + troubleshooting *notificationtroubleshooting.Service + enqueuer notificationTestEnqueuer +} + +type notificationTestEnqueuer interface { + notification.WorkerEnqueuer + emailprovider.Enqueuer + slackprovider.Enqueuer } type configuredSystemDestinationResponse struct { @@ -55,6 +69,21 @@ type createSystemNotificationDestinationRequest struct { DestinationTarget string `json:"destinationTarget" validate:"required"` } +type testNotificationRequest struct { + ProviderType string `json:"providerType" validate:"required,oneof=email slack" enums:"email,slack"` + DestinationTarget string `json:"destinationTarget" validate:"required"` + Mode string `json:"mode" validate:"omitempty,oneof=enqueue" enums:"enqueue"` +} + +type testNotificationResponse struct { + Accepted bool `json:"accepted"` + Mode string `json:"mode"` + ProviderType string `json:"providerType"` + DestinationTarget string `json:"destinationTarget"` + JobIDs []int64 `json:"jobIds"` + Message string `json:"message"` +} + func (r *createSystemNotificationDestinationRequest) UnmarshalJSON(data []byte) error { type requestAlias struct { ProviderTypeCamel string `json:"providerType"` @@ -73,15 +102,24 @@ func (r *createSystemNotificationDestinationRequest) UnmarshalJSON(data []byte) return nil } -func NewNotificationsHandler(sugar *zap.SugaredLogger, db *gorm.DB, cfg *config.Config) *NotificationsHandler { +func NewNotificationsHandler(sugar *zap.SugaredLogger, db *gorm.DB, cfg *config.Config, enqueuer notification.WorkerEnqueuer) *NotificationsHandler { + testEnqueuer, _ := enqueuer.(notificationTestEnqueuer) return &NotificationsHandler{ - sugar: sugar, - db: db, - providers: notificationproviders.NewLookup(notificationproviders.WithConfig(cfg)), + sugar: sugar, + db: db, + cfg: cfg, + providers: notificationproviders.NewLookup(notificationproviders.WithConfig(cfg)), + troubleshooting: notificationtroubleshooting.New(db, cfg), + enqueuer: testEnqueuer, } } func (h *NotificationsHandler) Register(api *echo.Group) { + api.GET("/health", h.GetTroubleshootingHealth) + api.GET("/jobs", h.ListTroubleshootingJobs) + api.GET("/jobs/:id", h.GetTroubleshootingJob) + api.POST("/test", h.SendTestNotification) + api.GET("/:notificationName/diagnostics", h.GetNotificationDiagnostics) api.GET("", h.ListSystemNotifications) api.GET("/providers", h.ListNotificationProviders) api.POST("/:notificationName/destinations", h.CreateSystemNotificationDestination) @@ -92,6 +130,208 @@ func (h *NotificationsHandler) RegisterPublic(api *echo.Group) { api.GET("/providers", h.ListNotificationProviderStatus) } +// GetTroubleshootingHealth godoc +// +// @Summary Get notification troubleshooting health +// @Description Returns provider, worker, queue, subscriber, destination, and schedule health for admin notification troubleshooting +// @Tags Notifications +// @Produce json +// @Success 200 {object} handler.GenericDataResponse[notificationtroubleshooting.HealthResponse] +// @Failure 401 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/notifications/health [get] +func (h *NotificationsHandler) GetTroubleshootingHealth(ctx echo.Context) error { + response, err := h.troubleshooting.Health(ctx.Request().Context()) + if err != nil { + h.sugar.Errorw("Failed to get notification troubleshooting health", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + return ctx.JSON(http.StatusOK, GenericDataResponse[notificationtroubleshooting.HealthResponse]{Data: response}) +} + +// ListTroubleshootingJobs godoc +// +// @Summary List notification River jobs +// @Description Lists recent notification-related River jobs with sanitized notification metadata +// @Tags Notifications +// @Produce json +// @Param queue query []string false "Queue filter; repeat or comma-separate values" +// @Param provider query string false "Provider filter: email or slack" Enums(email, slack) +// @Param notificationKind query string false "Notification kind filter" +// @Param state query []string false "River state filter; repeat or comma-separate values" Enums(available, cancelled, completed, discarded, pending, retryable, running, scheduled) +// @Param since query string false "RFC3339 lower bound for job creation time" Format(date-time) +// @Param limit query int false "Page size, default 50, max 200" minimum(1) maximum(200) +// @Param cursor query string false "Opaque pagination cursor" +// @Success 200 {object} notificationtroubleshooting.JobsListResponse +// @Failure 400 {object} api.Error +// @Failure 401 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/notifications/jobs [get] +func (h *NotificationsHandler) ListTroubleshootingJobs(ctx echo.Context) error { + query, err := parseTroubleshootingJobsQuery(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + response, err := h.troubleshooting.Jobs(ctx.Request().Context(), query) + if err != nil { + h.sugar.Warnw("Failed to list notification troubleshooting jobs", "error", err) + if notificationtroubleshooting.IsInvalidJobsQuery(err) { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + return ctx.JSON(http.StatusOK, response) +} + +// GetTroubleshootingJob godoc +// +// @Summary Get notification River job detail +// @Description Returns one sanitized notification-related River job with attempt errors +// @Tags Notifications +// @Produce json +// @Param id path int true "River job ID" +// @Success 200 {object} handler.GenericDataResponse[notificationtroubleshooting.JobDetail] +// @Failure 400 {object} api.Error +// @Failure 401 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/notifications/jobs/{id} [get] +func (h *NotificationsHandler) GetTroubleshootingJob(ctx echo.Context) error { + id, err := strconv.ParseInt(ctx.Param("id"), 10, 64) + if err != nil || id <= 0 { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("invalid job id"))) + } + response, ok, err := h.troubleshooting.Job(ctx.Request().Context(), id) + if err != nil { + h.sugar.Errorw("Failed to get notification troubleshooting job", "error", err, "jobID", id) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + if !ok { + return ctx.JSON(http.StatusNotFound, api.NotFound()) + } + return ctx.JSON(http.StatusOK, GenericDataResponse[notificationtroubleshooting.JobDetail]{Data: response}) +} + +// GetNotificationDiagnostics godoc +// +// @Summary Get notification diagnostics +// @Description Runs read-only diagnostics for evidence digest, workflow, risk, or POAM notifications +// @Tags Notifications +// @Produce json +// @Param notificationName path string true "Notification name or family" +// @Success 200 {object} handler.GenericDataResponse[notificationtroubleshooting.DiagnosticsResponse] +// @Failure 400 {object} api.Error +// @Failure 401 {object} api.Error +// @Failure 404 {object} api.Error "Not Found" +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/notifications/{notificationName}/diagnostics [get] +func (h *NotificationsHandler) GetNotificationDiagnostics(ctx echo.Context) error { + response, err := h.troubleshooting.Diagnostics(ctx.Request().Context(), ctx.Param("notificationName")) + if err != nil { + if notificationtroubleshooting.IsUnsupportedNotificationName(err) { + return ctx.JSON(http.StatusNotFound, api.NotFound()) + } + h.sugar.Errorw("Failed to get notification diagnostics", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + return ctx.JSON(http.StatusOK, GenericDataResponse[notificationtroubleshooting.DiagnosticsResponse]{Data: response}) +} + +// SendTestNotification godoc +// +// @Summary Enqueue fixed test notification +// @Description Enqueues a fixed server-side test notification to a validated admin-supplied destination +// @Tags Notifications +// @Accept json +// @Produce json +// @Param request body handler.testNotificationRequest true "Test destination" +// @Success 202 {object} handler.GenericDataResponse[handler.testNotificationResponse] +// @Failure 400 {object} api.Error +// @Failure 401 {object} api.Error +// @Failure 500 {object} api.Error +// @Failure 503 {object} api.Error +// @Security OAuth2Password +// @Router /admin/notifications/test [post] +func (h *NotificationsHandler) SendTestNotification(ctx echo.Context) error { + if h.enqueuer == nil || !h.enqueuer.IsStarted() { + return ctx.JSON(http.StatusServiceUnavailable, api.NewError(errors.New("notification worker enqueuer is not available"))) + } + + var req testNotificationRequest + if err := ctx.Bind(&req); err != nil { + h.sugar.Errorw("Failed to bind test notification request", "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := ctx.Validate(&req); err != nil { + return ctx.JSON(http.StatusBadRequest, api.Validator(err)) + } + + mode := strings.ToLower(strings.TrimSpace(req.Mode)) + if mode == "" { + mode = "enqueue" + } + if mode != "enqueue" { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("unsupported test notification mode"))) + } + + provider, ok := notification.NormalizeDeliveryChannel(req.ProviderType) + if !ok || (provider != notification.DeliveryChannelEmail && provider != notification.DeliveryChannelSlack) { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("unsupported notification provider %q", req.ProviderType))) + } + target, err := h.buildTarget(provider, req.DestinationTarget) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + metadata := notification.TransportMetadata{ + NotificationKind: notification.Kind("admin_test_notification"), + Provider: provider, + Channel: provider, + Target: req.DestinationTarget, + CorrelationID: "admin-test-notification:" + time.Now().UTC().Format(time.RFC3339Nano), + SourceJobKind: "admin_test_notification", + } + var jobIDs []int64 + switch provider { + case notification.DeliveryChannelEmail: + jobIDs, err = h.enqueuer.EnqueueNotificationEmail(ctx.Request().Context(), emailprovider.Delivery{ + To: target.Address[emailprovider.AddressKeyEmail], + Content: emailprovider.Content{ + From: h.defaultTestEmailFrom(), + Subject: "Compliance Framework test notification", + TextBody: "This is a fixed test notification from Compliance Framework.", + }, + Metadata: metadata, + }) + case notification.DeliveryChannelSlack: + jobIDs, err = h.enqueuer.EnqueueNotificationSlack(ctx.Request().Context(), slackprovider.Delivery{ + Channel: target.Address[slackprovider.AddressKeyChannel], + TargetType: target.Address[slackprovider.AddressKeyTargetType], + Content: slackprovider.Content{ + Text: "Compliance Framework test notification.", + }, + Metadata: metadata, + }) + } + if err != nil { + h.sugar.Errorw("Failed to enqueue test notification", "error", err, "provider", provider) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.JSON(http.StatusAccepted, GenericDataResponse[testNotificationResponse]{Data: testNotificationResponse{ + Accepted: true, + Mode: mode, + ProviderType: provider, + DestinationTarget: req.DestinationTarget, + JobIDs: jobIDs, + Message: "Test notification enqueued. Use jobIds to inspect deliveries.", + }}) +} + // ListNotificationProviders godoc // // @Summary List available notification providers @@ -543,6 +783,53 @@ func (h *NotificationsHandler) targetsMatch(left notification.Target, right noti return strings.EqualFold(leftResponse.DestinationTarget, rightResponse.DestinationTarget), nil } +func parseTroubleshootingJobsQuery(ctx echo.Context) (notificationtroubleshooting.JobsQuery, error) { + query := notificationtroubleshooting.JobsQuery{ + Queues: ctx.QueryParams()["queue"], + Provider: strings.TrimSpace(ctx.QueryParam("provider")), + NotificationKind: strings.TrimSpace(ctx.QueryParam("notificationKind")), + States: ctx.QueryParams()["state"], + Cursor: strings.TrimSpace(ctx.QueryParam("cursor")), + } + if since := strings.TrimSpace(ctx.QueryParam("since")); since != "" { + parsed, err := time.Parse(time.RFC3339, since) + if err != nil { + return query, fmt.Errorf("since must be an RFC3339 timestamp") + } + query.Since = &parsed + } + if limit := strings.TrimSpace(ctx.QueryParam("limit")); limit != "" { + parsed, err := strconv.Atoi(limit) + if err != nil { + return query, fmt.Errorf("limit must be an integer") + } + query.Limit = parsed + } + return query, nil +} + +func (h *NotificationsHandler) defaultTestEmailFrom() string { + if h.cfg != nil && h.cfg.Email != nil { + if provider := h.cfg.Email.GetDefaultProvider(); provider != nil { + if from := emailFromAddress(provider); from != "" { + return from + } + } + } + return "noreply@localhost" +} + +func emailFromAddress(provider config.EmailProviderSettings) string { + switch typed := provider.(type) { + case *config.SMTPConfig: + return strings.TrimSpace(typed.From) + case *config.SESConfig: + return strings.TrimSpace(typed.From) + default: + return "" + } +} + func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { diff --git a/internal/api/handler/notifications_integration_test.go b/internal/api/handler/notifications_integration_test.go index 63ce2ec4..1e2fba4e 100644 --- a/internal/api/handler/notifications_integration_test.go +++ b/internal/api/handler/notifications_integration_test.go @@ -15,6 +15,7 @@ import ( "github.com/compliance-framework/api/internal/service/notification" emailprovider "github.com/compliance-framework/api/internal/service/notification/providers/email" slackprovider "github.com/compliance-framework/api/internal/service/notification/providers/slack" + "github.com/compliance-framework/api/internal/service/notificationtroubleshooting" "github.com/compliance-framework/api/internal/service/relational" "github.com/compliance-framework/api/internal/tests" "github.com/stretchr/testify/suite" @@ -48,6 +49,13 @@ func (suite *NotificationsApiIntegrationSuite) SetupSuite() { }, }, } + suite.Config.Worker = &config.WorkerConfig{ + Enabled: true, + Workers: 7, + UsePolling: true, + } + suite.Config.DigestEnabled = true + suite.Config.DigestSchedule = "@daily" logger, _ := zap.NewDevelopment() suite.logger = logger.Sugar() @@ -123,6 +131,132 @@ func (suite *NotificationsApiIntegrationSuite) TestListNotificationProvidersUnau suite.Equal(http.StatusUnauthorized, rec.Code, "Expected Unauthorized response for missing token") } +func (suite *NotificationsApiIntegrationSuite) TestTroubleshootingRoutesUnauthorized() { + routes := []struct { + method string + path string + body []byte + }{ + {http.MethodGet, "/api/admin/notifications/health", nil}, + {http.MethodGet, "/api/admin/notifications/jobs", nil}, + {http.MethodGet, "/api/admin/notifications/jobs/1", nil}, + {http.MethodGet, "/api/admin/notifications/EVIDENCE_DIGEST/diagnostics", nil}, + {http.MethodPost, "/api/admin/notifications/test", []byte(`{"providerType":"slack","destinationTarget":"ccf-alerts"}`)}, + } + + for _, route := range routes { + rec := httptest.NewRecorder() + req := httptest.NewRequest(route.method, route.path, bytes.NewReader(route.body)) + if route.body != nil { + req.Header.Set("Content-Type", "application/json") + } + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusUnauthorized, rec.Code, "Expected Unauthorized response for "+route.path) + } +} + +func (suite *NotificationsApiIntegrationSuite) TestTroubleshootingHealthIncludesOperationalState() { + user := relational.User{ + Email: "digest-subscriber@example.com", + FirstName: "Digest", + LastName: "Subscriber", + IsActive: true, + IsLocked: false, + AuthMethod: "password", + } + suite.Require().NoError(user.SetPassword("password")) + suite.Require().NoError(suite.DB.Create(&user).Error) + suite.Require().NotNil(user.ID) + + suite.Require().NoError(suite.DB.Create(&relational.UserNotificationSubscription{ + UserID: user.ID.String(), + NotificationType: notification.SubscriptionGateEvidenceDigest, + Channels: []string{notification.DeliveryChannelEmail, notification.DeliveryChannelSlack}, + }).Error) + suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.SystemNotificationNameEvidenceDigest, + Provider: notification.DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + slackprovider.AddressKeyChannel: "ccf-alerts", + slackprovider.AddressKeyTargetType: slackprovider.TargetTypeChannel, + }, + }), + }).Error) + + rec, req := suite.authedRequest(http.MethodGet, "/api/admin/notifications/health") + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusOK, rec.Code, rec.Body.String()) + + var response GenericDataResponse[notificationtroubleshooting.HealthResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &response)) + + suite.True(response.Data.Worker.Enabled) + suite.Equal("polling", response.Data.Worker.Mode) + suite.True(response.Data.Worker.PollOnly) + suite.NotEmpty(response.Data.Worker.Queues) + + var slackQueue notificationtroubleshooting.QueueSummary + for _, queue := range response.Data.Worker.Queues { + if queue.Name == notification.DeliveryChannelSlack { + slackQueue = queue + break + } + } + suite.Equal(int64(notificationtroubleshooting.ProviderStaleThresholdSeconds), slackQueue.StaleThresholdSeconds) + suite.Equal(7, slackQueue.MaxWorkers) + + var emailProvider, slackProvider *notificationtroubleshooting.ProviderStatus + for i := range response.Data.Providers { + switch response.Data.Providers[i].ProviderType { + case notification.DeliveryChannelEmail: + emailProvider = &response.Data.Providers[i] + case notification.DeliveryChannelSlack: + slackProvider = &response.Data.Providers[i] + } + } + suite.Require().NotNil(emailProvider) + suite.True(emailProvider.Enabled) + suite.Require().NotNil(slackProvider) + suite.False(slackProvider.Enabled) + + var digestHealth *notificationtroubleshooting.NotificationHealth + for i := range response.Data.Notifications { + if response.Data.Notifications[i].Name == "EVIDENCE_DIGEST" { + digestHealth = &response.Data.Notifications[i] + break + } + } + suite.Require().NotNil(digestHealth) + suite.Equal(int64(1), digestHealth.SubscriberCounts.Email) + suite.Equal(int64(1), digestHealth.SubscriberCounts.Slack) + suite.Equal(int64(1), digestHealth.SubscriberCounts.TotalUsers) + suite.Len(digestHealth.ConfiguredDestinations, 1) + suite.Equal("ccf-alerts", digestHealth.ConfiguredDestinations[0].DestinationTarget) + + var digestSchedule *notificationtroubleshooting.ScheduleHealth + for i := range response.Data.Schedules { + if response.Data.Schedules[i].Name == "EVIDENCE_DIGEST" { + digestSchedule = &response.Data.Schedules[i] + break + } + } + suite.Require().NotNil(digestSchedule) + suite.True(digestSchedule.Enabled) + suite.Equal("send_global_digest", digestSchedule.JobKind) + suite.NotNil(digestSchedule.NextRunAt) + suite.NotEmpty(response.Data.Warnings) +} + +func (suite *NotificationsApiIntegrationSuite) TestNotificationDiagnosticsReturnsNotFoundForUnsupportedName() { + rec, req := suite.authedRequest(http.MethodGet, "/api/admin/notifications/not_real/diagnostics") + + suite.server.E().ServeHTTP(rec, req) + + suite.Equal(http.StatusNotFound, rec.Code, rec.Body.String()) +} + func (suite *NotificationsApiIntegrationSuite) TestListNotificationProviderStatus() { rec, req := suite.authedRequest(http.MethodGet, "/api/notifications/providers") diff --git a/internal/api/handler/notifications_test.go b/internal/api/handler/notifications_test.go new file mode 100644 index 00000000..0f2abe96 --- /dev/null +++ b/internal/api/handler/notifications_test.go @@ -0,0 +1,73 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/compliance-framework/api/internal/api/middleware" + "github.com/compliance-framework/api/internal/config" + "github.com/compliance-framework/api/internal/service/notification" + notificationproviders "github.com/compliance-framework/api/internal/service/notification/providers" + emailprovider "github.com/compliance-framework/api/internal/service/notification/providers/email" + slackprovider "github.com/compliance-framework/api/internal/service/notification/providers/slack" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +type stubNotificationTestEnqueuer struct { + started bool + jobIDs []int64 +} + +func (s *stubNotificationTestEnqueuer) IsStarted() bool { + return s.started +} + +func (s *stubNotificationTestEnqueuer) EnqueueNotificationEmail(_ context.Context, _ emailprovider.Delivery) ([]int64, error) { + return append([]int64(nil), s.jobIDs...), nil +} + +func (s *stubNotificationTestEnqueuer) EnqueueNotificationSlack(_ context.Context, _ slackprovider.Delivery) ([]int64, error) { + return append([]int64(nil), s.jobIDs...), nil +} + +func TestSendTestNotificationReturnsJobIDs(t *testing.T) { + e := echo.New() + e.Validator = middleware.NewValidator() + + handler := &NotificationsHandler{ + sugar: zap.NewNop().Sugar(), + cfg: &config.Config{}, + providers: notificationproviders.NewLookup(), + enqueuer: &stubNotificationTestEnqueuer{ + started: true, + jobIDs: []int64{42}, + }, + } + + payload := []byte(`{"providerType":"email","destinationTarget":"alerts@example.com"}`) + req := httptest.NewRequest(http.MethodPost, "/api/admin/notifications/test", bytes.NewReader(payload)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + + err := handler.SendTestNotification(e.NewContext(req, rec)) + require.NoError(t, err) + require.Equal(t, http.StatusAccepted, rec.Code) + + var raw map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &raw)) + data, ok := raw["data"].(map[string]any) + require.True(t, ok) + require.NotContains(t, data, "correlationId") + require.Equal(t, []any{float64(42)}, data["jobIds"]) + + var response GenericDataResponse[testNotificationResponse] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response)) + require.Equal(t, []int64{42}, response.Data.JobIDs) + require.Equal(t, notification.DeliveryChannelEmail, response.Data.ProviderType) +} diff --git a/internal/api/handler/response.go b/internal/api/handler/response.go index b4db9215..37def806 100644 --- a/internal/api/handler/response.go +++ b/internal/api/handler/response.go @@ -1,7 +1,7 @@ package handler type GenericDataResponse[T any] struct { - // Items from the list response + // Wrapped response data Data T `json:"data" yaml:"data"` } diff --git a/internal/service/digest/notifications.go b/internal/service/digest/notifications.go index 2741bffe..c70784f6 100644 --- a/internal/service/digest/notifications.go +++ b/internal/service/digest/notifications.go @@ -216,9 +216,22 @@ func (s *Service) dispatchEvidenceDigestNotifications( generatedAt time.Time, includeConfiguredDestinations bool, includeSubscribedUsers bool, +) error { + return s.dispatchEvidenceDigestNotificationsWithSourceJobID(ctx, summary, webBaseURL, generatedAt, includeConfiguredDestinations, includeSubscribedUsers, "") +} + +func (s *Service) dispatchEvidenceDigestNotificationsWithSourceJobID( + ctx context.Context, + summary *EvidenceSummary, + webBaseURL string, + generatedAt time.Time, + includeConfiguredDestinations bool, + includeSubscribedUsers bool, + sourceJobID string, ) error { request := notification.FanoutRequest{} dispatchOptions := evidenceDigestDispatchOptions(generatedAt) + dispatchOptions.SourceJobID = strings.TrimSpace(sourceJobID) if includeConfiguredDestinations { targets, err := s.configuredDigestTargets(ctx) diff --git a/internal/service/digest/service.go b/internal/service/digest/service.go index 73e3fab9..04a0794f 100644 --- a/internal/service/digest/service.go +++ b/internal/service/digest/service.go @@ -241,6 +241,16 @@ func (s *Service) GetDigestRecipients(ctx context.Context) ([]DigestRecipient, e // SendGlobalDigest sends or enqueues the global digest to all active users. func (s *Service) SendGlobalDigest(ctx context.Context) error { + return s.sendGlobalDigest(ctx, "") +} + +// SendGlobalDigestWithSourceJobID sends the global digest while propagating the +// River source job ID into downstream provider jobs. +func (s *Service) SendGlobalDigestWithSourceJobID(ctx context.Context, sourceJobID string) error { + return s.sendGlobalDigest(ctx, sourceJobID) +} + +func (s *Service) sendGlobalDigest(ctx context.Context, sourceJobID string) error { summary, err := s.GetGlobalEvidenceSummary(ctx) if err != nil { return fmt.Errorf("failed to get evidence summary: %w", err) @@ -268,7 +278,7 @@ func (s *Service) SendGlobalDigest(ctx context.Context) error { } if !sendUserDigests { - return s.dispatchEvidenceDigestNotifications(ctx, summary, webBaseURL, generatedAt, sendConfiguredDestinations, false) + return s.dispatchEvidenceDigestNotificationsWithSourceJobID(ctx, summary, webBaseURL, generatedAt, sendConfiguredDestinations, false, sourceJobID) } s.logger.Debugw("Sending global digest", @@ -277,5 +287,5 @@ func (s *Service) SendGlobalDigest(ctx context.Context) error { "expired", summary.ExpiredCount, ) - return s.dispatchEvidenceDigestNotifications(ctx, summary, webBaseURL, generatedAt, sendConfiguredDestinations, true) + return s.dispatchEvidenceDigestNotificationsWithSourceJobID(ctx, summary, webBaseURL, generatedAt, sendConfiguredDestinations, true, sourceJobID) } diff --git a/internal/service/notification/providers/email/provider.go b/internal/service/notification/providers/email/provider.go index a40617e2..ccb5796d 100644 --- a/internal/service/notification/providers/email/provider.go +++ b/internal/service/notification/providers/email/provider.go @@ -23,7 +23,7 @@ type ServiceProviderDescriptor struct { type Enqueuer interface { IsStarted() bool - EnqueueNotificationEmail(ctx context.Context, delivery Delivery) error + EnqueueNotificationEmail(ctx context.Context, delivery Delivery) ([]int64, error) } type ContentRenderer interface { @@ -319,7 +319,7 @@ func (p *Provider) Deliver(ctx context.Context, delivery notification.Delivery) } if enqueuer := p.enqueuer(); enqueuer != nil && enqueuer.IsStarted() { - if err := enqueuer.EnqueueNotificationEmail(ctx, providerDelivery); err != nil { + if _, err := enqueuer.EnqueueNotificationEmail(ctx, providerDelivery); err != nil { return fmt.Errorf("enqueue email delivery: %w", err) } return nil diff --git a/internal/service/notification/providers/email/provider_test.go b/internal/service/notification/providers/email/provider_test.go index c9b8db57..faf93e60 100644 --- a/internal/service/notification/providers/email/provider_test.go +++ b/internal/service/notification/providers/email/provider_test.go @@ -41,9 +41,9 @@ func (e *stubEnqueuer) IsStarted() bool { return e.started } -func (e *stubEnqueuer) EnqueueNotificationEmail(_ context.Context, delivery Delivery) error { +func (e *stubEnqueuer) EnqueueNotificationEmail(_ context.Context, delivery Delivery) ([]int64, error) { e.deliveries = append(e.deliveries, delivery) - return e.err + return []int64{int64(len(e.deliveries))}, e.err } type stubTemplateRenderer struct { diff --git a/internal/service/notification/providers/slack/provider.go b/internal/service/notification/providers/slack/provider.go index 1e562bd4..55f19d27 100644 --- a/internal/service/notification/providers/slack/provider.go +++ b/internal/service/notification/providers/slack/provider.go @@ -69,7 +69,7 @@ type Sender interface { type Enqueuer interface { IsStarted() bool - EnqueueNotificationSlack(ctx context.Context, delivery Delivery) error + EnqueueNotificationSlack(ctx context.Context, delivery Delivery) ([]int64, error) } type SenderProvider func() Sender @@ -346,7 +346,7 @@ func (p *Provider) Deliver(ctx context.Context, delivery notification.Delivery) } if enqueuer := p.enqueuer(); enqueuer != nil && enqueuer.IsStarted() { - if err := enqueuer.EnqueueNotificationSlack(ctx, providerDelivery); err != nil { + if _, err := enqueuer.EnqueueNotificationSlack(ctx, providerDelivery); err != nil { return fmt.Errorf("enqueue slack delivery: %w", err) } return nil diff --git a/internal/service/notification/transport_test.go b/internal/service/notification/transport_test.go index 859b9aca..e629907a 100644 --- a/internal/service/notification/transport_test.go +++ b/internal/service/notification/transport_test.go @@ -20,9 +20,9 @@ func (s *stubWorkerEnqueuer) IsStarted() bool { return s.started } -func (s *stubWorkerEnqueuer) EnqueueNotificationEmail(_ context.Context, delivery testEmailDelivery) error { +func (s *stubWorkerEnqueuer) EnqueueNotificationEmail(_ context.Context, delivery testEmailDelivery) ([]int64, error) { s.emails = append(s.emails, delivery) - return nil + return []int64{int64(len(s.emails))}, nil } type stubSlackEnqueuer struct { @@ -34,9 +34,9 @@ func (s *stubSlackEnqueuer) IsStarted() bool { return s.started } -func (s *stubSlackEnqueuer) EnqueueNotificationSlack(_ context.Context, delivery stubSlackDelivery) error { +func (s *stubSlackEnqueuer) EnqueueNotificationSlack(_ context.Context, delivery stubSlackDelivery) ([]int64, error) { s.slacks = append(s.slacks, delivery) - return nil + return []int64{int64(len(s.slacks))}, nil } type stubEmailSender struct { @@ -87,7 +87,8 @@ func (p *testEmailProvider) Deliver(ctx context.Context, delivery Delivery) erro to := delivery.Target.Address["email"] if p.enqueuer != nil && p.enqueuer.started { - return p.enqueuer.EnqueueNotificationEmail(ctx, testEmailDelivery{To: to, Content: payload}) + _, err := p.enqueuer.EnqueueNotificationEmail(ctx, testEmailDelivery{To: to, Content: payload}) + return err } if p.sender == nil || !p.sender.enabled { @@ -163,7 +164,8 @@ func (p *testSlackProvider) Deliver(ctx context.Context, delivery Delivery) erro channel := delivery.Target.Address["channel"] if p.enqueuer != nil && p.enqueuer.started { - return p.enqueuer.EnqueueNotificationSlack(ctx, stubSlackDelivery{Channel: channel, Text: payload.Text}) + _, err := p.enqueuer.EnqueueNotificationSlack(ctx, stubSlackDelivery{Channel: channel, Text: payload.Text}) + return err } if p.sender == nil || !p.sender.enabled { diff --git a/internal/service/notificationtroubleshooting/service.go b/internal/service/notificationtroubleshooting/service.go new file mode 100644 index 00000000..a13cebb8 --- /dev/null +++ b/internal/service/notificationtroubleshooting/service.go @@ -0,0 +1,1632 @@ +package notificationtroubleshooting + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "github.com/compliance-framework/api/internal/config" + "github.com/compliance-framework/api/internal/service/notification" + notificationproviders "github.com/compliance-framework/api/internal/service/notification/providers" + "github.com/compliance-framework/api/internal/service/relational" + "github.com/compliance-framework/api/internal/service/worker" + "github.com/robfig/cron/v3" + "gorm.io/gorm" +) + +const ( + ProviderStaleThresholdSeconds = 600 + SourceStaleThresholdSeconds = 1800 + + StatusPass = "pass" + StatusWarning = "warning" + StatusFail = "fail" +) + +var notificationQueues = []string{"email", "slack", "digest", "workflow", "risk", "poam", "scheduler"} + +var notificationJobKinds = []string{ + worker.JobTypeSendEmail, + worker.JobTypeSendEmailFrom, + worker.JobTypeSendSlackChannel, + worker.JobTypeSendSlackDM, + worker.JobTypeSendGlobalDigest, + worker.JobTypeWorkflowTaskAssigned, + worker.JobTypeWorkflowTaskDueSoon, + worker.JobTypeWorkflowTaskDigest, + worker.JobTypeWorkflowExecutionFailed, + worker.JobTypeWorkflowDueSoonChecker, + "workflow_task_digest_checker", + "schedule_workflows", + worker.JobTypeRiskReviewDeadlineReminderScanner, + worker.JobTypeRiskReviewOverdueEscalationScanner, + worker.JobTypeRiskStaleRiskScanner, + worker.JobTypeRiskOpenDigestScheduler, + worker.JobTypeRiskReviewDueReminder, + worker.JobTypeRiskReviewOverdueEscalation, + worker.JobTypeRiskStaleOpenReminder, + worker.JobTypeRiskOpenDigest, + worker.JobTypePoamDeadlineReminderScanner, + worker.JobTypePoamOverdueTransitionScanner, + worker.JobTypeMilestoneOverdueScannerScanner, + worker.JobTypePoamOpenDigestScheduler, + worker.JobTypePoamDeadlineReminder, + worker.JobTypePoamOverdueNotification, + worker.JobTypeMilestoneOverdueReminder, + worker.JobTypePoamOpenDigest, +} + +var providerJobKinds = map[string]struct{}{ + worker.JobTypeSendEmail: {}, + worker.JobTypeSendEmailFrom: {}, + worker.JobTypeSendSlackChannel: {}, + worker.JobTypeSendSlackDM: {}, +} + +var ( + ErrInvalidJobsQuery = errors.New("invalid notification troubleshooting jobs query") + ErrUnsupportedNotificationName = errors.New("unsupported notification name") +) + +func IsInvalidJobsQuery(err error) bool { + return errors.Is(err, ErrInvalidJobsQuery) +} + +func IsUnsupportedNotificationName(err error) bool { + return errors.Is(err, ErrUnsupportedNotificationName) +} + +type Service struct { + db *gorm.DB + cfg *config.Config + providers notification.ProviderLookup + now func() time.Time +} + +func New(db *gorm.DB, cfg *config.Config) *Service { + return &Service{ + db: db, + cfg: cfg, + providers: notificationproviders.NewLookup(notificationproviders.WithConfig(cfg)), + now: time.Now, + } +} + +type HealthResponse struct { + Worker WorkerHealth `json:"worker"` + Providers []ProviderStatus `json:"providers"` + Notifications []NotificationHealth `json:"notifications"` + Schedules []ScheduleHealth `json:"schedules"` + Warnings []TroubleshootingWarning `json:"warnings"` +} + +type WorkerHealth struct { + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + PollOnly bool `json:"pollOnly"` + Queues []QueueSummary `json:"queues"` +} + +type QueueSummary struct { + Name string `json:"name"` + MaxWorkers int `json:"maxWorkers"` + Available int64 `json:"available"` + Retryable int64 `json:"retryable"` + Running int64 `json:"running"` + Scheduled int64 `json:"scheduled"` + Completed24h int64 `json:"completed24h"` + Discarded24h int64 `json:"discarded24h"` + OldestAvailableAt *time.Time `json:"oldestAvailableAt"` + StaleCount int64 `json:"staleCount"` + StaleThresholdSeconds int64 `json:"staleThresholdSeconds"` +} + +type ProviderStatus struct { + ProviderType string `json:"providerType"` + Enabled bool `json:"enabled"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type NotificationHealth struct { + Name string `json:"name"` + ConfiguredDestinations []ConfiguredSystemDestinationResponse `json:"configuredDestinations"` + SubscriberCounts SubscriberCounts `json:"subscriberCounts"` + Warnings []TroubleshootingWarning `json:"warnings"` +} + +type ConfiguredSystemDestinationResponse struct { + ProviderType string `json:"providerType"` + DestinationTarget string `json:"destinationTarget"` +} + +type SubscriberCounts struct { + Email int64 `json:"email"` + Slack int64 `json:"slack"` + TotalUsers int64 `json:"totalUsers"` +} + +type ScheduleHealth struct { + Name string `json:"name"` + JobKind string `json:"jobKind"` + DeliveryKind string `json:"deliveryKind,omitempty"` + Queue string `json:"queue"` + Enabled bool `json:"enabled"` + Schedule string `json:"schedule"` + NextRunAt *time.Time `json:"nextRunAt"` + LastJob *JobSummary `json:"lastJob,omitempty"` +} + +type JobSummary struct { + ID int64 `json:"id"` + State string `json:"state"` + CreatedAt time.Time `json:"createdAt" format:"date-time"` + FinalizedAt *time.Time `json:"finalizedAt" format:"date-time"` +} + +type TroubleshootingWarning struct { + Code string `json:"code"` + Severity string `json:"severity"` + Message string `json:"message"` + Target string `json:"target,omitempty"` +} + +type JobsQuery struct { + Queues []string + Provider string + NotificationKind string + States []string + Since *time.Time + Limit int + Cursor string +} + +type JobsListResponse struct { + Data []JobListItem `json:"data"` + Pagination Pagination `json:"pagination"` +} + +type Pagination struct { + NextCursor string `json:"nextCursor,omitempty"` +} + +type JobListItem struct { + ID int64 `json:"id"` + State string `json:"state"` + Queue string `json:"queue"` + Kind string `json:"kind"` + Attempt int `json:"attempt"` + MaxAttempts int `json:"maxAttempts"` + CreatedAt time.Time `json:"createdAt" format:"date-time"` + ScheduledAt time.Time `json:"scheduledAt" format:"date-time"` + AttemptedAt *time.Time `json:"attemptedAt" format:"date-time"` + FinalizedAt *time.Time `json:"finalizedAt" format:"date-time"` + NotificationKind string `json:"notificationKind,omitempty"` + Provider string `json:"provider,omitempty"` + Target string `json:"target,omitempty"` + CorrelationID string `json:"correlationId,omitempty"` + SourceJobKind string `json:"sourceJobKind,omitempty"` + SourceJobID string `json:"sourceJobId,omitempty"` + LastError *string `json:"lastError"` + Stale bool `json:"stale"` +} + +type JobDetail struct { + JobListItem + Metadata map[string]string `json:"metadata"` + Args map[string]any `json:"args"` + Errors []SanitizedAttemptError `json:"errors"` +} + +type SanitizedAttemptError struct { + Attempt int `json:"attempt"` + At time.Time `json:"at"` + Error string `json:"error"` +} + +type DiagnosticsResponse struct { + NotificationName string `json:"notificationName"` + Status string `json:"status"` + Checks []DiagnosticCheck `json:"checks"` + RecommendedActions []string `json:"recommendedActions"` + SubscriberCounts SubscriberCounts `json:"subscriberCounts"` + ConfiguredDestinations []ConfiguredSystemDestinationResponse `json:"configuredDestinations"` +} + +type DiagnosticCheck struct { + Code string `json:"code"` + Label string `json:"label"` + Status string `json:"status"` + Message string `json:"message"` + JobID int64 `json:"jobId,omitempty"` +} + +type riverJobRecord struct { + ID int64 + State string + Queue string + Kind string + Attempt int + MaxAttempts int + CreatedAt time.Time + ScheduledAt time.Time + AttemptedAt *time.Time + FinalizedAt *time.Time + Args []byte + Metadata []byte + ErrorsJSON []byte `gorm:"column:errors_json"` +} + +func (s *Service) Health(ctx context.Context) (HealthResponse, error) { + queues, err := s.queueSummaries(ctx) + if err != nil { + return HealthResponse{}, err + } + + providers, err := s.providerStatuses() + if err != nil { + return HealthResponse{}, err + } + schedules, err := s.scheduleHealth(ctx) + if err != nil { + return HealthResponse{}, err + } + notifications, err := s.notificationHealth(ctx, providers) + if err != nil { + return HealthResponse{}, err + } + + response := HealthResponse{ + Worker: WorkerHealth{ + Enabled: s.workerEnabled(), + Mode: s.workerMode(), + PollOnly: s.workerPollOnly(), + Queues: queues, + }, + Providers: providers, + Notifications: notifications, + Schedules: schedules, + } + response.Warnings = s.healthWarnings(response) + return response, nil +} + +func (s *Service) Jobs(ctx context.Context, query JobsQuery) (JobsListResponse, error) { + if err := validateJobsQuery(&query); err != nil { + return JobsListResponse{}, fmt.Errorf("%w: %w", ErrInvalidJobsQuery, err) + } + if !s.hasRiverJobsTable() { + return JobsListResponse{Data: []JobListItem{}}, nil + } + + limit := normalizeLimit(query.Limit) + cursorID, err := decodeCursor(query.Cursor) + if err != nil { + return JobsListResponse{}, fmt.Errorf("%w: %w", ErrInvalidJobsQuery, err) + } + + dbq := s.db.WithContext(ctx).Table("river_job"). + Select("id, state::text AS state, queue, kind, attempt, max_attempts, created_at, scheduled_at, attempted_at, finalized_at, args, metadata, COALESCE(to_jsonb(errors), '[]'::jsonb) AS errors_json"). + Where("kind IN ?", notificationJobKinds) + + if len(query.Queues) > 0 { + dbq = dbq.Where("queue IN ?", query.Queues) + } + if query.Provider != "" { + switch query.Provider { + case notification.DeliveryChannelEmail: + dbq = dbq.Where("kind IN ?", []string{worker.JobTypeSendEmail, worker.JobTypeSendEmailFrom}) + case notification.DeliveryChannelSlack: + dbq = dbq.Where("kind IN ?", []string{worker.JobTypeSendSlackChannel, worker.JobTypeSendSlackDM}) + } + } + if query.NotificationKind != "" { + dbq = dbq.Where("args ->> 'notification_kind' = ?", query.NotificationKind) + } + if len(query.States) > 0 { + dbq = dbq.Where("state::text IN ?", query.States) + } + if query.Since != nil { + dbq = dbq.Where("created_at >= ?", *query.Since) + } + if cursorID > 0 { + dbq = dbq.Where("id < ?", cursorID) + } + + var rows []riverJobRecord + if err := dbq.Order("id DESC").Limit(limit + 1).Find(&rows).Error; err != nil { + return JobsListResponse{}, err + } + + nextCursor := "" + if len(rows) > limit { + nextCursor = encodeCursor(rows[limit-1].ID) + rows = rows[:limit] + } + + items := make([]JobListItem, 0, len(rows)) + for i := range rows { + items = append(items, s.jobListItem(rows[i])) + } + + return JobsListResponse{Data: items, Pagination: Pagination{NextCursor: nextCursor}}, nil +} + +func (s *Service) Job(ctx context.Context, id int64) (JobDetail, bool, error) { + if id <= 0 { + return JobDetail{}, false, fmt.Errorf("job id must be positive") + } + if !s.hasRiverJobsTable() { + return JobDetail{}, false, nil + } + + var row riverJobRecord + err := s.db.WithContext(ctx).Table("river_job"). + Select("id, state::text AS state, queue, kind, attempt, max_attempts, created_at, scheduled_at, attempted_at, finalized_at, args, metadata, COALESCE(to_jsonb(errors), '[]'::jsonb) AS errors_json"). + Where("id = ? AND kind IN ?", id, notificationJobKinds). + First(&row).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return JobDetail{}, false, nil + } + return JobDetail{}, false, err + } + + item := s.jobListItem(row) + detail := JobDetail{ + JobListItem: item, + Metadata: metadataForArgs(row.Args, row.Metadata, item), + Args: sanitizeArgs(row.Kind, parseJSONMap(row.Args)), + Errors: parseAttemptErrors(row.ErrorsJSON), + } + return detail, true, nil +} + +func (s *Service) Diagnostics(ctx context.Context, rawName string) (DiagnosticsResponse, error) { + family, displayName, ok := normalizeDiagnosticsName(rawName) + if !ok { + return DiagnosticsResponse{}, fmt.Errorf("%w %q", ErrUnsupportedNotificationName, rawName) + } + + checks := []DiagnosticCheck{} + actions := []string{} + providers, err := s.providerStatuses() + if err != nil { + return DiagnosticsResponse{}, err + } + providerEnabled := map[string]bool{} + for _, provider := range providers { + providerEnabled[provider.ProviderType] = provider.Enabled + } + + for _, provider := range []string{notification.DeliveryChannelEmail, notification.DeliveryChannelSlack} { + providerLabel := titleASCII(provider) + status := StatusPass + message := providerLabel + " provider is enabled." + if !providerEnabled[provider] { + status = StatusWarning + message = providerLabel + " provider is disabled." + actions = append(actions, "Enable the "+providerLabel+" provider if this notification should use "+provider+".") + } + checks = append(checks, DiagnosticCheck{ + Code: "provider_" + provider + "_enabled", + Label: providerLabel + " provider enabled", + Status: status, + Message: message, + }) + } + + var responseCounts SubscriberCounts + for _, subscription := range subscriptionDiagnosticsForFamily(family) { + counts, err := s.subscriberCounts(ctx, subscription.Gate) + if err != nil { + return DiagnosticsResponse{}, err + } + responseCounts = addSubscriberCounts(responseCounts, counts) + status := StatusPass + message := fmt.Sprintf("%d active users are subscribed.", counts.TotalUsers) + if counts.TotalUsers == 0 { + status = StatusWarning + message = "No active users are subscribed." + actions = append(actions, "Review user notification subscriptions for "+displayName+".") + } + checks = append(checks, DiagnosticCheck{ + Code: "subscribers_" + subscription.CodeSuffix, + Label: subscription.Label, + Status: status, + Message: message, + }) + } + + configuredDestinations := []ConfiguredSystemDestinationResponse{} + for _, name := range systemNotificationsForFamily(family) { + destinations, err := s.configuredDestinations(ctx, name) + if err != nil { + return DiagnosticsResponse{}, err + } + configuredDestinations = append(configuredDestinations, destinations...) + status := StatusPass + message := fmt.Sprintf("%d configured system destinations found.", len(destinations)) + if len(destinations) == 0 { + status = StatusWarning + message = "No configured system destination was found." + actions = append(actions, "Add a system destination for "+strings.ToUpper(name)+".") + } + checks = append(checks, DiagnosticCheck{ + Code: "configured_destination_" + name, + Label: "System destination configured", + Status: status, + Message: message, + }) + } + + for _, schedule := range schedulesForFamily(s.cfg, family) { + checks = append(checks, s.scheduleCheck(ctx, schedule)) + } + + sourceKinds, deliveryKinds := jobKindsForFamily(family) + providerSourceKinds := append([]string{}, sourceKinds...) + providerSourceKinds = append(providerSourceKinds, deliveryKinds...) + latestSource, hasSource, err := s.latestJobForKinds(ctx, append(sourceKinds, deliveryKinds...)) + if err != nil { + return DiagnosticsResponse{}, err + } + if hasSource { + checks = append(checks, DiagnosticCheck{ + Code: "recent_source_job", + Label: "Recent source job", + Status: statusForFinalState(latestSource.State), + Message: fmt.Sprintf("Latest source job %s at %s.", latestSource.State, latestSource.CreatedAt.UTC().Format(time.RFC3339)), + JobID: latestSource.ID, + }) + } else { + checks = append(checks, DiagnosticCheck{ + Code: "recent_source_job", + Label: "Recent source job", + Status: StatusWarning, + Message: "No recent source or scanner job was found.", + }) + } + + providerJobs, err := s.recentProviderJobs(ctx, providerSourceKinds, latestSource) + if err != nil { + return DiagnosticsResponse{}, err + } + if len(providerJobs) == 0 { + checks = append(checks, DiagnosticCheck{ + Code: "downstream_provider_jobs", + Label: "Downstream provider jobs", + Status: StatusFail, + Message: "No downstream email or Slack provider jobs were found.", + }) + actions = append(actions, "Check whether the source job created provider fanout jobs.") + } else { + checks = append(checks, DiagnosticCheck{ + Code: "downstream_provider_jobs", + Label: "Downstream provider jobs", + Status: providerJobsStatus(providerJobs), + Message: fmt.Sprintf("%d downstream provider jobs found.", len(providerJobs)), + }) + } + + staleCount, discardedCount, latestError, err := s.providerQueueProblems(ctx, providerSourceKinds) + if err != nil { + return DiagnosticsResponse{}, err + } + checks = append(checks, DiagnosticCheck{ + Code: "stale_provider_jobs", + Label: "Stale provider jobs", + Status: warningIfCount(staleCount), + Message: fmt.Sprintf("%d stale provider jobs found.", staleCount), + }) + discardedMessage := fmt.Sprintf("%d discarded or retryable provider jobs found.", discardedCount) + if latestError != "" { + discardedMessage += " Latest error: " + latestError + } + checks = append(checks, DiagnosticCheck{ + Code: "failed_provider_jobs", + Label: "Failed provider jobs", + Status: warningIfCount(discardedCount), + Message: discardedMessage, + }) + + checks = append(checks, s.correlationCoverageCheck(providerJobs)) + + return DiagnosticsResponse{ + NotificationName: displayName, + Status: aggregateStatus(checks), + Checks: checks, + RecommendedActions: dedupeStrings(actions), + SubscriberCounts: responseCounts, + ConfiguredDestinations: configuredDestinations, + }, nil +} + +func (s *Service) workerEnabled() bool { + return s.cfg == nil || s.cfg.Worker == nil || s.cfg.Worker.Enabled +} + +func (s *Service) workerPollOnly() bool { + return s.cfg != nil && s.cfg.Worker != nil && s.cfg.Worker.UsePolling +} + +func (s *Service) workerMode() string { + if s.workerPollOnly() { + return "polling" + } + return "notify" +} + +func (s *Service) providerStatuses() ([]ProviderStatus, error) { + catalog, ok := s.providers.(notification.ProviderCatalog) + if !ok { + return nil, fmt.Errorf("notification provider catalog is not configured") + } + providers := catalog.Providers() + response := make([]ProviderStatus, 0, len(providers)) + for _, provider := range providers { + response = append(response, ProviderStatus{ + ProviderType: provider.ProviderType, + Enabled: provider.Enabled, + DisplayName: provider.DisplayName, + Description: provider.Description, + Metadata: provider.Metadata, + }) + } + return response, nil +} + +func (s *Service) queueSummaries(ctx context.Context) ([]QueueSummary, error) { + if !s.hasRiverJobsTable() { + summaries := make([]QueueSummary, 0, len(notificationQueues)) + for _, queue := range notificationQueues { + summaries = append(summaries, QueueSummary{Name: queue, MaxWorkers: s.maxWorkersForQueue(queue), StaleThresholdSeconds: thresholdForQueue(queue)}) + } + return summaries, nil + } + + now := s.now().UTC() + summaryByQueue := make(map[string]*QueueSummary, len(notificationQueues)) + for _, queue := range notificationQueues { + summary := QueueSummary{Name: queue, MaxWorkers: s.maxWorkersForQueue(queue), StaleThresholdSeconds: thresholdForQueue(queue)} + summaryByQueue[queue] = &summary + } + + var stateRows []struct { + Queue string + State string + Count int64 + } + if err := s.db.WithContext(ctx).Table("river_job"). + Select("queue, state::text AS state, count(*) AS count"). + Where("queue IN ? AND kind IN ?", notificationQueues, notificationJobKinds). + Group("queue, state"). + Find(&stateRows).Error; err != nil { + return nil, fmt.Errorf("computing queue state health: %w", err) + } + for _, row := range stateRows { + summary, ok := summaryByQueue[row.Queue] + if !ok { + continue + } + switch row.State { + case "available": + summary.Available = row.Count + case "retryable": + summary.Retryable = row.Count + case "running": + summary.Running = row.Count + case "scheduled": + summary.Scheduled = row.Count + } + } + + var finalizedRows []struct { + Queue string `gorm:"column:queue"` + Completed24h int64 `gorm:"column:completed24h"` + Discarded24h int64 `gorm:"column:discarded24h"` + } + if err := s.db.WithContext(ctx).Table("river_job"). + Select(` + queue, + sum(CASE WHEN state = 'completed' AND finalized_at >= ? THEN 1 ELSE 0 END) AS completed24h, + sum(CASE WHEN state = 'discarded' AND finalized_at >= ? THEN 1 ELSE 0 END) AS discarded24h + `, now.Add(-24*time.Hour), now.Add(-24*time.Hour)). + Where("queue IN ? AND kind IN ?", notificationQueues, notificationJobKinds). + Group("queue"). + Find(&finalizedRows).Error; err != nil { + return nil, fmt.Errorf("computing queue finalized health: %w", err) + } + for _, row := range finalizedRows { + if summary, ok := summaryByQueue[row.Queue]; ok { + summary.Completed24h = row.Completed24h + summary.Discarded24h = row.Discarded24h + } + } + + var waitingRows []struct { + Queue string `gorm:"column:queue"` + OldestAvailableAt *time.Time `gorm:"column:oldest_available_at"` + StaleCount int64 `gorm:"column:stale_count"` + } + if err := s.db.WithContext(ctx).Table("river_job"). + Select(` + queue, + min(CASE WHEN state IN ? AND scheduled_at <= ? THEN scheduled_at END) AS oldest_available_at, + sum(CASE WHEN state IN ? AND scheduled_at <= ? AND ((kind IN ? AND scheduled_at <= ?) OR (kind NOT IN ? AND scheduled_at <= ?)) THEN 1 ELSE 0 END) AS stale_count + `, + waitingStates(), now, + waitingStates(), now, + providerKindList(), now.Add(-ProviderStaleThresholdSeconds*time.Second), + providerKindList(), now.Add(-SourceStaleThresholdSeconds*time.Second), + ). + Where("queue IN ? AND kind IN ?", notificationQueues, notificationJobKinds). + Group("queue"). + Find(&waitingRows).Error; err != nil { + return nil, fmt.Errorf("computing queue waiting health: %w", err) + } + for _, row := range waitingRows { + if summary, ok := summaryByQueue[row.Queue]; ok { + summary.OldestAvailableAt = row.OldestAvailableAt + summary.StaleCount = row.StaleCount + } + } + + summaries := make([]QueueSummary, 0, len(notificationQueues)) + for _, queue := range notificationQueues { + summaries = append(summaries, *summaryByQueue[queue]) + } + return summaries, nil +} + +func (s *Service) maxWorkersForQueue(queue string) int { + workers := 5 + if s.cfg != nil && s.cfg.Worker != nil && s.cfg.Worker.Workers > 0 { + workers = s.cfg.Worker.Workers + } + switch queue { + case "digest", "scheduler": + return 1 + case "workflow": + return 2 + case "risk": + return 20 + case "poam": + return 10 + default: + return workers + } +} + +func thresholdForQueue(queue string) int64 { + if queue == "email" || queue == "slack" { + return ProviderStaleThresholdSeconds + } + return SourceStaleThresholdSeconds +} + +func (s *Service) notificationHealth(ctx context.Context, providers []ProviderStatus) ([]NotificationHealth, error) { + names := []string{ + notification.SubscriptionGateEvidenceDigest, + notification.SubscriptionGateTaskAvailable, + notification.SubscriptionGateTaskDailyDigest, + notification.SystemNotificationNameWorkflowExecutionFailed, + notification.SubscriptionGateRiskNotifications, + } + response := make([]NotificationHealth, 0, len(names)) + for _, name := range names { + destinations, err := s.configuredDestinations(ctx, name) + if err != nil { + return nil, err + } + counts, err := s.subscriberCounts(ctx, subscriptionGateForSystemName(name)) + if err != nil { + return nil, err + } + item := NotificationHealth{ + Name: strings.ToUpper(name), + ConfiguredDestinations: destinations, + SubscriberCounts: counts, + } + if len(destinations) == 0 && supportsSystemDestination(name) { + item.Warnings = append(item.Warnings, TroubleshootingWarning{ + Code: "missing_destination", + Severity: "warning", + Message: "No system destination is configured.", + Target: strings.ToUpper(name), + }) + } + response = append(response, item) + } + return response, nil +} + +func (s *Service) configuredDestinations(ctx context.Context, name string) ([]ConfiguredSystemDestinationResponse, error) { + var rows []relational.SystemNotificationDestination + if s.db == nil { + return []ConfiguredSystemDestinationResponse{}, nil + } + if err := s.db.WithContext(ctx). + Where("notification_type = ?", name). + Order("provider ASC, created_at ASC"). + Find(&rows).Error; err != nil { + return nil, fmt.Errorf("loading configured destinations for %s: %w", name, err) + } + destinations := make([]ConfiguredSystemDestinationResponse, 0, len(rows)) + seen := map[string]struct{}{} + for _, row := range rows { + target := notification.Target{Provider: row.Provider, Address: row.Target.Data().Address} + configurator, ok := notification.LookupTargetConfigurator(s.providers, row.Provider) + if !ok { + continue + } + normalized, err := configurator.NormalizeTarget(target) + if err != nil { + continue + } + display, err := configurator.DisplayTarget(normalized) + if err != nil { + continue + } + key := row.Provider + ":" + strings.ToLower(display) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + destinations = append(destinations, ConfiguredSystemDestinationResponse{ProviderType: row.Provider, DestinationTarget: display}) + } + return destinations, nil +} + +func (s *Service) subscriberCounts(ctx context.Context, gate string) (SubscriberCounts, error) { + if gate == "" || s.db == nil { + return SubscriberCounts{}, nil + } + var rows []relational.UserNotificationSubscription + if err := s.db.WithContext(ctx). + Joins("JOIN ccf_users ON ccf_users.id::text = ccf_user_notification_subscriptions.user_id"). + Where("ccf_user_notification_subscriptions.notification_type = ?", gate). + Where("ccf_users.is_active = ? AND ccf_users.is_locked = ?", true, false). + Find(&rows).Error; err != nil { + return SubscriberCounts{}, fmt.Errorf("loading subscriber counts for %s: %w", gate, err) + } + userIDs := map[string]struct{}{} + var counts SubscriberCounts + for _, row := range rows { + userIDs[row.UserID] = struct{}{} + seenChannels := map[string]struct{}{} + for _, raw := range row.Channels { + channel, ok := notification.NormalizeDeliveryChannel(raw) + if !ok { + continue + } + seenChannels[channel] = struct{}{} + } + if _, ok := seenChannels[notification.DeliveryChannelEmail]; ok { + counts.Email++ + } + if _, ok := seenChannels[notification.DeliveryChannelSlack]; ok { + counts.Slack++ + } + } + counts.TotalUsers = int64(len(userIDs)) + return counts, nil +} + +func (s *Service) scheduleHealth(ctx context.Context) ([]ScheduleHealth, error) { + defs := allScheduleDefinitions(s.cfg) + items := make([]ScheduleHealth, 0, len(defs)) + for _, def := range defs { + item := ScheduleHealth{ + Name: def.Name, + JobKind: def.JobKind, + DeliveryKind: def.DeliveryKind, + Queue: def.Queue, + Enabled: def.Enabled, + Schedule: def.Schedule, + } + if def.Enabled { + next, err := nextRun(def.Schedule, s.now()) + if err == nil { + item.NextRunAt = &next + } + } + if last, ok, err := s.latestJobForKind(ctx, def.JobKind); err != nil { + return nil, err + } else if ok { + item.LastJob = &JobSummary{ID: last.ID, State: last.State, CreatedAt: last.CreatedAt, FinalizedAt: last.FinalizedAt} + } + items = append(items, item) + } + return items, nil +} + +type scheduleDefinition struct { + Name string + JobKind string + DeliveryKind string + Queue string + Enabled bool + Schedule string +} + +func allScheduleDefinitions(cfg *config.Config) []scheduleDefinition { + workflowCfg := config.DefaultWorkflowConfig() + if cfg != nil && cfg.Workflow != nil { + workflowCfg = cfg.Workflow + } + riskCfg := config.DefaultRiskConfig() + if cfg != nil && cfg.Risk != nil { + riskCfg = cfg.Risk + } + poamCfg := config.DefaultPoamConfig() + if cfg != nil && cfg.Poam != nil { + poamCfg = cfg.Poam + } + digestEnabled := cfg == nil || cfg.DigestEnabled + digestSchedule := "@weekly" + if cfg != nil && strings.TrimSpace(cfg.DigestSchedule) != "" { + digestSchedule = cfg.DigestSchedule + } + return []scheduleDefinition{ + {"EVIDENCE_DIGEST", worker.JobTypeSendGlobalDigest, "", "digest", digestEnabled, digestSchedule}, + {"WORKFLOW_DUE_SOON", worker.JobTypeWorkflowDueSoonChecker, worker.JobTypeWorkflowTaskDueSoon, "email", workflowCfg.DueSoonEnabled, workflowCfg.DueSoonSchedule}, + {"WORKFLOW_TASK_DIGEST", "workflow_task_digest_checker", worker.JobTypeWorkflowTaskDigest, "digest", workflowCfg.TaskDigestEnabled, workflowCfg.TaskDigestSchedule}, + {"RISK_REVIEW_DEADLINE_REMINDER", worker.JobTypeRiskReviewDeadlineReminderScanner, worker.JobTypeRiskReviewDueReminder, "risk", riskCfg.ReviewDeadlineReminderEnabled, riskCfg.ReviewDeadlineReminderSchedule}, + {"RISK_REVIEW_OVERDUE_ESCALATION", worker.JobTypeRiskReviewOverdueEscalationScanner, worker.JobTypeRiskReviewOverdueEscalation, "risk", riskCfg.ReviewOverdueEscalationEnabled, riskCfg.ReviewOverdueEscalationSchedule}, + {"RISK_STALE_REMINDER", worker.JobTypeRiskStaleRiskScanner, worker.JobTypeRiskStaleOpenReminder, "risk", riskCfg.StaleRiskScannerEnabled, riskCfg.StaleRiskScannerSchedule}, + {"RISK_OPEN_DIGEST", worker.JobTypeRiskOpenDigestScheduler, worker.JobTypeRiskOpenDigest, "risk", riskCfg.OpenDigestEnabled, riskCfg.OpenDigestSchedule}, + {"POAM_DEADLINE_REMINDER", worker.JobTypePoamDeadlineReminderScanner, worker.JobTypePoamDeadlineReminder, "poam", poamCfg.DeadlineReminderEnabled, poamCfg.DeadlineReminderSchedule}, + {"POAM_OVERDUE_NOTIFICATION", worker.JobTypePoamOverdueTransitionScanner, worker.JobTypePoamOverdueNotification, "poam", poamCfg.OverdueTransitionEnabled, poamCfg.OverdueTransitionSchedule}, + {"POAM_MILESTONE_OVERDUE_REMINDER", worker.JobTypeMilestoneOverdueScannerScanner, worker.JobTypeMilestoneOverdueReminder, "poam", poamCfg.MilestoneOverdueEnabled, poamCfg.MilestoneOverdueSchedule}, + {"POAM_OPEN_DIGEST", worker.JobTypePoamOpenDigestScheduler, worker.JobTypePoamOpenDigest, "digest", poamCfg.OpenDigestEnabled, poamCfg.OpenDigestSchedule}, + } +} + +func nextRun(schedule string, now time.Time) (time.Time, error) { + parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor) + parsed, err := parser.Parse(schedule) + if err != nil { + return time.Time{}, err + } + return parsed.Next(now.UTC()).UTC(), nil +} + +func (s *Service) latestJobForKind(ctx context.Context, kind string) (riverJobRecord, bool, error) { + return s.latestJobForKinds(ctx, []string{kind}) +} + +func (s *Service) latestJobForKinds(ctx context.Context, kinds []string) (riverJobRecord, bool, error) { + if !s.hasRiverJobsTable() || len(kinds) == 0 { + return riverJobRecord{}, false, nil + } + var row riverJobRecord + err := s.db.WithContext(ctx).Table("river_job"). + Select("id, state::text AS state, queue, kind, attempt, max_attempts, created_at, scheduled_at, attempted_at, finalized_at, args, metadata, COALESCE(to_jsonb(errors), '[]'::jsonb) AS errors_json"). + Where("kind IN ?", kinds). + Order("id DESC"). + Limit(1). + First(&row).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return riverJobRecord{}, false, nil + } + return riverJobRecord{}, false, err + } + return row, true, nil +} + +func (s *Service) jobListItem(row riverJobRecord) JobListItem { + args := parseJSONMap(row.Args) + metadata := parseJSONMap(row.Metadata) + item := JobListItem{ + ID: row.ID, + State: row.State, + Queue: row.Queue, + Kind: row.Kind, + Attempt: row.Attempt, + MaxAttempts: row.MaxAttempts, + CreatedAt: row.CreatedAt, + ScheduledAt: row.ScheduledAt, + AttemptedAt: row.AttemptedAt, + FinalizedAt: row.FinalizedAt, + LastError: lastAttemptError(row.ErrorsJSON), + Stale: isStaleJob(row.Kind, row.State, row.ScheduledAt, s.now()), + } + item.NotificationKind = stringValue(args, metadata, "notification_kind") + item.CorrelationID = stringValue(args, metadata, "correlation_id") + item.SourceJobKind = stringValue(args, metadata, "source_job_kind") + item.SourceJobID = stringValue(args, metadata, "source_job_id") + item.Provider = providerForJob(row.Kind, args) + item.Target = targetForJob(row.Kind, args) + return item +} + +func metadataForArgs(argsJSON []byte, metadataJSON []byte, item JobListItem) map[string]string { + args := parseJSONMap(argsJSON) + metadata := parseJSONMap(metadataJSON) + result := map[string]string{} + fields := map[string]string{ + "notificationKind": item.NotificationKind, + "correlationId": item.CorrelationID, + "sourceJobKind": item.SourceJobKind, + "sourceJobId": item.SourceJobID, + "provider": item.Provider, + "target": item.Target, + } + for k, v := range fields { + if v != "" { + result[k] = v + } + } + for _, key := range []string{"notification_kind", "correlation_id", "source_job_kind", "source_job_id", "recipient_user_id"} { + if value := stringValue(args, metadata, key); value != "" { + result[toCamel(key)] = value + } + } + return result +} + +func sanitizeArgs(kind string, args map[string]any) map[string]any { + safe := map[string]any{} + switch kind { + case worker.JobTypeSendEmail, worker.JobTypeSendEmailFrom: + copyIfPresent(safe, args, "to") + copyIfPresent(safe, args, "cc") + copyIfPresent(safe, args, "bcc") + copyIfPresent(safe, args, "from") + copyIfPresent(safe, args, "subject") + copyIfPresent(safe, args, "provider") + case worker.JobTypeSendSlackChannel, worker.JobTypeSendSlackDM: + copyIfPresent(safe, args, "channel") + copyIfPresent(safe, args, "target_type") + default: + for k, v := range args { + key := strings.ToLower(k) + if strings.Contains(key, "token") || strings.Contains(key, "secret") || strings.Contains(key, "password") || + strings.Contains(key, "html_body") || strings.Contains(key, "text_body") || strings.Contains(key, "blocks") || + strings.Contains(key, "attachments") { + continue + } + safe[k] = v + } + } + return safe +} + +func providerForJob(kind string, args map[string]any) string { + switch kind { + case worker.JobTypeSendEmail, worker.JobTypeSendEmailFrom: + return notification.DeliveryChannelEmail + case worker.JobTypeSendSlackChannel, worker.JobTypeSendSlackDM: + return notification.DeliveryChannelSlack + default: + return stringFromAny(args["provider"]) + } +} + +func targetForJob(kind string, args map[string]any) string { + switch kind { + case worker.JobTypeSendEmail, worker.JobTypeSendEmailFrom: + if values, ok := args["to"].([]any); ok && len(values) > 0 { + return stringFromAny(values[0]) + } + case worker.JobTypeSendSlackChannel, worker.JobTypeSendSlackDM: + return stringFromAny(args["channel"]) + } + return "" +} + +func isStaleJob(kind, state string, scheduledAt time.Time, now time.Time) bool { + if state != "available" && state != "retryable" && state != "scheduled" { + return false + } + if scheduledAt.After(now) { + return false + } + threshold := SourceStaleThresholdSeconds * time.Second + if _, ok := providerJobKinds[kind]; ok { + threshold = ProviderStaleThresholdSeconds * time.Second + } + return scheduledAt.Before(now.Add(-threshold)) || scheduledAt.Equal(now.Add(-threshold)) +} + +func validateJobsQuery(query *JobsQuery) error { + query.Queues = normalizeStringList(query.Queues) + query.States = normalizeStringList(query.States) + allowedQueues := setOf(notificationQueues) + for _, queue := range query.Queues { + if _, ok := allowedQueues[queue]; !ok { + return fmt.Errorf("unsupported queue %q", queue) + } + } + if query.Provider != "" { + provider, ok := notification.NormalizeDeliveryChannel(query.Provider) + if !ok || (provider != notification.DeliveryChannelEmail && provider != notification.DeliveryChannelSlack) { + return fmt.Errorf("unsupported provider %q", query.Provider) + } + query.Provider = provider + } + allowedStates := setOf([]string{"available", "cancelled", "completed", "discarded", "pending", "retryable", "running", "scheduled"}) + for _, state := range query.States { + if _, ok := allowedStates[state]; !ok { + return fmt.Errorf("unsupported state %q", state) + } + } + return nil +} + +func normalizeLimit(limit int) int { + if limit <= 0 { + return 50 + } + if limit > 200 { + return 200 + } + return limit +} + +func (s *Service) hasRiverJobsTable() bool { + return s != nil && s.db != nil && s.db.Migrator().HasTable("river_job") +} + +func waitingStates() []string { + return []string{"available", "retryable", "scheduled"} +} + +func providerKindList() []string { + return []string{worker.JobTypeSendEmail, worker.JobTypeSendEmailFrom, worker.JobTypeSendSlackChannel, worker.JobTypeSendSlackDM} +} + +func parseJSONMap(data []byte) map[string]any { + if len(data) == 0 { + return map[string]any{} + } + var out map[string]any + if err := json.Unmarshal(data, &out); err != nil { + return map[string]any{} + } + return out +} + +func parseAttemptErrors(data []byte) []SanitizedAttemptError { + if len(data) == 0 { + return []SanitizedAttemptError{} + } + var raw []struct { + Attempt int `json:"attempt"` + At time.Time `json:"at"` + Error string `json:"error"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return []SanitizedAttemptError{} + } + result := make([]SanitizedAttemptError, 0, len(raw)) + for _, item := range raw { + result = append(result, SanitizedAttemptError{Attempt: item.Attempt, At: item.At, Error: item.Error}) + } + return result +} + +func lastAttemptError(data []byte) *string { + errors := parseAttemptErrors(data) + if len(errors) == 0 { + return nil + } + value := errors[len(errors)-1].Error + return &value +} + +func stringValue(args map[string]any, metadata map[string]any, key string) string { + if value := stringFromAny(args[key]); value != "" { + return value + } + return stringFromAny(metadata[key]) +} + +func stringFromAny(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case fmt.Stringer: + return strings.TrimSpace(typed.String()) + case float64: + if typed == float64(int64(typed)) { + return strconv.FormatInt(int64(typed), 10) + } + return strconv.FormatFloat(typed, 'f', -1, 64) + default: + return "" + } +} + +func copyIfPresent(dst map[string]any, src map[string]any, key string) { + if value, ok := src[key]; ok { + dst[toCamel(key)] = value + } +} + +func toCamel(value string) string { + parts := strings.Split(value, "_") + for i := 1; i < len(parts); i++ { + if parts[i] == "" { + continue + } + parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:] + } + return strings.Join(parts, "") +} + +func encodeCursor(id int64) string { + return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10))) +} + +func decodeCursor(cursor string) (int64, error) { + if strings.TrimSpace(cursor) == "" { + return 0, nil + } + raw, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil { + return 0, fmt.Errorf("invalid cursor") + } + id, err := strconv.ParseInt(string(raw), 10, 64) + if err != nil || id < 0 { + return 0, fmt.Errorf("invalid cursor") + } + return id, nil +} + +func normalizeStringList(values []string) []string { + seen := map[string]struct{}{} + result := []string{} + for _, value := range values { + for _, part := range strings.Split(value, ",") { + trimmed := strings.ToLower(strings.TrimSpace(part)) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + } + sort.Strings(result) + return result +} + +func setOf(values []string) map[string]struct{} { + set := make(map[string]struct{}, len(values)) + for _, value := range values { + set[value] = struct{}{} + } + return set +} + +func titleASCII(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + return strings.ToUpper(trimmed[:1]) + trimmed[1:] +} + +func subscriptionGateForSystemName(name string) string { + switch name { + case notification.SystemNotificationNameWorkflowExecutionFailed: + return notification.SubscriptionGateTaskAvailable + default: + if gate, ok := notification.NormalizeSubscriptionGate(name); ok { + return gate + } + return "" + } +} + +func supportsSystemDestination(name string) bool { + switch strings.ToLower(strings.TrimSpace(name)) { + case notification.SystemNotificationNameEvidenceDigest, + notification.SystemNotificationNameWorkflowExecutionFailed: + return true + default: + return false + } +} + +func (s *Service) healthWarnings(response HealthResponse) []TroubleshootingWarning { + warnings := []TroubleshootingWarning{} + for _, provider := range response.Providers { + if !provider.Enabled { + warnings = append(warnings, TroubleshootingWarning{ + Code: provider.ProviderType + "_provider_disabled", + Severity: "warning", + Message: provider.DisplayName + " provider is disabled.", + Target: provider.ProviderType, + }) + } + } + for _, queue := range response.Worker.Queues { + if queue.StaleCount > 0 { + warnings = append(warnings, TroubleshootingWarning{ + Code: queue.Name + "_queue_backlog", + Severity: "warning", + Message: fmt.Sprintf("%s queue has %d stale notification jobs.", titleASCII(queue.Name), queue.StaleCount), + Target: queue.Name, + }) + } + if queue.Discarded24h > 0 { + warnings = append(warnings, TroubleshootingWarning{ + Code: queue.Name + "_discarded_jobs", + Severity: "warning", + Message: fmt.Sprintf("%s queue has %d discarded notification jobs in the last 24 hours.", titleASCII(queue.Name), queue.Discarded24h), + Target: queue.Name, + }) + } + } + for _, item := range response.Notifications { + warnings = append(warnings, item.Warnings...) + } + for _, schedule := range response.Schedules { + if schedule.Enabled && schedule.LastJob == nil { + warnings = append(warnings, TroubleshootingWarning{ + Code: "missing_schedule_run", + Severity: "warning", + Message: "No River job was found for enabled schedule " + schedule.Name + ".", + Target: schedule.Name, + }) + } + } + return warnings +} + +func normalizeDiagnosticsName(raw string) (string, string, bool) { + normalized := strings.ToLower(strings.TrimSpace(raw)) + normalized = strings.ReplaceAll(normalized, "-", "_") + switch normalized { + case "evidence_digest", "evidencedigest": + return "evidence", "EVIDENCE_DIGEST", true + case "workflow", "task_available", "task_daily_digest", "workflow_execution_failed": + return "workflow", "WORKFLOW", true + case "risk", "risk_notifications", "risknotifications": + return "risk", "RISK", true + case "poam": + return "poam", "POAM", true + default: + return "", "", false + } +} + +type subscriptionDiagnostic struct { + Gate string + CodeSuffix string + Label string +} + +func subscriptionDiagnosticsForFamily(family string) []subscriptionDiagnostic { + switch family { + case "evidence": + return []subscriptionDiagnostic{ + { + Gate: notification.SubscriptionGateEvidenceDigest, + CodeSuffix: notification.SubscriptionGateEvidenceDigest, + Label: "Evidence Digest subscribers", + }, + } + case "workflow": + return []subscriptionDiagnostic{ + { + Gate: notification.SubscriptionGateTaskAvailable, + CodeSuffix: notification.SubscriptionGateTaskAvailable, + Label: "Task Available subscribers", + }, + { + Gate: notification.SubscriptionGateTaskDailyDigest, + CodeSuffix: notification.SubscriptionGateTaskDailyDigest, + Label: "Task Daily Digest subscribers", + }, + } + case "risk": + return []subscriptionDiagnostic{ + { + Gate: notification.SubscriptionGateRiskNotifications, + CodeSuffix: notification.SubscriptionGateRiskNotifications, + Label: "Risk Notification subscribers", + }, + } + case "poam": + return []subscriptionDiagnostic{ + { + Gate: notification.SubscriptionGateRiskNotifications, + CodeSuffix: "poam_notifications", + Label: "POAM subscribers", + }, + } + default: + return nil + } +} + +func addSubscriberCounts(left, right SubscriberCounts) SubscriberCounts { + return SubscriberCounts{ + Email: left.Email + right.Email, + Slack: left.Slack + right.Slack, + TotalUsers: left.TotalUsers + right.TotalUsers, + } +} + +func systemNotificationsForFamily(family string) []string { + switch family { + case "evidence": + return []string{notification.SystemNotificationNameEvidenceDigest} + case "workflow": + return []string{notification.SystemNotificationNameWorkflowExecutionFailed} + default: + return nil + } +} + +func schedulesForFamily(cfg *config.Config, family string) []scheduleDefinition { + all := allScheduleDefinitions(cfg) + result := []scheduleDefinition{} + for _, def := range all { + switch family { + case "evidence": + if strings.HasPrefix(def.Name, "EVIDENCE_") { + result = append(result, def) + } + case "workflow": + if strings.HasPrefix(def.Name, "WORKFLOW_") { + result = append(result, def) + } + case "risk": + if strings.HasPrefix(def.Name, "RISK_") { + result = append(result, def) + } + case "poam": + if strings.HasPrefix(def.Name, "POAM_") { + result = append(result, def) + } + } + } + return result +} + +func jobKindsForFamily(family string) ([]string, []string) { + switch family { + case "evidence": + return []string{worker.JobTypeSendGlobalDigest}, []string{worker.JobTypeSendGlobalDigest} + case "workflow": + return []string{worker.JobTypeWorkflowDueSoonChecker, "workflow_task_digest_checker"}, []string{ + worker.JobTypeWorkflowTaskAssigned, + worker.JobTypeWorkflowTaskDueSoon, + worker.JobTypeWorkflowTaskDigest, + worker.JobTypeWorkflowExecutionFailed, + } + case "risk": + sourceKinds := []string{ + worker.JobTypeRiskReviewDeadlineReminderScanner, + worker.JobTypeRiskReviewOverdueEscalationScanner, + worker.JobTypeRiskStaleRiskScanner, + worker.JobTypeRiskOpenDigestScheduler, + } + deliveryKinds := []string{ + worker.JobTypeRiskReviewDueReminder, + worker.JobTypeRiskReviewOverdueEscalation, + worker.JobTypeRiskStaleOpenReminder, + worker.JobTypeRiskOpenDigest, + } + return sourceKinds, deliveryKinds + case "poam": + sourceKinds := []string{ + worker.JobTypePoamDeadlineReminderScanner, + worker.JobTypePoamOverdueTransitionScanner, + worker.JobTypeMilestoneOverdueScannerScanner, + worker.JobTypePoamOpenDigestScheduler, + } + deliveryKinds := []string{ + worker.JobTypePoamDeadlineReminder, + worker.JobTypePoamOverdueNotification, + worker.JobTypeMilestoneOverdueReminder, + worker.JobTypePoamOpenDigest, + } + return sourceKinds, deliveryKinds + default: + return nil, nil + } +} + +func (s *Service) scheduleCheck(ctx context.Context, def scheduleDefinition) DiagnosticCheck { + if !def.Enabled { + return DiagnosticCheck{ + Code: "schedule_" + strings.ToLower(def.Name), + Label: def.Name + " schedule", + Status: StatusWarning, + Message: "Schedule is disabled.", + } + } + next, err := nextRun(def.Schedule, s.now()) + if err != nil { + return DiagnosticCheck{ + Code: "schedule_" + strings.ToLower(def.Name), + Label: def.Name + " schedule", + Status: StatusFail, + Message: "Schedule cannot be parsed: " + err.Error(), + } + } + last, ok, err := s.latestJobForKind(ctx, def.JobKind) + if err != nil { + return DiagnosticCheck{ + Code: "schedule_" + strings.ToLower(def.Name), + Label: def.Name + " schedule", + Status: StatusFail, + Message: "Failed to read latest schedule job: " + err.Error(), + } + } + message := "Next scheduled run is " + next.Format(time.RFC3339) + "." + check := DiagnosticCheck{ + Code: "schedule_" + strings.ToLower(def.Name), + Label: def.Name + " schedule", + Status: StatusPass, + Message: message, + } + if ok { + check.JobID = last.ID + check.Message = message + " Latest job state is " + last.State + "." + } + return check +} + +func (s *Service) recentProviderJobs(ctx context.Context, sourceKinds []string, latestSource riverJobRecord) ([]JobListItem, error) { + if !s.hasRiverJobsTable() { + return []JobListItem{}, nil + } + q := s.db.WithContext(ctx).Table("river_job"). + Select("id, state::text AS state, queue, kind, attempt, max_attempts, created_at, scheduled_at, attempted_at, finalized_at, args, metadata, COALESCE(to_jsonb(errors), '[]'::jsonb) AS errors_json"). + Where("kind IN ?", providerKindList()). + Order("id DESC"). + Limit(50) + if len(sourceKinds) > 0 { + predicate, args := providerJobSourcePredicate(sourceKinds, latestSource) + q = q.Where(predicate, args...) + } + var rows []riverJobRecord + if err := q.Find(&rows).Error; err != nil { + return nil, err + } + items := make([]JobListItem, 0, len(rows)) + for _, row := range rows { + items = append(items, s.jobListItem(row)) + } + return items, nil +} + +func providerJobSourcePredicate(sourceKinds []string, latestSource riverJobRecord) (string, []any) { + sourceJobKindExpr := "COALESCE(NULLIF(args ->> 'source_job_kind', ''), NULLIF(metadata ->> 'source_job_kind', ''), '')" + sourceJobIDExpr := "COALESCE(NULLIF(args ->> 'source_job_id', ''), NULLIF(metadata ->> 'source_job_id', ''), '')" + if latestSource.ID > 0 { + return fmt.Sprintf("((%s IN ? AND (%s = ? OR %s = '')) OR (%s = '' AND created_at >= ?))", sourceJobKindExpr, sourceJobIDExpr, sourceJobIDExpr, sourceJobKindExpr), + []any{sourceKinds, strconv.FormatInt(latestSource.ID, 10), latestSource.CreatedAt} + } + if latestSource.CreatedAt.IsZero() { + return fmt.Sprintf("%s IN ?", sourceJobKindExpr), []any{sourceKinds} + } + return fmt.Sprintf("(%s IN ?) OR (%s = '' AND created_at >= ?)", sourceJobKindExpr, sourceJobKindExpr), + []any{sourceKinds, latestSource.CreatedAt} +} + +func (s *Service) providerQueueProblems(ctx context.Context, sourceKinds []string) (int64, int64, string, error) { + jobs, err := s.recentProviderJobs(ctx, sourceKinds, riverJobRecord{CreatedAt: s.now().Add(-30 * 24 * time.Hour)}) + if err != nil { + return 0, 0, "", err + } + var stale int64 + var failed int64 + latestError := "" + for _, job := range jobs { + if job.Stale { + stale++ + } + if job.State == "discarded" || job.State == "retryable" { + failed++ + if latestError == "" && job.LastError != nil { + latestError = *job.LastError + } + } + } + return stale, failed, latestError, nil +} + +func providerJobsStatus(jobs []JobListItem) string { + for _, job := range jobs { + if job.State == "discarded" { + return StatusFail + } + if job.State == "available" || job.State == "retryable" || job.State == "scheduled" || job.Stale { + return StatusWarning + } + } + return StatusPass +} + +func statusForFinalState(state string) string { + switch state { + case "completed": + return StatusPass + case "discarded", "cancelled": + return StatusFail + default: + return StatusWarning + } +} + +func warningIfCount(count int64) string { + if count > 0 { + return StatusWarning + } + return StatusPass +} + +func (s *Service) correlationCoverageCheck(jobs []JobListItem) DiagnosticCheck { + if len(jobs) == 0 { + return DiagnosticCheck{ + Code: "correlation_coverage", + Label: "Correlation coverage", + Status: StatusWarning, + Message: "No provider jobs were available to evaluate correlation metadata.", + } + } + var withCorrelation int + for _, job := range jobs { + if job.CorrelationID != "" && job.SourceJobKind != "" && job.SourceJobID != "" { + withCorrelation++ + } + } + status := StatusPass + if withCorrelation < len(jobs) { + status = StatusWarning + } + return DiagnosticCheck{ + Code: "correlation_coverage", + Label: "Correlation coverage", + Status: status, + Message: fmt.Sprintf("%d of %d provider jobs include correlation ID, source job kind, and source job ID metadata.", withCorrelation, len(jobs)), + } +} + +func aggregateStatus(checks []DiagnosticCheck) string { + status := StatusPass + for _, check := range checks { + if check.Status == StatusFail { + return StatusFail + } + if check.Status == StatusWarning { + status = StatusWarning + } + } + return status +} + +func dedupeStrings(values []string) []string { + seen := map[string]struct{}{} + result := []string{} + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + return result +} diff --git a/internal/service/notificationtroubleshooting/service_test.go b/internal/service/notificationtroubleshooting/service_test.go new file mode 100644 index 00000000..2a571c08 --- /dev/null +++ b/internal/service/notificationtroubleshooting/service_test.go @@ -0,0 +1,148 @@ +package notificationtroubleshooting + +import ( + "testing" + "time" + + "github.com/compliance-framework/api/internal/service/notification" + "github.com/compliance-framework/api/internal/service/worker" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSanitizeArgsRedactsProviderPayloads(t *testing.T) { + emailArgs := map[string]any{ + "to": []any{"admin@example.com"}, + "subject": "hello", + "html_body": "
secret body
", + "text_body": "secret body", + "attachments": []any{"secret.pdf"}, + "headers": map[string]any{"Authorization": "Bearer token"}, + } + sanitizedEmail := sanitizeArgs(worker.JobTypeSendEmail, emailArgs) + assert.Equal(t, []any{"admin@example.com"}, sanitizedEmail["to"]) + assert.Equal(t, "hello", sanitizedEmail["subject"]) + assert.NotContains(t, sanitizedEmail, "htmlBody") + assert.NotContains(t, sanitizedEmail, "textBody") + assert.NotContains(t, sanitizedEmail, "attachments") + assert.NotContains(t, sanitizedEmail, "headers") + + slackArgs := map[string]any{ + "channel": "ccf-alerts", + "target_type": "channel", + "text": "secret text", + "blocks": []any{"secret block"}, + } + sanitizedSlack := sanitizeArgs(worker.JobTypeSendSlackChannel, slackArgs) + assert.Equal(t, "ccf-alerts", sanitizedSlack["channel"]) + assert.Equal(t, "channel", sanitizedSlack["targetType"]) + assert.NotContains(t, sanitizedSlack, "text") + assert.NotContains(t, sanitizedSlack, "blocks") +} + +func TestIsStaleJobUsesProviderAndSourceThresholds(t *testing.T) { + now := time.Date(2026, 5, 22, 12, 0, 0, 0, time.UTC) + + assert.True(t, isStaleJob(worker.JobTypeSendSlackChannel, "available", now.Add(-11*time.Minute), now)) + assert.False(t, isStaleJob(worker.JobTypeSendSlackChannel, "available", now.Add(-9*time.Minute), now)) + assert.True(t, isStaleJob(worker.JobTypeRiskReviewDeadlineReminderScanner, "available", now.Add(-31*time.Minute), now)) + assert.False(t, isStaleJob(worker.JobTypeRiskReviewDeadlineReminderScanner, "available", now.Add(-29*time.Minute), now)) + assert.False(t, isStaleJob(worker.JobTypeSendSlackChannel, "scheduled", now.Add(time.Minute), now)) + assert.False(t, isStaleJob(worker.JobTypeSendSlackChannel, "completed", now.Add(-24*time.Hour), now)) +} + +func TestValidateJobsQueryNormalizesRepeatableAndCommaSeparatedFilters(t *testing.T) { + query := JobsQuery{ + Queues: []string{"email,slack", "digest"}, + Provider: "SLACK", + States: []string{"available,retryable"}, + Limit: 300, + } + + require.NoError(t, validateJobsQuery(&query)) + assert.Equal(t, []string{"digest", "email", "slack"}, query.Queues) + assert.Equal(t, "slack", query.Provider) + assert.Equal(t, []string{"available", "retryable"}, query.States) + assert.Equal(t, 200, normalizeLimit(query.Limit)) +} + +func TestValidateJobsQueryRejectsUnsupportedFilters(t *testing.T) { + require.Error(t, validateJobsQuery(&JobsQuery{Queues: []string{"default"}})) + require.Error(t, validateJobsQuery(&JobsQuery{Provider: "pagerduty"})) + require.Error(t, validateJobsQuery(&JobsQuery{States: []string{"lost"}})) +} + +func TestNextRunParsesDescriptorSchedules(t *testing.T) { + now := time.Date(2026, 5, 22, 12, 0, 0, 0, time.UTC) + + next, err := nextRun("@daily", now) + require.NoError(t, err) + assert.Equal(t, time.Date(2026, 5, 23, 0, 0, 0, 0, time.UTC), next) +} + +func TestSupportsSystemDestinationOnlyAllowsConfiguredSystemTypes(t *testing.T) { + assert.True(t, supportsSystemDestination(notification.SystemNotificationNameEvidenceDigest)) + assert.True(t, supportsSystemDestination(notification.SystemNotificationNameWorkflowExecutionFailed)) + assert.True(t, supportsSystemDestination(" WORKFLOW_EXECUTION_FAILED ")) + + assert.False(t, supportsSystemDestination(notification.SubscriptionGateTaskAvailable)) + assert.False(t, supportsSystemDestination(notification.SubscriptionGateTaskDailyDigest)) + assert.False(t, supportsSystemDestination(notification.SubscriptionGateRiskNotifications)) + assert.False(t, supportsSystemDestination("poam_deadline_reminder")) + assert.False(t, supportsSystemDestination("poam_notifications")) +} + +func TestSubscriptionDiagnosticsForFamilyUsesDistinctWorkflowLabels(t *testing.T) { + diagnostics := subscriptionDiagnosticsForFamily("workflow") + + require.Len(t, diagnostics, 2) + assert.Equal(t, notification.SubscriptionGateTaskAvailable, diagnostics[0].Gate) + assert.Equal(t, notification.SubscriptionGateTaskAvailable, diagnostics[0].CodeSuffix) + assert.Equal(t, "Task Available subscribers", diagnostics[0].Label) + assert.Equal(t, notification.SubscriptionGateTaskDailyDigest, diagnostics[1].Gate) + assert.Equal(t, notification.SubscriptionGateTaskDailyDigest, diagnostics[1].CodeSuffix) + assert.Equal(t, "Task Daily Digest subscribers", diagnostics[1].Label) +} + +func TestSubscriptionDiagnosticsForFamilyUsesPoamSpecificCode(t *testing.T) { + diagnostics := subscriptionDiagnosticsForFamily("poam") + + require.Len(t, diagnostics, 1) + assert.Equal(t, notification.SubscriptionGateRiskNotifications, diagnostics[0].Gate) + assert.Equal(t, "poam_notifications", diagnostics[0].CodeSuffix) + assert.Equal(t, "POAM subscribers", diagnostics[0].Label) +} + +func TestProviderJobSourcePredicateConstrainsLatestSourceJob(t *testing.T) { + createdAt := time.Date(2026, 5, 22, 12, 0, 0, 0, time.UTC) + + predicate, args := providerJobSourcePredicate( + []string{worker.JobTypeWorkflowTaskDueSoon}, + riverJobRecord{ID: 241582, CreatedAt: createdAt}, + ) + + assert.Contains(t, predicate, "source_job_kind") + assert.Contains(t, predicate, "source_job_id") + assert.Contains(t, predicate, "source_job_id', ''), NULLIF(metadata ->> 'source_job_id'") + assert.Contains(t, predicate, "created_at >= ?") + require.Len(t, args, 3) + assert.Equal(t, []string{worker.JobTypeWorkflowTaskDueSoon}, args[0]) + assert.Equal(t, "241582", args[1]) + assert.Equal(t, createdAt, args[2]) +} + +func TestProviderJobSourcePredicateKeepsLegacyWindowForSyntheticSource(t *testing.T) { + createdAt := time.Date(2026, 5, 22, 12, 0, 0, 0, time.UTC) + + predicate, args := providerJobSourcePredicate( + []string{worker.JobTypeWorkflowTaskDueSoon}, + riverJobRecord{CreatedAt: createdAt}, + ) + + assert.Contains(t, predicate, "source_job_kind") + assert.NotContains(t, predicate, "source_job_id") + assert.Contains(t, predicate, "created_at >= ?") + require.Len(t, args, 2) + assert.Equal(t, []string{worker.JobTypeWorkflowTaskDueSoon}, args[0]) + assert.Equal(t, createdAt, args[1]) +} diff --git a/internal/service/worker/due_soon_checker.go b/internal/service/worker/due_soon_checker.go index 3d635524..d26cc2ab 100644 --- a/internal/service/worker/due_soon_checker.go +++ b/internal/service/worker/due_soon_checker.go @@ -15,8 +15,10 @@ import ( // DueSoonCheckerArgs represents the arguments for the periodic due-soon checker job type DueSoonCheckerArgs struct{} +const JobTypeWorkflowDueSoonChecker = "workflow_due_soon_checker" + // Kind returns the job kind for River -func (DueSoonCheckerArgs) Kind() string { return "workflow_due_soon_checker" } +func (DueSoonCheckerArgs) Kind() string { return JobTypeWorkflowDueSoonChecker } // Timeout returns the timeout for the due-soon checker job func (DueSoonCheckerArgs) Timeout() time.Duration { return 5 * time.Minute } @@ -119,9 +121,14 @@ func (w *DueSoonCheckerWorker) Work(ctx context.Context, job *river.Job[DueSoonC userName = user.FullName() } + request := requestWithDueSoonCheckerSourceJob( + buildWorkflowTaskDueSoonNotificationRequest(baseArgs, userName, w.webBaseURL), + riverJobID(job), + step.ID.String(), + ) if err := notifier.Dispatch( ctx, - buildWorkflowTaskDueSoonNotificationRequest(baseArgs, userName, w.webBaseURL), + request, ); err != nil { return fmt.Errorf("due-soon checker: failed to dispatch reminder for step %s: %w", step.ID.String(), err) } @@ -131,3 +138,15 @@ func (w *DueSoonCheckerWorker) Work(ctx context.Context, job *river.Job[DueSoonC w.logger.Infow("DueSoonCheckerWorker: dispatched due-soon reminders", "count", dispatched) return nil } + +func requestWithDueSoonCheckerSourceJob(request notification.Request, jobID int64, stepExecutionID string) notification.Request { + sourceJobKind := DueSoonCheckerArgs{}.Kind() + request.Options.SourceJobKind = sourceJobKind + if stepExecutionID != "" { + request.Options.CorrelationID = sourceJobKind + ":" + stepExecutionID + } + if jobID > 0 { + request.Options.SourceJobID = fmt.Sprintf("%d", jobID) + } + return request +} diff --git a/internal/service/worker/jobs.go b/internal/service/worker/jobs.go index dcb2f9a4..740c6dd3 100644 --- a/internal/service/worker/jobs.go +++ b/internal/service/worker/jobs.go @@ -227,6 +227,10 @@ type DigestService interface { SendGlobalDigest(ctx context.Context) error } +type DigestServiceWithSourceJobID interface { + SendGlobalDigestWithSourceJobID(ctx context.Context, sourceJobID string) error +} + // Timeout returns the timeout for email jobs func (SendEmailArgs) Timeout() time.Duration { return 30 * time.Second @@ -548,6 +552,18 @@ func NewSendGlobalDigestWorker(digestService DigestService, logger *zap.SugaredL func (w *SendGlobalDigestWorker) Work(ctx context.Context, job *river.Job[SendGlobalDigestArgs]) error { w.logger.Infow("Processing global digest job", "job_id", job.ID) + if sourceAware, ok := w.digestService.(DigestServiceWithSourceJobID); ok { + if err := sourceAware.SendGlobalDigestWithSourceJobID(ctx, fmt.Sprintf("%d", job.ID)); err != nil { + w.logger.Errorw("Failed to send global digest", + "job_id", job.ID, + "error", err, + ) + return fmt.Errorf("failed to send global digest: %w", err) + } + w.logger.Infow("Global digest processed successfully", "job_id", job.ID) + return nil + } + if err := w.digestService.SendGlobalDigest(ctx); err != nil { w.logger.Errorw("Failed to send global digest", "job_id", job.ID, diff --git a/internal/service/worker/notification_bridge.go b/internal/service/worker/notification_bridge.go index 4bf2e41b..338c44a5 100644 --- a/internal/service/worker/notification_bridge.go +++ b/internal/service/worker/notification_bridge.go @@ -106,35 +106,35 @@ func (e *workerNotificationEnqueuer) IsStarted() bool { return e != nil && e.client != nil } -func (e *workerNotificationEnqueuer) EnqueueNotificationEmail(ctx context.Context, delivery emailprovider.Delivery) error { +func (e *workerNotificationEnqueuer) EnqueueNotificationEmail(ctx context.Context, delivery emailprovider.Delivery) ([]int64, error) { if e == nil || e.client == nil { - return fmt.Errorf("worker client is not initialized") + return nil, fmt.Errorf("worker client is not initialized") } - _, err := e.client.InsertMany(ctx, notificationEmailInsertParams(delivery, normalizedNotificationEmailQueue(e.emailQueue), e.maxAttempts)) + results, err := e.client.InsertMany(ctx, notificationEmailInsertParams(delivery, normalizedNotificationEmailQueue(e.emailQueue), e.maxAttempts)) if err != nil { - return fmt.Errorf("failed to enqueue notification email delivery: %w", err) + return nil, fmt.Errorf("failed to enqueue notification email delivery: %w", err) } - return nil + return jobIDsFromInsertResults(results), nil } -func (e *workerNotificationEnqueuer) EnqueueNotificationSlack(ctx context.Context, delivery slackprovider.Delivery) error { +func (e *workerNotificationEnqueuer) EnqueueNotificationSlack(ctx context.Context, delivery slackprovider.Delivery) ([]int64, error) { if e == nil || e.client == nil { - return fmt.Errorf("worker client is not initialized") + return nil, fmt.Errorf("worker client is not initialized") } params, err := notificationSlackInsertParams(delivery, defaultNotificationSlackQueue, e.maxAttempts) if err != nil { - return err + return nil, err } - _, err = e.client.InsertMany(ctx, params) + results, err := e.client.InsertMany(ctx, params) if err != nil { - return fmt.Errorf("failed to enqueue notification slack delivery: %w", err) + return nil, fmt.Errorf("failed to enqueue notification slack delivery: %w", err) } - return nil + return jobIDsFromInsertResults(results), nil } func (s *workerNotificationEmailSender) IsEnabled() bool { diff --git a/internal/service/worker/notification_definition_helpers.go b/internal/service/worker/notification_definition_helpers.go index 94d720b4..7d2e8c19 100644 --- a/internal/service/worker/notification_definition_helpers.go +++ b/internal/service/worker/notification_definition_helpers.go @@ -152,3 +152,10 @@ func newJobDispatchOptions(jobKind, requestedChannel string, correlationParts .. SourceJobKind: trimmedJobKind, } } + +func requestWithSourceJobID(request notification.Request, jobID int64) notification.Request { + if jobID > 0 { + request.Options.SourceJobID = fmt.Sprintf("%d", jobID) + } + return request +} diff --git a/internal/service/worker/notification_definition_helpers_test.go b/internal/service/worker/notification_definition_helpers_test.go index d11b700a..85b82af6 100644 --- a/internal/service/worker/notification_definition_helpers_test.go +++ b/internal/service/worker/notification_definition_helpers_test.go @@ -24,6 +24,30 @@ func TestBuildWorkflowTaskDigestNotificationRequest_CorrelationIncludesDigestDat assert.Equal(t, JobTypeWorkflowTaskDigest, request.Options.SourceJobKind) } +func TestRequestWithSourceJobIDSetsDispatchMetadata(t *testing.T) { + request := buildWorkflowTaskDigestNotificationRequest( + WorkflowTaskDigestArgs{UserID: "user-1"}, + digestNotificationData{}, + ) + + request = requestWithSourceJobID(request, 241582) + + assert.Equal(t, "241582", request.Options.SourceJobID) +} + +func TestRequestWithSourceJobIDIgnoresNonPositiveJobID(t *testing.T) { + for _, jobID := range []int64{0, -1} { + request := buildWorkflowTaskDigestNotificationRequest( + WorkflowTaskDigestArgs{UserID: "user-1"}, + digestNotificationData{}, + ) + + request = requestWithSourceJobID(request, jobID) + + assert.Empty(t, request.Options.SourceJobID) + } +} + func TestRiskReminderDispatchOptions_IncludesWindowKey(t *testing.T) { riskID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") ownerUserID := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") diff --git a/internal/service/worker/poam_deadline_reminder_worker.go b/internal/service/worker/poam_deadline_reminder_worker.go index 4daefb58..b2c4418a 100644 --- a/internal/service/worker/poam_deadline_reminder_worker.go +++ b/internal/service/worker/poam_deadline_reminder_worker.go @@ -193,7 +193,7 @@ func (w *PoamDeadlineReminderWorker) Work( if err := notificationService.Dispatch( ctx, - buildPoamDeadlineReminderNotificationRequest(args, user.FullName()), + requestWithSourceJobID(buildPoamDeadlineReminderNotificationRequest(args, user.FullName()), riverJobID(job)), ); err != nil { return fmt.Errorf("dispatch poam-deadline-reminder notification: %w", err) } diff --git a/internal/service/worker/poam_digest_worker.go b/internal/service/worker/poam_digest_worker.go index 2c27e075..20d3cf50 100644 --- a/internal/service/worker/poam_digest_worker.go +++ b/internal/service/worker/poam_digest_worker.go @@ -543,7 +543,7 @@ func (w *PoamOpenDigestWorker) Work(ctx context.Context, job *river.Job[PoamOpen if err := notificationService.Dispatch( ctx, - buildPoamOpenDigestNotificationRequest(args, data), + requestWithSourceJobID(buildPoamOpenDigestNotificationRequest(args, data), riverJobID(job)), ); err != nil { return fmt.Errorf("dispatch poam-open-digest notification: %w", err) } diff --git a/internal/service/worker/poam_milestone_overdue_worker.go b/internal/service/worker/poam_milestone_overdue_worker.go index 00c98310..7d1835cb 100644 --- a/internal/service/worker/poam_milestone_overdue_worker.go +++ b/internal/service/worker/poam_milestone_overdue_worker.go @@ -241,7 +241,7 @@ func (w *MilestoneOverdueReminderWorker) Work( if err := notificationService.Dispatch( ctx, - buildPoamMilestoneOverdueNotificationRequest(args, user.FullName()), + requestWithSourceJobID(buildPoamMilestoneOverdueNotificationRequest(args, user.FullName()), riverJobID(job)), ); err != nil { return fmt.Errorf("dispatch poam-milestone-overdue-reminder notification: %w", err) } diff --git a/internal/service/worker/poam_overdue_transition_worker.go b/internal/service/worker/poam_overdue_transition_worker.go index 8cd63340..4238a683 100644 --- a/internal/service/worker/poam_overdue_transition_worker.go +++ b/internal/service/worker/poam_overdue_transition_worker.go @@ -201,7 +201,7 @@ func (w *PoamOverdueNotificationWorker) Work( if err := notificationService.Dispatch( ctx, - buildPoamOverdueNotificationRequest(args, user.FullName()), + requestWithSourceJobID(buildPoamOverdueNotificationRequest(args, user.FullName()), riverJobID(job)), ); err != nil { return fmt.Errorf("dispatch poam-overdue-notification notification: %w", err) } diff --git a/internal/service/worker/risk_digest_worker.go b/internal/service/worker/risk_digest_worker.go index 226d4cf4..b919bde0 100644 --- a/internal/service/worker/risk_digest_worker.go +++ b/internal/service/worker/risk_digest_worker.go @@ -278,7 +278,7 @@ func (w *RiskOpenDigestWorker) Work(ctx context.Context, job *river.Job[RiskOpen if err := notificationService.Dispatch( ctx, - buildRiskOpenDigestNotificationRequest(args, data), + requestWithSourceJobID(buildRiskOpenDigestNotificationRequest(args, data), riverJobID(job)), ); err != nil { return fmt.Errorf("dispatch risk-open-digest notification: %w", err) } diff --git a/internal/service/worker/risk_workers.go b/internal/service/worker/risk_workers.go index 04b9e8c8..ea092e51 100644 --- a/internal/service/worker/risk_workers.go +++ b/internal/service/worker/risk_workers.go @@ -364,7 +364,7 @@ func (w *RiskReviewDueReminderWorker) Work(ctx context.Context, job *river.Job[R job.Args.RiskID, job.Args.OwnerUserID, func(userName string, data riskNotificationData) notification.Request { - return buildRiskReviewDueReminderNotificationRequest(job.Args, userName, data) + return requestWithSourceJobID(buildRiskReviewDueReminderNotificationRequest(job.Args, userName, data), riverJobID(job)) }, ) } @@ -414,7 +414,7 @@ func (w *RiskReviewOverdueEscalationWorker) Work(ctx context.Context, job *river job.Args.RiskID, job.Args.OwnerUserID, func(userName string, data riskNotificationData) notification.Request { - return buildRiskReviewOverdueEscalationNotificationRequest(job.Args, userName, data) + return requestWithSourceJobID(buildRiskReviewOverdueEscalationNotificationRequest(job.Args, userName, data), riverJobID(job)) }, ) } @@ -464,7 +464,7 @@ func (w *RiskStaleOpenReminderWorker) Work(ctx context.Context, job *river.Job[R job.Args.RiskID, job.Args.OwnerUserID, func(userName string, data riskNotificationData) notification.Request { - return buildRiskStaleOpenReminderNotificationRequest(job.Args, userName, data) + return requestWithSourceJobID(buildRiskStaleOpenReminderNotificationRequest(job.Args, userName, data), riverJobID(job)) }, ) } diff --git a/internal/service/worker/service.go b/internal/service/worker/service.go index 74caa207..034e56ba 100644 --- a/internal/service/worker/service.go +++ b/internal/service/worker/service.go @@ -830,15 +830,15 @@ func (s *Service) EnqueueSendEmail(ctx context.Context, args *SendEmailArgs) err } // EnqueueNotificationEmail enqueues a provider-ready notification email delivery. -func (s *Service) EnqueueNotificationEmail(ctx context.Context, delivery emailprovider.Delivery) error { +func (s *Service) EnqueueNotificationEmail(ctx context.Context, delivery emailprovider.Delivery) ([]int64, error) { if !s.config.Enabled { - return fmt.Errorf("worker service is disabled") + return nil, fmt.Errorf("worker service is disabled") } if s.client == nil { - return fmt.Errorf("worker client is not initialized") + return nil, fmt.Errorf("worker client is not initialized") } if err := delivery.Validate(); err != nil { - return err + return nil, err } maxAttempts := s.config.RetryPolicy.MaxAttempts @@ -846,24 +846,24 @@ func (s *Service) EnqueueNotificationEmail(ctx context.Context, delivery emailpr maxAttempts = 5 } - _, err := s.client.InsertMany(ctx, notificationEmailInsertParams(delivery, s.emailQueue(), maxAttempts)) + results, err := s.client.InsertMany(ctx, notificationEmailInsertParams(delivery, s.emailQueue(), maxAttempts)) if err != nil { - return fmt.Errorf("failed to enqueue notification email delivery: %w", err) + return nil, fmt.Errorf("failed to enqueue notification email delivery: %w", err) } - return nil + return jobIDsFromInsertResults(results), nil } // EnqueueNotificationSlack enqueues a provider-ready notification Slack delivery. -func (s *Service) EnqueueNotificationSlack(ctx context.Context, delivery slackprovider.Delivery) error { +func (s *Service) EnqueueNotificationSlack(ctx context.Context, delivery slackprovider.Delivery) ([]int64, error) { if !s.config.Enabled { - return fmt.Errorf("worker service is disabled") + return nil, fmt.Errorf("worker service is disabled") } if s.client == nil { - return fmt.Errorf("worker client is not initialized") + return nil, fmt.Errorf("worker client is not initialized") } if err := delivery.Validate(); err != nil { - return err + return nil, err } maxAttempts := s.config.RetryPolicy.MaxAttempts @@ -873,15 +873,26 @@ func (s *Service) EnqueueNotificationSlack(ctx context.Context, delivery slackpr params, err := notificationSlackInsertParams(delivery, s.slackQueue(), maxAttempts) if err != nil { - return err + return nil, err } - _, err = s.client.InsertMany(ctx, params) + results, err := s.client.InsertMany(ctx, params) if err != nil { - return fmt.Errorf("failed to enqueue notification slack delivery: %w", err) + return nil, fmt.Errorf("failed to enqueue notification slack delivery: %w", err) } - return nil + return jobIDsFromInsertResults(results), nil +} + +func jobIDsFromInsertResults(results []*rivertype.JobInsertResult) []int64 { + jobIDs := make([]int64, 0, len(results)) + for _, result := range results { + if result == nil || result.Job == nil { + continue + } + jobIDs = append(jobIDs, result.Job.ID) + } + return jobIDs } func notificationInsertOpts(queue string, maxAttempts int, metadata notification.TransportMetadata) *river.InsertOpts { @@ -910,6 +921,11 @@ func notificationDeliveryUniqueOpts(metadata notification.TransportMetadata) (ri ByArgs: true, ByPeriod: 24 * time.Hour, }, true + case JobTypeWorkflowDueSoonChecker: + return river.UniqueOpts{ + ByArgs: true, + ByPeriod: 24 * time.Hour, + }, true case JobTypeWorkflowTaskDigest, JobTypeWorkflowExecutionFailed, JobTypeRiskReviewDueReminder, diff --git a/internal/service/worker/workflow_execution_failed_worker.go b/internal/service/worker/workflow_execution_failed_worker.go index 49cfcd3a..a1d74b00 100644 --- a/internal/service/worker/workflow_execution_failed_worker.go +++ b/internal/service/worker/workflow_execution_failed_worker.go @@ -121,7 +121,7 @@ func (w *WorkflowExecutionFailedWorker) Work(ctx context.Context, job *river.Job MyTasksURL: w.webBaseURL + "/my-tasks", } requests := []notification.Request{ - buildWorkflowExecutionFailedNotificationRequest(args, recipient.ID, model), + requestWithSourceJobID(buildWorkflowExecutionFailedNotificationRequest(args, recipient.ID, model), riverJobID(job)), } targets, err := w.configuredWorkflowExecutionFailedTargets(ctx) @@ -130,7 +130,7 @@ func (w *WorkflowExecutionFailedWorker) Work(ctx context.Context, job *river.Job } if systemRequest, ok := buildWorkflowExecutionFailedSystemNotificationRequest(args, targets, model); ok { - requests = append(requests, systemRequest) + requests = append(requests, requestWithSourceJobID(systemRequest, riverJobID(job))) } if err := notifier.DispatchFanout( diff --git a/internal/service/worker/workflow_notification_jobs.go b/internal/service/worker/workflow_notification_jobs.go index 3f06d467..4663fa58 100644 --- a/internal/service/worker/workflow_notification_jobs.go +++ b/internal/service/worker/workflow_notification_jobs.go @@ -129,12 +129,12 @@ func (w *WorkflowTaskAssignedWorker) Work(ctx context.Context, job *river.Job[Wo } if args.AssignedToType == workflows.AssignmentTypeEmail.String() { - return w.dispatchToEmailAddress(ctx, args) + return w.dispatchToEmailAddress(ctx, args, riverJobID(job)) } - return w.dispatchToUser(ctx, args) + return w.dispatchToUser(ctx, args, riverJobID(job)) } -func (w *WorkflowTaskAssignedWorker) dispatchToUser(ctx context.Context, args WorkflowTaskAssignedArgs) error { +func (w *WorkflowTaskAssignedWorker) dispatchToUser(ctx context.Context, args WorkflowTaskAssignedArgs, sourceJobID int64) error { hydratedArgs, ready, err := w.hydrateNotificationArgs(ctx, args) if err != nil { return err @@ -160,7 +160,7 @@ func (w *WorkflowTaskAssignedWorker) dispatchToUser(ctx context.Context, args Wo if err := notifier.Dispatch( ctx, - buildWorkflowTaskAssignedNotificationRequest(hydratedArgs, user.FullName(), w.webBaseURL), + requestWithSourceJobID(buildWorkflowTaskAssignedNotificationRequest(hydratedArgs, user.FullName(), w.webBaseURL), sourceJobID), ); err != nil { return fmt.Errorf("dispatch workflow-task-assigned notification: %w", err) } @@ -168,7 +168,7 @@ func (w *WorkflowTaskAssignedWorker) dispatchToUser(ctx context.Context, args Wo return nil } -func (w *WorkflowTaskAssignedWorker) dispatchToEmailAddress(ctx context.Context, args WorkflowTaskAssignedArgs) error { +func (w *WorkflowTaskAssignedWorker) dispatchToEmailAddress(ctx context.Context, args WorkflowTaskAssignedArgs, sourceJobID int64) error { hydratedArgs, ready, err := w.hydrateNotificationArgs(ctx, args) if err != nil { return err @@ -184,7 +184,7 @@ func (w *WorkflowTaskAssignedWorker) dispatchToEmailAddress(ctx context.Context, if err := notifier.Dispatch( ctx, - buildWorkflowTaskAssignedNotificationRequest(hydratedArgs, "", w.webBaseURL), + requestWithSourceJobID(buildWorkflowTaskAssignedNotificationRequest(hydratedArgs, "", w.webBaseURL), sourceJobID), ); err != nil { return fmt.Errorf("dispatch workflow-task-assigned direct email notification: %w", err) } @@ -289,7 +289,7 @@ func (w *WorkflowTaskDueSoonWorker) Work(ctx context.Context, job *river.Job[Wor if err := notifier.Dispatch( ctx, - buildWorkflowTaskDueSoonNotificationRequest(args, user.FullName(), w.webBaseURL), + requestWithSourceJobID(buildWorkflowTaskDueSoonNotificationRequest(args, user.FullName(), w.webBaseURL), riverJobID(job)), ); err != nil { return fmt.Errorf("dispatch workflow-task-due-soon notification: %w", err) } diff --git a/internal/service/worker/workflow_notification_jobs_test.go b/internal/service/worker/workflow_notification_jobs_test.go index af52af87..a8a1b735 100644 --- a/internal/service/worker/workflow_notification_jobs_test.go +++ b/internal/service/worker/workflow_notification_jobs_test.go @@ -13,6 +13,7 @@ import ( "github.com/compliance-framework/api/internal/service/relational/workflows" "github.com/google/uuid" "github.com/riverqueue/river" + "github.com/riverqueue/river/rivertype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" @@ -146,7 +147,10 @@ func TestDueSoonCheckerWorker_EnqueuesOneJobPerChannel(t *testing.T) { } require.NoError(t, db.Create(&stepExecution).Error) - err := worker.Work(context.Background(), &river.Job[DueSoonCheckerArgs]{Args: DueSoonCheckerArgs{}}) + err := worker.Work(context.Background(), &river.Job[DueSoonCheckerArgs]{ + JobRow: &rivertype.JobRow{ID: 241582}, + Args: DueSoonCheckerArgs{}, + }) require.NoError(t, err) require.Len(t, client.params, 2) @@ -163,8 +167,9 @@ func TestDueSoonCheckerWorker_EnqueuesOneJobPerChannel(t *testing.T) { assert.Equal(t, []string{"alice@example.com"}, args.To) assert.Equal(t, JobTypeWorkflowTaskDueSoon, args.NotificationKind) assert.Equal(t, userID.String(), args.RecipientUserID) - assert.Equal(t, JobTypeWorkflowTaskDueSoon, args.SourceJobKind) - assert.Equal(t, "workflow_task_due_soon:"+stepExecution.ID.String(), args.CorrelationID) + assert.Equal(t, JobTypeWorkflowDueSoonChecker, args.SourceJobKind) + assert.Equal(t, "241582", args.SourceJobID) + assert.Equal(t, JobTypeWorkflowDueSoonChecker+":"+stepExecution.ID.String(), args.CorrelationID) assert.Equal(t, "email", param.InsertOpts.Queue) assert.True(t, param.InsertOpts.UniqueOpts.ByArgs) assert.Equal(t, 24*time.Hour, param.InsertOpts.UniqueOpts.ByPeriod) @@ -174,8 +179,9 @@ func TestDueSoonCheckerWorker_EnqueuesOneJobPerChannel(t *testing.T) { assert.Equal(t, slackprovider.TargetTypeDirectMessage, args.TargetType) assert.Equal(t, JobTypeWorkflowTaskDueSoon, args.NotificationKind) assert.Equal(t, userID.String(), args.RecipientUserID) - assert.Equal(t, JobTypeWorkflowTaskDueSoon, args.SourceJobKind) - assert.Equal(t, "workflow_task_due_soon:"+stepExecution.ID.String(), args.CorrelationID) + assert.Equal(t, JobTypeWorkflowDueSoonChecker, args.SourceJobKind) + assert.Equal(t, "241582", args.SourceJobID) + assert.Equal(t, JobTypeWorkflowDueSoonChecker+":"+stepExecution.ID.String(), args.CorrelationID) assert.Equal(t, "slack", param.InsertOpts.Queue) assert.True(t, param.InsertOpts.UniqueOpts.ByArgs) assert.Equal(t, 24*time.Hour, param.InsertOpts.UniqueOpts.ByPeriod) diff --git a/internal/service/worker/workflow_task_digest_worker.go b/internal/service/worker/workflow_task_digest_worker.go index 928789c2..74947788 100644 --- a/internal/service/worker/workflow_task_digest_worker.go +++ b/internal/service/worker/workflow_task_digest_worker.go @@ -131,7 +131,7 @@ func (w *WorkflowTaskDigestWorker) Work(ctx context.Context, job *river.Job[Work if err := notificationService.Dispatch( ctx, - buildWorkflowTaskDigestNotificationRequest(args, data), + requestWithSourceJobID(buildWorkflowTaskDigestNotificationRequest(args, data), riverJobID(job)), ); err != nil { return fmt.Errorf("dispatch workflow-task-digest notification: %w", err) }