diff --git a/.env.example b/.env.example index a2331f84..5150ca08 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,8 @@ CCF_RISK_CONFIG="risk.yaml" CCF_SLACK_ENABLED=false # Slack token generated from https://api.slack.com/apps, with "chat:write" scope. CCF_SLACK_TOKEN="xxxx" -# Channel name to send digest messages to, e.g. "my_slack_channel". Make sure the app user is added to this channel. +# DEPRECATED: +# Legacy one-time migration source for the initial Slack digest destination row in ccf_system_notification_destinations. CCF_SLACK_DIGEST_CHANNEL="my_slack_channel" # Slack OAuth account-linking config. CCF_SLACK_CLIENT_ID="" diff --git a/cmd/migrate.go b/cmd/migrate.go index 4e800a3a..42ad8eab 100644 --- a/cmd/migrate.go +++ b/cmd/migrate.go @@ -60,7 +60,7 @@ func migrateUp(cmd *cobra.Command, args []string) { panic("failed to connect database") } - err = service.MigrateUp(db) + err = service.MigrateUpWithConfig(db, cfg) if err != nil { panic(err) } diff --git a/cmd/run.go b/cmd/run.go index d76e5cba..2f57d2f2 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -81,7 +81,7 @@ func RunServer(cmd *cobra.Command, args []string) { sugar.Fatalw("Failed to connect to SQL database", "error", err) } - err = service.MigrateUp(db) + err = service.MigrateUpWithConfig(db, cfg) if err != nil { sugar.Fatalw("Failed to migrate database", "error", err) } diff --git a/docs/docs.go b/docs/docs.go index 73209a20..f3f0abd4 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -100,6 +100,215 @@ const docTemplate = `{ ] } }, + "/admin/notifications": { + "get": { + "description": "Returns system notification destination configurations for admin management", + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "List system notification destinations", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-handler_systemNotificationResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/notifications/providers": { + "get": { + "description": "Returns notification providers registered in the backend", + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "List available notification providers", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-handler_availableNotificationProviderResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/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": [ + "Notifications" + ], + "summary": "Create system notification destination", + "parameters": [ + { + "type": "string", + "description": "Notification name", + "name": "notificationName", + "in": "path", + "required": true + }, + { + "description": "Destination details", + "name": "destination", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.createSystemNotificationDestinationRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_configuredSystemDestinationResponse" + } + }, + "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": [] + } + ] + }, + "delete": { + "description": "Deletes a stored system notification destination configuration for an admin-managed notification", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Delete system notification destination", + "parameters": [ + { + "type": "string", + "description": "Notification name", + "name": "notificationName", + "in": "path", + "required": true + }, + { + "description": "Destination details", + "name": "destination", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.createSystemNotificationDestinationRequest" + } + } + ], + "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" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/admin/risk-templates": { "get": { "description": "List risk templates with optional filters and pagination.", @@ -2187,6 +2396,43 @@ const docTemplate = `{ } } }, + "/notifications/providers": { + "get": { + "description": "Returns notification provider availability for authenticated users", + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "List notification provider status", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-handler_notificationProviderStatusResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/oscal/activities": { "post": { "description": "Creates a new activity for us in other resources.", @@ -27159,6 +27405,18 @@ const docTemplate = `{ } } }, + "handler.GenericDataListResponse-handler_availableNotificationProviderResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/handler.availableNotificationProviderResponse" + } + } + } + }, "handler.GenericDataListResponse-handler_milestoneResponse": { "type": "object", "properties": { @@ -27171,6 +27429,18 @@ const docTemplate = `{ } } }, + "handler.GenericDataListResponse-handler_notificationProviderStatusResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/handler.notificationProviderStatusResponse" + } + } + } + }, "handler.GenericDataListResponse-handler_poamItemResponse": { "type": "object", "properties": { @@ -27207,6 +27477,18 @@ const docTemplate = `{ } } }, + "handler.GenericDataListResponse-handler_systemNotificationResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/handler.systemNotificationResponse" + } + } + } + }, "handler.GenericDataListResponse-oscalTypes_1_1_3_AssessmentPlan": { "type": "object", "properties": { @@ -27826,6 +28108,19 @@ const docTemplate = `{ } } }, + "handler.GenericDataResponse-handler_configuredSystemDestinationResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/handler.configuredSystemDestinationResponse" + } + ] + } + } + }, "handler.GenericDataResponse-handler_milestoneResponse": { "type": "object", "properties": { @@ -28984,6 +29279,40 @@ const docTemplate = `{ } } }, + "handler.availableNotificationProviderResponse": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "providerType": { + "type": "string" + } + } + }, + "handler.configuredSystemDestinationResponse": { + "type": "object", + "properties": { + "destinationTarget": { + "type": "string" + }, + "providerType": { + "type": "string" + } + } + }, "handler.controlLinkResponse": { "type": "object", "properties": { @@ -29180,6 +29509,21 @@ const docTemplate = `{ } } }, + "handler.createSystemNotificationDestinationRequest": { + "type": "object", + "required": [ + "destinationTarget", + "providerType" + ], + "properties": { + "destinationTarget": { + "type": "string" + }, + "providerType": { + "type": "string" + } + } + }, "handler.evidenceLinkResponse": { "type": "object", "properties": { @@ -29260,6 +29604,17 @@ const docTemplate = `{ } } }, + "handler.notificationProviderStatusResponse": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "providerType": { + "type": "string" + } + } + }, "handler.poamControlRefRequest": { "type": "object", "required": [ @@ -29699,6 +30054,20 @@ const docTemplate = `{ } } }, + "handler.systemNotificationResponse": { + "type": "object", + "properties": { + "configuredDestinations": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.configuredSystemDestinationResponse" + } + }, + "name": { + "type": "string" + } + } + }, "handler.threatIDRequest": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 0a90ac88..15f4a14d 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -94,6 +94,215 @@ ] } }, + "/admin/notifications": { + "get": { + "description": "Returns system notification destination configurations for admin management", + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "List system notification destinations", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-handler_systemNotificationResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/notifications/providers": { + "get": { + "description": "Returns notification providers registered in the backend", + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "List available notification providers", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-handler_availableNotificationProviderResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/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": [ + "Notifications" + ], + "summary": "Create system notification destination", + "parameters": [ + { + "type": "string", + "description": "Notification name", + "name": "notificationName", + "in": "path", + "required": true + }, + { + "description": "Destination details", + "name": "destination", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.createSystemNotificationDestinationRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_configuredSystemDestinationResponse" + } + }, + "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": [] + } + ] + }, + "delete": { + "description": "Deletes a stored system notification destination configuration for an admin-managed notification", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Delete system notification destination", + "parameters": [ + { + "type": "string", + "description": "Notification name", + "name": "notificationName", + "in": "path", + "required": true + }, + { + "description": "Destination details", + "name": "destination", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.createSystemNotificationDestinationRequest" + } + } + ], + "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" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/admin/risk-templates": { "get": { "description": "List risk templates with optional filters and pagination.", @@ -2181,6 +2390,43 @@ } } }, + "/notifications/providers": { + "get": { + "description": "Returns notification provider availability for authenticated users", + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "List notification provider status", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-handler_notificationProviderStatusResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/oscal/activities": { "post": { "description": "Creates a new activity for us in other resources.", @@ -27153,6 +27399,18 @@ } } }, + "handler.GenericDataListResponse-handler_availableNotificationProviderResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/handler.availableNotificationProviderResponse" + } + } + } + }, "handler.GenericDataListResponse-handler_milestoneResponse": { "type": "object", "properties": { @@ -27165,6 +27423,18 @@ } } }, + "handler.GenericDataListResponse-handler_notificationProviderStatusResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/handler.notificationProviderStatusResponse" + } + } + } + }, "handler.GenericDataListResponse-handler_poamItemResponse": { "type": "object", "properties": { @@ -27201,6 +27471,18 @@ } } }, + "handler.GenericDataListResponse-handler_systemNotificationResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/handler.systemNotificationResponse" + } + } + } + }, "handler.GenericDataListResponse-oscalTypes_1_1_3_AssessmentPlan": { "type": "object", "properties": { @@ -27820,6 +28102,19 @@ } } }, + "handler.GenericDataResponse-handler_configuredSystemDestinationResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/handler.configuredSystemDestinationResponse" + } + ] + } + } + }, "handler.GenericDataResponse-handler_milestoneResponse": { "type": "object", "properties": { @@ -28978,6 +29273,40 @@ } } }, + "handler.availableNotificationProviderResponse": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "providerType": { + "type": "string" + } + } + }, + "handler.configuredSystemDestinationResponse": { + "type": "object", + "properties": { + "destinationTarget": { + "type": "string" + }, + "providerType": { + "type": "string" + } + } + }, "handler.controlLinkResponse": { "type": "object", "properties": { @@ -29174,6 +29503,21 @@ } } }, + "handler.createSystemNotificationDestinationRequest": { + "type": "object", + "required": [ + "destinationTarget", + "providerType" + ], + "properties": { + "destinationTarget": { + "type": "string" + }, + "providerType": { + "type": "string" + } + } + }, "handler.evidenceLinkResponse": { "type": "object", "properties": { @@ -29254,6 +29598,17 @@ } } }, + "handler.notificationProviderStatusResponse": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "providerType": { + "type": "string" + } + } + }, "handler.poamControlRefRequest": { "type": "object", "required": [ @@ -29693,6 +30048,20 @@ } } }, + "handler.systemNotificationResponse": { + "type": "object", + "properties": { + "configuredDestinations": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.configuredSystemDestinationResponse" + } + }, + "name": { + "type": "string" + } + } + }, "handler.threatIDRequest": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 4f19a16c..ba352b4c 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -555,6 +555,14 @@ definitions: $ref: '#/definitions/handler.StatusInterval' type: array type: object + handler.GenericDataListResponse-handler_availableNotificationProviderResponse: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/handler.availableNotificationProviderResponse' + type: array + type: object handler.GenericDataListResponse-handler_milestoneResponse: properties: data: @@ -563,6 +571,14 @@ definitions: $ref: '#/definitions/handler.milestoneResponse' type: array type: object + handler.GenericDataListResponse-handler_notificationProviderStatusResponse: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/handler.notificationProviderStatusResponse' + type: array + type: object handler.GenericDataListResponse-handler_poamItemResponse: properties: data: @@ -587,6 +603,14 @@ definitions: $ref: '#/definitions/handler.selectableUserResponse' type: array type: object + handler.GenericDataListResponse-handler_systemNotificationResponse: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/handler.systemNotificationResponse' + type: array + type: object handler.GenericDataListResponse-oscal_InventoryItemWithSource: properties: data: @@ -988,6 +1012,13 @@ definitions: - $ref: '#/definitions/handler.SubscriptionsResponse' description: Items from the list response type: object + handler.GenericDataResponse-handler_configuredSystemDestinationResponse: + properties: + data: + allOf: + - $ref: '#/definitions/handler.configuredSystemDestinationResponse' + description: Items from the list response + type: object handler.GenericDataResponse-handler_milestoneResponse: properties: data: @@ -1641,6 +1672,28 @@ definitions: subject-id: type: string type: object + handler.availableNotificationProviderResponse: + properties: + description: + type: string + displayName: + type: string + enabled: + type: boolean + metadata: + additionalProperties: + type: string + type: object + providerType: + type: string + type: object + handler.configuredSystemDestinationResponse: + properties: + destinationTarget: + type: string + providerType: + type: string + type: object handler.controlLinkResponse: properties: catalog-id: @@ -1773,6 +1826,16 @@ definitions: title: type: string type: object + handler.createSystemNotificationDestinationRequest: + properties: + destinationTarget: + type: string + providerType: + type: string + required: + - destinationTarget + - providerType + type: object handler.evidenceLinkResponse: properties: created-at: @@ -1825,6 +1888,13 @@ definitions: updatedAt: type: string type: object + handler.notificationProviderStatusResponse: + properties: + enabled: + type: boolean + providerType: + type: string + type: object handler.poamControlRefRequest: properties: catalogId: @@ -2121,6 +2191,15 @@ definitions: id: type: string type: object + handler.systemNotificationResponse: + properties: + configuredDestinations: + items: + $ref: '#/definitions/handler.configuredSystemDestinationResponse' + type: array + name: + type: string + type: object handler.threatIDRequest: properties: id: @@ -9094,6 +9173,142 @@ paths: summary: Trigger evidence digest tags: - Digest + /admin/notifications: + get: + description: Returns system notification destination configurations for admin + management + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-handler_systemNotificationResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List system notification destinations + tags: + - Notifications + /admin/notifications/{notificationName}/destinations: + delete: + consumes: + - application/json + description: Deletes a stored system notification destination configuration + for an admin-managed notification + parameters: + - description: Notification name + in: path + name: notificationName + required: true + type: string + - description: Destination details + in: body + name: destination + required: true + schema: + $ref: '#/definitions/handler.createSystemNotificationDestinationRequest' + produces: + - application/json + 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' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Delete system notification destination + tags: + - Notifications + post: + consumes: + - application/json + description: Creates a new system notification destination configuration for + an admin-managed notification + parameters: + - description: Notification name + in: path + name: notificationName + required: true + type: string + - description: Destination details + in: body + name: destination + required: true + schema: + $ref: '#/definitions/handler.createSystemNotificationDestinationRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_configuredSystemDestinationResponse' + "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: [] + summary: Create system notification destination + tags: + - Notifications + /admin/notifications/providers: + get: + description: Returns notification providers registered in the backend + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-handler_availableNotificationProviderResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List available notification providers + tags: + - Notifications /admin/risk-templates: get: description: List risk templates with optional filters and pagination. @@ -10465,6 +10680,29 @@ paths: summary: Import dashboard filters tags: - Filters + /notifications/providers: + get: + description: Returns notification provider availability for authenticated users + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-handler_notificationProviderStatusResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List notification provider status + tags: + - Notifications /oscal/activities: post: consumes: diff --git a/internal/api/handler/api.go b/internal/api/handler/api.go index 760fd15d..0939a926 100644 --- a/internal/api/handler/api.go +++ b/internal/api/handler/api.go @@ -121,6 +121,16 @@ 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) + notificationsPublicGroup := server.API().Group("/notifications") + notificationsPublicGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) + notificationsHandler.RegisterPublic(notificationsPublicGroup) + + notificationsGroup := server.API().Group("/admin/notifications") + notificationsGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) + notificationsGroup.Use(middleware.RequireAdminGroups(db, config, logger)) + notificationsHandler.Register(notificationsGroup) + // Digest handler (admin only) if services.DigestService != nil { digestHandler := NewDigestHandler(services.DigestService, logger) diff --git a/internal/api/handler/notifications.go b/internal/api/handler/notifications.go new file mode 100644 index 00000000..b07c5507 --- /dev/null +++ b/internal/api/handler/notifications.go @@ -0,0 +1,554 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "reflect" + "sort" + "strings" + + "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" + "github.com/compliance-framework/api/internal/service/relational" + "github.com/labstack/echo/v4" + "go.uber.org/zap" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +type NotificationsHandler struct { + sugar *zap.SugaredLogger + db *gorm.DB + providers notification.ProviderLookup +} + +type configuredSystemDestinationResponse struct { + ProviderType string `json:"providerType"` + DestinationTarget string `json:"destinationTarget"` +} + +type availableNotificationProviderResponse struct { + ProviderType string `json:"providerType"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type notificationProviderStatusResponse struct { + ProviderType string `json:"providerType"` + Enabled bool `json:"enabled"` +} + +type systemNotificationResponse struct { + Name string `json:"name"` + ConfiguredDestinations []configuredSystemDestinationResponse `json:"configuredDestinations"` +} + +type createSystemNotificationDestinationRequest struct { + ProviderType string `json:"providerType" validate:"required"` + DestinationTarget string `json:"destinationTarget" validate:"required"` +} + +func (r *createSystemNotificationDestinationRequest) UnmarshalJSON(data []byte) error { + type requestAlias struct { + ProviderTypeCamel string `json:"providerType"` + DestinationTargetCamel string `json:"destinationTarget"` + ProviderTypeKebab string `json:"provider-type"` + DestinationTargetKebab string `json:"destination-target"` + } + + var decoded requestAlias + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + + r.ProviderType = strings.TrimSpace(firstNonEmpty(decoded.ProviderTypeCamel, decoded.ProviderTypeKebab)) + r.DestinationTarget = strings.TrimSpace(firstNonEmpty(decoded.DestinationTargetCamel, decoded.DestinationTargetKebab)) + return nil +} + +func NewNotificationsHandler(sugar *zap.SugaredLogger, db *gorm.DB, cfg *config.Config) *NotificationsHandler { + return &NotificationsHandler{ + sugar: sugar, + db: db, + providers: notificationproviders.NewLookup(notificationproviders.WithConfig(cfg)), + } +} + +func (h *NotificationsHandler) Register(api *echo.Group) { + api.GET("", h.ListSystemNotifications) + api.GET("/providers", h.ListNotificationProviders) + api.POST("/:notificationName/destinations", h.CreateSystemNotificationDestination) + api.DELETE("/:notificationName/destinations", h.DeleteSystemNotificationDestination) +} + +func (h *NotificationsHandler) RegisterPublic(api *echo.Group) { + api.GET("/providers", h.ListNotificationProviderStatus) +} + +// ListNotificationProviders godoc +// +// @Summary List available notification providers +// @Description Returns notification providers registered in the backend +// @Tags Notifications +// @Produce json +// @Success 200 {object} handler.GenericDataListResponse[handler.availableNotificationProviderResponse] +// @Failure 401 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/notifications/providers [get] +func (h *NotificationsHandler) ListNotificationProviders(ctx echo.Context) error { + catalog, ok := h.providers.(notification.ProviderCatalog) + if !ok { + err := errors.New("notification provider catalog is not configured") + h.sugar.Errorw("Failed to list notification providers", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + providers := catalog.Providers() + response := make([]availableNotificationProviderResponse, 0, len(providers)) + for _, provider := range providers { + response = append(response, availableNotificationProviderResponse{ + ProviderType: provider.ProviderType, + DisplayName: provider.DisplayName, + Description: provider.Description, + Enabled: provider.Enabled, + Metadata: provider.Metadata, + }) + } + + return ctx.JSON(http.StatusOK, GenericDataListResponse[availableNotificationProviderResponse]{Data: response}) +} + +// ListNotificationProviderStatus godoc +// +// @Summary List notification provider status +// @Description Returns notification provider availability for authenticated users +// @Tags Notifications +// @Produce json +// @Success 200 {object} handler.GenericDataListResponse[handler.notificationProviderStatusResponse] +// @Failure 401 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /notifications/providers [get] +func (h *NotificationsHandler) ListNotificationProviderStatus(ctx echo.Context) error { + catalog, ok := h.providers.(notification.ProviderCatalog) + if !ok { + err := errors.New("notification provider catalog is not configured") + h.sugar.Errorw("Failed to list notification provider status", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + providers := catalog.Providers() + response := make([]notificationProviderStatusResponse, 0, len(providers)) + for _, provider := range providers { + response = append(response, notificationProviderStatusResponse{ + ProviderType: provider.ProviderType, + Enabled: provider.Enabled, + }) + } + + return ctx.JSON(http.StatusOK, GenericDataListResponse[notificationProviderStatusResponse]{Data: response}) +} + +// ListSystemNotifications godoc +// +// @Summary List system notification destinations +// @Description Returns system notification destination configurations for admin management +// @Tags Notifications +// @Produce json +// @Success 200 {object} handler.GenericDataListResponse[handler.systemNotificationResponse] +// @Failure 401 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/notifications [get] +func (h *NotificationsHandler) ListSystemNotifications(ctx echo.Context) error { + var rows []relational.SystemNotificationDestination + if err := h.db.WithContext(ctx.Request().Context()). + Order("notification_type ASC, provider ASC, created_at ASC"). + Find(&rows).Error; err != nil { + h.sugar.Errorw("Failed to list system notification destinations", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + configsByName := make(map[string][]configuredSystemDestinationResponse) + seenDestinations := make(map[string]struct{}) + + for i := range rows { + name, ok := notification.NormalizeNotificationType(rows[i].NotificationType) + if !ok { + err := fmt.Errorf("unsupported notification type %q", rows[i].NotificationType) + h.sugar.Errorw("Invalid configured system notification type", "error", err, "notificationType", rows[i].NotificationType) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + destination, err := h.destinationResponseForRecord(rows[i]) + if err != nil { + h.sugar.Errorw( + "Invalid configured system notification destination", + "error", err, + "notificationType", name, + "provider", rows[i].Provider, + ) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + destinationKey := name + ":" + destination.ProviderType + ":" + strings.ToLower(strings.TrimSpace(destination.DestinationTarget)) + if _, exists := seenDestinations[destinationKey]; exists { + continue + } + seenDestinations[destinationKey] = struct{}{} + + configsByName[name] = append(configsByName[name], destination) + } + + orderedNames := make([]string, 0, len(configsByName)) + for name := range configsByName { + orderedNames = append(orderedNames, name) + } + sort.Strings(orderedNames) + + response := make([]systemNotificationResponse, 0, len(orderedNames)) + for _, name := range orderedNames { + destinations := append([]configuredSystemDestinationResponse(nil), configsByName[name]...) + sort.Slice(destinations, func(i, j int) bool { + if destinations[i].ProviderType == destinations[j].ProviderType { + return destinations[i].DestinationTarget < destinations[j].DestinationTarget + } + return destinations[i].ProviderType < destinations[j].ProviderType + }) + + response = append(response, systemNotificationResponse{ + Name: strings.ToUpper(name), + ConfiguredDestinations: destinations, + }) + } + + return ctx.JSON(http.StatusOK, GenericDataListResponse[systemNotificationResponse]{Data: response}) +} + +// CreateSystemNotificationDestination godoc +// +// @Summary Create system notification destination +// @Description Creates a new system notification destination configuration for an admin-managed notification +// @Tags Notifications +// @Accept json +// @Produce json +// @Param notificationName path string true "Notification name" +// @Param destination body handler.createSystemNotificationDestinationRequest true "Destination details" +// @Success 201 {object} handler.GenericDataResponse[handler.configuredSystemDestinationResponse] +// @Failure 400 {object} api.Error +// @Failure 401 {object} api.Error +// @Failure 409 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/notifications/{notificationName}/destinations [post] +func (h *NotificationsHandler) CreateSystemNotificationDestination(ctx echo.Context) error { + notificationName := ctx.Param("notificationName") + canonicalType, ok := notification.NormalizeNotificationType(notificationName) + if !ok { + err := fmt.Errorf("unsupported notification type %q", notificationName) + h.sugar.Warnw("Invalid system notification type", "error", err, "notificationName", notificationName) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + var req createSystemNotificationDestinationRequest + if err := ctx.Bind(&req); err != nil { + h.sugar.Errorw("Failed to bind create system notification destination 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)) + } + + provider, ok := notification.NormalizeDeliveryChannel(req.ProviderType) + if !ok { + err := fmt.Errorf("unsupported notification provider %q", req.ProviderType) + h.sugar.Warnw("Invalid system notification provider", "error", err, "providerType", req.ProviderType) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + target, err := h.buildTarget(provider, req.DestinationTarget) + if err != nil { + h.sugar.Warnw( + "Invalid system notification destination target", + "error", err, + "provider", provider, + "notificationType", canonicalType, + ) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + exists, err := h.destinationExists(ctx.Request().Context(), canonicalType, target) + if err != nil { + h.sugar.Errorw( + "Failed to check existing system notification destinations", + "error", err, + "provider", provider, + "notificationType", canonicalType, + ) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + if exists { + return ctx.JSON( + http.StatusConflict, + api.NewError(errors.New("destination already configured for this notification")), + ) + } + + row := relational.SystemNotificationDestination{ + NotificationType: canonicalType, + Provider: provider, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: target.Address, + }), + } + if err := h.db.WithContext(ctx.Request().Context()).Create(&row).Error; err != nil { + h.sugar.Errorw( + "Failed to create system notification destination", + "error", err, + "provider", provider, + "notificationType", canonicalType, + ) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + response, err := h.destinationResponseForTarget(target) + if err != nil { + h.sugar.Errorw( + "Failed to build created system notification destination response", + "error", err, + "provider", provider, + "notificationType", canonicalType, + ) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.JSON(http.StatusCreated, GenericDataResponse[configuredSystemDestinationResponse]{Data: response}) +} + +// DeleteSystemNotificationDestination godoc +// +// @Summary Delete system notification destination +// @Description Deletes a stored system notification destination configuration for an admin-managed notification +// @Tags Notifications +// @Accept json +// @Produce json +// @Param notificationName path string true "Notification name" +// @Param destination body handler.createSystemNotificationDestinationRequest true "Destination details" +// @Success 204 {object} nil +// @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/{notificationName}/destinations [delete] +func (h *NotificationsHandler) DeleteSystemNotificationDestination(ctx echo.Context) error { + notificationName := ctx.Param("notificationName") + canonicalType, ok := notification.NormalizeNotificationType(notificationName) + if !ok { + err := fmt.Errorf("unsupported notification type %q", notificationName) + h.sugar.Warnw("Invalid system notification type", "error", err, "notificationName", notificationName) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + var req createSystemNotificationDestinationRequest + if err := ctx.Bind(&req); err != nil { + h.sugar.Errorw("Failed to bind delete system notification destination 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)) + } + + provider, ok := notification.NormalizeDeliveryChannel(req.ProviderType) + if !ok { + err := fmt.Errorf("unsupported notification provider %q", req.ProviderType) + h.sugar.Warnw("Invalid system notification provider", "error", err, "providerType", req.ProviderType) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + target, err := h.buildTarget(provider, req.DestinationTarget) + if err != nil { + h.sugar.Warnw( + "Invalid system notification destination target", + "error", err, + "provider", provider, + "notificationType", canonicalType, + ) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + rows, err := h.findDestinationRows(ctx.Request().Context(), canonicalType, target) + if err != nil { + h.sugar.Errorw( + "Failed to find system notification destinations for delete", + "error", err, + "provider", provider, + "notificationType", canonicalType, + ) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + if len(rows) == 0 { + return ctx.JSON(http.StatusNotFound, api.NotFoundCustomMsg("configured notification destination not found")) + } + + if err := h.db.WithContext(ctx.Request().Context()).Transaction(func(tx *gorm.DB) error { + for i := range rows { + if err := tx.Delete(&rows[i]).Error; err != nil { + return err + } + } + return nil + }); err != nil { + h.sugar.Errorw( + "Failed to delete system notification destination", + "error", err, + "provider", provider, + "notificationType", canonicalType, + ) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.NoContent(http.StatusNoContent) +} + +func (h *NotificationsHandler) destinationResponseForRecord(record relational.SystemNotificationDestination) (configuredSystemDestinationResponse, error) { + target, err := h.targetForRecord(record) + if err != nil { + return configuredSystemDestinationResponse{}, err + } + + return h.destinationResponseForTarget(target) +} + +func (h *NotificationsHandler) destinationResponseForTarget(target notification.Target) (configuredSystemDestinationResponse, error) { + provider, ok := notification.NormalizeDeliveryChannel(target.Provider) + if !ok { + return configuredSystemDestinationResponse{}, fmt.Errorf("unsupported notification provider %q", target.Provider) + } + + configurator, ok := notification.LookupTargetConfigurator(h.providers, provider) + if !ok { + return configuredSystemDestinationResponse{}, fmt.Errorf("unsupported notification provider %q", provider) + } + + destinationTarget, err := configurator.DisplayTarget(target) + if err != nil { + return configuredSystemDestinationResponse{}, err + } + + return configuredSystemDestinationResponse{ + ProviderType: provider, + DestinationTarget: destinationTarget, + }, nil +} + +func (h *NotificationsHandler) targetForRecord(record relational.SystemNotificationDestination) (notification.Target, error) { + provider, ok := notification.NormalizeDeliveryChannel(record.Provider) + if !ok { + return notification.Target{}, fmt.Errorf("unsupported notification provider %q", record.Provider) + } + + configurator, ok := notification.LookupTargetConfigurator(h.providers, provider) + if !ok { + return notification.Target{}, fmt.Errorf("unsupported notification provider %q", provider) + } + + target, err := configurator.NormalizeTarget(notification.Target{ + Provider: provider, + Address: record.Target.Data().Address, + }) + if err != nil { + return notification.Target{}, err + } + + return target, nil +} + +func (h *NotificationsHandler) buildTarget(provider string, rawTarget string) (notification.Target, error) { + configurator, ok := notification.LookupTargetConfigurator(h.providers, provider) + if !ok { + return notification.Target{}, fmt.Errorf("unsupported notification provider %q", provider) + } + + return configurator.BuildTarget(rawTarget) +} + +func (h *NotificationsHandler) destinationExists(ctx context.Context, notificationType string, target notification.Target) (bool, error) { + rows, err := h.findDestinationRows(ctx, notificationType, target) + if err != nil { + return false, err + } + + return len(rows) > 0, nil +} + +func (h *NotificationsHandler) findDestinationRows(ctx context.Context, notificationType string, target notification.Target) ([]relational.SystemNotificationDestination, error) { + provider, ok := notification.NormalizeDeliveryChannel(target.Provider) + if !ok { + return nil, fmt.Errorf("unsupported notification provider %q", target.Provider) + } + + var rows []relational.SystemNotificationDestination + if err := h.db.WithContext(ctx). + Where("notification_type = ? AND provider = ?", notificationType, provider). + Find(&rows).Error; err != nil { + return nil, err + } + + matches := make([]relational.SystemNotificationDestination, 0, len(rows)) + for i := range rows { + existingTarget, err := h.targetForRecord(rows[i]) + if err != nil { + return nil, err + } + + match, err := h.targetsMatch(existingTarget, target) + if err != nil { + return nil, err + } + if match { + matches = append(matches, rows[i]) + } + } + + return matches, nil +} + +func (h *NotificationsHandler) targetsMatch(left notification.Target, right notification.Target) (bool, error) { + if reflect.DeepEqual(left.Address, right.Address) { + return true, nil + } + + leftResponse, err := h.destinationResponseForTarget(left) + if err != nil { + return false, err + } + rightResponse, err := h.destinationResponseForTarget(right) + if err != nil { + return false, err + } + + if leftResponse.ProviderType != rightResponse.ProviderType { + return false, nil + } + + return strings.EqualFold(leftResponse.DestinationTarget, rightResponse.DestinationTarget), nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + + return "" +} diff --git a/internal/api/handler/notifications_integration_test.go b/internal/api/handler/notifications_integration_test.go new file mode 100644 index 00000000..f57b1ebe --- /dev/null +++ b/internal/api/handler/notifications_integration_test.go @@ -0,0 +1,537 @@ +//go:build integration + +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/compliance-framework/api/internal/api" + "github.com/compliance-framework/api/internal/config" + "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/relational" + "github.com/compliance-framework/api/internal/tests" + "github.com/stretchr/testify/suite" + "go.uber.org/zap" + "gorm.io/datatypes" +) + +func TestNotificationsApi(t *testing.T) { + suite.Run(t, new(NotificationsApiIntegrationSuite)) +} + +type NotificationsApiIntegrationSuite struct { + tests.IntegrationTestSuite + server *api.Server + logger *zap.SugaredLogger +} + +func (suite *NotificationsApiIntegrationSuite) SetupSuite() { + suite.IntegrationTestSuite.SetupSuite() + + suite.Config.Email = &config.EmailConfig{ + Enabled: true, + Provider: "smtp", + Providers: &config.SupportedEmailProviders{ + SMTP: &config.SMTPConfig{ + Name: "smtp-primary", + Enabled: true, + Host: "smtp.example.com", + Port: 587, + From: "alerts@example.com", + }, + }, + } + + logger, _ := zap.NewDevelopment() + suite.logger = logger.Sugar() + + metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) + suite.server = api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) + RegisterHandlers(suite.server, suite.logger, suite.DB, suite.Config, &APIServices{}) +} + +func (suite *NotificationsApiIntegrationSuite) SetupTest() { + err := suite.Migrator.Refresh() + suite.Require().NoError(err) +} + +func (suite *NotificationsApiIntegrationSuite) authedRequest(method string, path string) (*httptest.ResponseRecorder, *http.Request) { + token, err := suite.GetAuthToken() + suite.Require().NoError(err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(method, path, nil) + req.Header.Set("Authorization", "Bearer "+*token) + return rec, req +} + +func (suite *NotificationsApiIntegrationSuite) authedJSONRequest(method string, path string, body any) (*httptest.ResponseRecorder, *http.Request) { + token, err := suite.GetAuthToken() + suite.Require().NoError(err) + + payload, err := json.Marshal(body) + suite.Require().NoError(err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(method, path, bytes.NewReader(payload)) + req.Header.Set("Authorization", "Bearer "+*token) + req.Header.Set("Content-Type", "application/json") + return rec, req +} + +func (suite *NotificationsApiIntegrationSuite) TestListNotificationProviders() { + rec, req := suite.authedRequest(http.MethodGet, "/api/admin/notifications/providers") + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusOK, rec.Code, "Expected OK response for ListNotificationProviders") + + var response GenericDataListResponse[availableNotificationProviderResponse] + err := json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal notification providers response") + suite.Require().Len(response.Data, 2) + + suite.Equal(availableNotificationProviderResponse{ + ProviderType: "email", + DisplayName: "Email", + Description: "Configured SMTP provider for email service", + Enabled: true, + Metadata: map[string]string{ + emailprovider.MetadataKeyServiceProviderName: "smtp-primary", + emailprovider.MetadataKeyServiceProviderType: "smtp", + }, + }, response.Data[0]) + suite.Equal(availableNotificationProviderResponse{ + ProviderType: "slack", + DisplayName: "Slack", + Description: "Configured Slack workspace", + Enabled: false, + }, response.Data[1]) +} + +func (suite *NotificationsApiIntegrationSuite) TestListNotificationProvidersUnauthorized() { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/admin/notifications/providers", nil) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusUnauthorized, rec.Code, "Expected Unauthorized response for missing token") +} + +func (suite *NotificationsApiIntegrationSuite) TestListNotificationProviderStatus() { + rec, req := suite.authedRequest(http.MethodGet, "/api/notifications/providers") + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusOK, rec.Code, "Expected OK response for ListNotificationProviderStatus") + + var response GenericDataListResponse[notificationProviderStatusResponse] + err := json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal notification provider status response") + suite.Require().Len(response.Data, 2) + + suite.Equal(notificationProviderStatusResponse{ProviderType: "email", Enabled: true}, response.Data[0]) + suite.Equal(notificationProviderStatusResponse{ProviderType: "slack", Enabled: false}, response.Data[1]) +} + +func (suite *NotificationsApiIntegrationSuite) TestListNotificationProviderStatusUnauthorized() { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/notifications/providers", nil) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusUnauthorized, rec.Code, "Expected Unauthorized response for missing token") +} + +func (suite *NotificationsApiIntegrationSuite) TestListSystemNotifications() { + suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + slackprovider.AddressKeyChannel: "ccf-slack-int", + slackprovider.AddressKeyTargetType: slackprovider.TargetTypeChannel, + }, + }), + }).Error) + + suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelEmail, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + emailprovider.AddressKeyEmail: "alerts@example.com", + }, + }), + }).Error) + + suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + slackprovider.AddressKeyChannel: "ccf-slack-int", + slackprovider.AddressKeyTargetType: slackprovider.TargetTypeChannel, + }, + }), + }).Error) + + rec, req := suite.authedRequest(http.MethodGet, "/api/admin/notifications") + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(200, rec.Code, "Expected OK response for ListSystemNotifications") + + var response GenericDataListResponse[systemNotificationResponse] + err := json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal notifications response") + suite.Require().Len(response.Data, 1) + + byName := make(map[string]systemNotificationResponse, len(response.Data)) + for _, item := range response.Data { + byName[item.Name] = item + } + + digestConfig, ok := byName["EVIDENCE_DIGEST"] + suite.True(ok, "Expected EVIDENCE_DIGEST entry") + suite.Len(digestConfig.ConfiguredDestinations, 2) + suite.Equal([]configuredSystemDestinationResponse{ + {ProviderType: "email", DestinationTarget: "alerts@example.com"}, + {ProviderType: "slack", DestinationTarget: "ccf-slack-int"}, + }, digestConfig.ConfiguredDestinations) +} + +func (suite *NotificationsApiIntegrationSuite) TestListSystemNotificationsDeduplicatesEquivalentDestinations() { + suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + slackprovider.AddressKeyChannel: "ccf-slack-int", + slackprovider.AddressKeyTargetType: slackprovider.TargetTypeChannel, + }, + }), + }).Error) + suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + slackprovider.AddressKeyChannel: " CCF-SLACK-INT ", + slackprovider.AddressKeyTargetType: slackprovider.TargetTypeChannel, + }, + }), + }).Error) + + rec, req := suite.authedRequest(http.MethodGet, "/api/admin/notifications") + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusOK, rec.Code, "Expected OK response for ListSystemNotifications") + + var response GenericDataListResponse[systemNotificationResponse] + err := json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal notifications response") + suite.Require().Len(response.Data, 1) + suite.Equal("EVIDENCE_DIGEST", response.Data[0].Name) + suite.Equal([]configuredSystemDestinationResponse{ + {ProviderType: "slack", DestinationTarget: "ccf-slack-int"}, + }, response.Data[0].ConfiguredDestinations) +} + +func (suite *NotificationsApiIntegrationSuite) TestListSystemNotificationsIncludesConfiguredSupportedTypesOutsideDefaultBaseline() { + suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeTaskAvailable, + Provider: notification.DeliveryChannelEmail, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + emailprovider.AddressKeyEmail: "alerts@example.com", + }, + }), + }).Error) + + rec, req := suite.authedRequest(http.MethodGet, "/api/admin/notifications") + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(200, rec.Code, "Expected OK response for ListSystemNotifications") + + var response GenericDataListResponse[systemNotificationResponse] + err := json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal notifications response") + suite.Require().Len(response.Data, 1) + + suite.Equal("TASK_AVAILABLE", response.Data[0].Name) + suite.Equal([]configuredSystemDestinationResponse{ + {ProviderType: "email", DestinationTarget: "alerts@example.com"}, + }, response.Data[0].ConfiguredDestinations) +} + +func (suite *NotificationsApiIntegrationSuite) TestListSystemNotificationsReturnsEmptyDataWhenNoConfigurationsExist() { + rec, req := suite.authedRequest(http.MethodGet, "/api/admin/notifications") + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(200, rec.Code, "Expected OK response for ListSystemNotifications") + + var response GenericDataListResponse[systemNotificationResponse] + err := json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal notifications response") + suite.Empty(response.Data) +} + +func (suite *NotificationsApiIntegrationSuite) TestCreateSystemNotificationDestination() { + rec, req := suite.authedJSONRequest(http.MethodPost, "/api/admin/notifications/EVIDENCE_DIGEST/destinations", map[string]string{ + "providerType": "email", + "destinationTarget": "alerts@example.com", + }) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusCreated, rec.Code, "Expected Created response for CreateSystemNotificationDestination") + + var response GenericDataResponse[configuredSystemDestinationResponse] + err := json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal create notification response") + suite.Equal(configuredSystemDestinationResponse{ + ProviderType: "email", + DestinationTarget: "alerts@example.com", + }, response.Data) + + var rows []relational.SystemNotificationDestination + suite.Require().NoError(suite.DB.Find(&rows).Error) + suite.Require().Len(rows, 1) + suite.Equal(notification.NotificationTypeEvidenceDigest, rows[0].NotificationType) + suite.Equal(notification.DeliveryChannelEmail, rows[0].Provider) + suite.Equal("alerts@example.com", rows[0].Target.Data().Address[emailprovider.AddressKeyEmail]) +} + +func (suite *NotificationsApiIntegrationSuite) TestCreateSystemNotificationDestinationAcceptsKebabCasePayload() { + rec, req := suite.authedJSONRequest(http.MethodPost, "/api/admin/notifications/EVIDENCE_DIGEST/destinations", map[string]string{ + "provider-type": "email", + "destination-target": "alerts@example.com", + }) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusCreated, rec.Code, "Expected Created response for kebab-case create payload") + + var response GenericDataResponse[configuredSystemDestinationResponse] + err := json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal create notification response") + suite.Equal(configuredSystemDestinationResponse{ + ProviderType: "email", + DestinationTarget: "alerts@example.com", + }, response.Data) +} + +func (suite *NotificationsApiIntegrationSuite) TestCreateSystemNotificationDestinationSlackDefaultsTargetTypeToChannel() { + rec, req := suite.authedJSONRequest(http.MethodPost, "/api/admin/notifications/TASK_AVAILABLE/destinations", map[string]string{ + "providerType": "slack", + "destinationTarget": "ccf-slack-int", + }) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusCreated, rec.Code, "Expected Created response for CreateSystemNotificationDestination") + + var rows []relational.SystemNotificationDestination + suite.Require().NoError(suite.DB.Find(&rows).Error) + suite.Require().Len(rows, 1) + suite.Equal(notification.NotificationTypeTaskAvailable, rows[0].NotificationType) + suite.Equal(notification.DeliveryChannelSlack, rows[0].Provider) + suite.Equal("ccf-slack-int", rows[0].Target.Data().Address[slackprovider.AddressKeyChannel]) + suite.Equal(slackprovider.TargetTypeChannel, rows[0].Target.Data().Address[slackprovider.AddressKeyTargetType]) +} + +func (suite *NotificationsApiIntegrationSuite) TestCreateSystemNotificationDestinationRejectsDuplicateDestination() { + suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelEmail, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + emailprovider.AddressKeyEmail: "alerts@example.com", + }, + }), + }).Error) + + rec, req := suite.authedJSONRequest(http.MethodPost, "/api/admin/notifications/EVIDENCE_DIGEST/destinations", map[string]string{ + "providerType": "email", + "destinationTarget": "Alerts ", + }) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusConflict, rec.Code, "Expected Conflict response for duplicate notification destination") + + var rows []relational.SystemNotificationDestination + suite.Require().NoError(suite.DB.Find(&rows).Error) + suite.Require().Len(rows, 1) +} + +func (suite *NotificationsApiIntegrationSuite) TestCreateSystemNotificationDestinationRejectsInvalidInput() { + rec, req := suite.authedJSONRequest(http.MethodPost, "/api/admin/notifications/not_real/destinations", map[string]string{ + "providerType": "email", + "destinationTarget": "alerts@example.com", + }) + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusBadRequest, rec.Code, "Expected BadRequest response for unsupported notification type") + + rec, req = suite.authedJSONRequest(http.MethodPost, "/api/admin/notifications/EVIDENCE_DIGEST/destinations", map[string]string{ + "providerType": "pagerduty", + "destinationTarget": "alerts@example.com", + }) + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusBadRequest, rec.Code, "Expected BadRequest response for unsupported provider") + + rec, req = suite.authedJSONRequest(http.MethodPost, "/api/admin/notifications/EVIDENCE_DIGEST/destinations", map[string]string{ + "providerType": "email", + "destinationTarget": "not-an-email", + }) + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusBadRequest, rec.Code, "Expected BadRequest response for invalid destination target") +} + +func (suite *NotificationsApiIntegrationSuite) TestCreateSystemNotificationDestinationUnauthorized() { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/admin/notifications/EVIDENCE_DIGEST/destinations", bytes.NewReader([]byte(`{"providerType":"email","destinationTarget":"alerts@example.com"}`))) + req.Header.Set("Content-Type", "application/json") + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusUnauthorized, rec.Code, "Expected Unauthorized response for missing token") +} + +func (suite *NotificationsApiIntegrationSuite) TestDeleteSystemNotificationDestination() { + suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelEmail, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + emailprovider.AddressKeyEmail: "alerts@example.com", + }, + }), + }).Error) + + rec, req := suite.authedJSONRequest(http.MethodDelete, "/api/admin/notifications/EVIDENCE_DIGEST/destinations", map[string]string{ + "providerType": "email", + "destinationTarget": "alerts@example.com", + }) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusNoContent, rec.Code, "Expected NoContent response for DeleteSystemNotificationDestination") + + var count int64 + suite.Require().NoError(suite.DB.Model(&relational.SystemNotificationDestination{}).Count(&count).Error) + suite.Equal(int64(0), count) +} + +func (suite *NotificationsApiIntegrationSuite) TestDeleteSystemNotificationDestinationAcceptsKebabCasePayload() { + suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelEmail, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + emailprovider.AddressKeyEmail: "alerts@example.com", + }, + }), + }).Error) + + rec, req := suite.authedJSONRequest(http.MethodDelete, "/api/admin/notifications/EVIDENCE_DIGEST/destinations", map[string]string{ + "provider-type": "email", + "destination-target": "alerts@example.com", + }) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusNoContent, rec.Code, "Expected NoContent response for kebab-case delete payload") +} + +func (suite *NotificationsApiIntegrationSuite) TestDeleteSystemNotificationDestinationRemovesDuplicateRows() { + suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + slackprovider.AddressKeyChannel: "ccf-slack-int", + slackprovider.AddressKeyTargetType: slackprovider.TargetTypeChannel, + }, + }), + }).Error) + suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + slackprovider.AddressKeyChannel: "CCF-SLACK-INT", + slackprovider.AddressKeyTargetType: slackprovider.TargetTypeChannel, + }, + }), + }).Error) + suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + slackprovider.AddressKeyChannel: "ccf-slack-secondary", + slackprovider.AddressKeyTargetType: slackprovider.TargetTypeChannel, + }, + }), + }).Error) + + rec, req := suite.authedJSONRequest(http.MethodDelete, "/api/admin/notifications/EVIDENCE_DIGEST/destinations", map[string]string{ + "providerType": "slack", + "destinationTarget": "ccf-slack-int", + }) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusNoContent, rec.Code, "Expected NoContent response for duplicate destination delete") + + var rows []relational.SystemNotificationDestination + suite.Require().NoError(suite.DB.Order("created_at ASC").Find(&rows).Error) + suite.Require().Len(rows, 1) + suite.Equal("ccf-slack-secondary", rows[0].Target.Data().Address[slackprovider.AddressKeyChannel]) +} + +func (suite *NotificationsApiIntegrationSuite) TestDeleteSystemNotificationDestinationRejectsInvalidInput() { + rec, req := suite.authedJSONRequest(http.MethodDelete, "/api/admin/notifications/not_real/destinations", map[string]string{ + "providerType": "email", + "destinationTarget": "alerts@example.com", + }) + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusBadRequest, rec.Code, "Expected BadRequest response for unsupported notification type") + + rec, req = suite.authedJSONRequest(http.MethodDelete, "/api/admin/notifications/EVIDENCE_DIGEST/destinations", map[string]string{ + "providerType": "pagerduty", + "destinationTarget": "alerts@example.com", + }) + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusBadRequest, rec.Code, "Expected BadRequest response for unsupported provider") + + rec, req = suite.authedJSONRequest(http.MethodDelete, "/api/admin/notifications/EVIDENCE_DIGEST/destinations", map[string]string{ + "providerType": "email", + "destinationTarget": "not-an-email", + }) + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusBadRequest, rec.Code, "Expected BadRequest response for invalid destination target") +} + +func (suite *NotificationsApiIntegrationSuite) TestDeleteSystemNotificationDestinationReturnsNotFoundWhenMissing() { + rec, req := suite.authedJSONRequest(http.MethodDelete, "/api/admin/notifications/EVIDENCE_DIGEST/destinations", map[string]string{ + "providerType": "email", + "destinationTarget": "alerts@example.com", + }) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusNotFound, rec.Code, "Expected NotFound response for missing notification destination") +} + +func (suite *NotificationsApiIntegrationSuite) TestDeleteSystemNotificationDestinationUnauthorized() { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/admin/notifications/EVIDENCE_DIGEST/destinations", bytes.NewReader([]byte(`{"providerType":"email","destinationTarget":"alerts@example.com"}`))) + req.Header.Set("Content-Type", "application/json") + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusUnauthorized, rec.Code, "Expected Unauthorized response for missing token") +} + +func (suite *NotificationsApiIntegrationSuite) TestListSystemNotificationsUnauthorized() { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/admin/notifications", nil) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(401, rec.Code, "Expected Unauthorized response for missing token") +} diff --git a/internal/config/slack.go b/internal/config/slack.go index ede215b5..b116d95a 100644 --- a/internal/config/slack.go +++ b/internal/config/slack.go @@ -9,8 +9,9 @@ import ( ) type SlackConfig struct { - Enabled bool `mapstructure:"enabled" yaml:"enabled" json:"enabled"` - Token string `mapstructure:"token" yaml:"token" json:"token"` + Enabled bool `mapstructure:"enabled" yaml:"enabled" json:"enabled"` + Token string `mapstructure:"token" yaml:"token" json:"token"` + // DigestChannel is kept for one-time migration into ccf_system_notification_destinations. DigestChannel string `mapstructure:"digest_channel" yaml:"digest_channel" json:"digest_channel"` ClientID string `mapstructure:"client_id" yaml:"client_id" json:"client_id"` ClientSecret string `mapstructure:"client_secret" yaml:"client_secret" json:"client_secret"` diff --git a/internal/service/digest/notifications.go b/internal/service/digest/notifications.go index a834606c..409f7c7e 100644 --- a/internal/service/digest/notifications.go +++ b/internal/service/digest/notifications.go @@ -10,6 +10,7 @@ import ( "github.com/compliance-framework/api/internal/service/email" emailtypes "github.com/compliance-framework/api/internal/service/email/types" "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" "gorm.io/gorm" @@ -134,7 +135,7 @@ func NewNotificationService( return notification.NewService(nil, nil, nil) } - return notificationRuntime.NewRuntimeFactory(newDigestConfiguredDestinationResolver(cfg)).MustNewService( + return notificationRuntime.NewRuntimeFactory(notification.NewGORMConfiguredDestinationResolver(db)).MustNewService( notification.NewGORMUserRepository(db), notification.NewDefinition( evidenceDigestKind, @@ -149,36 +150,6 @@ func NewNotificationService( ) } -type digestConfiguredDestinationResolver struct { - config *config.Config -} - -func newDigestConfiguredDestinationResolver(cfg *config.Config) digestConfiguredDestinationResolver { - return digestConfiguredDestinationResolver{config: cfg} -} - -func (r digestConfiguredDestinationResolver) ResolveConfiguredDestination(_ context.Context, key string) (notification.ConfiguredDestination, error) { - if strings.TrimSpace(key) != slackprovider.ConfiguredDestinationDigestChan { - return notification.ConfiguredDestination{}, fmt.Errorf("%w: %q", notification.ErrConfiguredDestinationNotFound, key) - } - if r.config == nil || r.config.Slack == nil || !r.config.Slack.Enabled { - return notification.ConfiguredDestination{}, fmt.Errorf("%w: %q", notification.ErrConfiguredDestinationNotFound, key) - } - - channel := strings.TrimSpace(r.config.Slack.DigestChannel) - if channel == "" { - return notification.ConfiguredDestination{}, fmt.Errorf("%w: %q", notification.ErrConfiguredDestinationNotFound, key) - } - - return notification.ConfiguredDestination{ - Provider: notification.DeliveryChannelSlack, - Address: map[string]string{ - slackprovider.AddressKeyChannel: channel, - slackprovider.AddressKeyTargetType: slackprovider.TargetTypeChannel, - }, - }, nil -} - func evidenceDigestModelFromAny(model any) (evidenceDigestNotificationModel, error) { switch typed := model.(type) { case evidenceDigestNotificationModel: @@ -243,23 +214,27 @@ func (s *Service) dispatchEvidenceDigestNotifications( summary *EvidenceSummary, webBaseURL string, generatedAt time.Time, - includeConfiguredSlack bool, + includeConfiguredDestinations bool, includeSubscribedUsers bool, ) error { request := notification.FanoutRequest{} dispatchOptions := evidenceDigestDispatchOptions(generatedAt) - if includeConfiguredSlack { - request.Requests = append(request.Requests, notification.Request{ - Kind: evidenceDigestKind, - Audiences: []notification.Audience{ - {ConfiguredDestination: ¬ification.ConfiguredDestinationAudience{ - Key: slackprovider.ConfiguredDestinationDigestChan, - }}, - }, - Model: newEvidenceDigestNotificationModel(summary, "", webBaseURL, generatedAt), - Options: dispatchOptions, - }) + if includeConfiguredDestinations { + targets, err := s.configuredDigestTargets(ctx) + if err != nil { + return err + } + + audiences := audiencesForTargets(targets) + if len(audiences) > 0 { + request.Requests = append(request.Requests, notification.Request{ + Kind: evidenceDigestKind, + Audiences: audiences, + Model: newEvidenceDigestNotificationModel(summary, "", webBaseURL, generatedAt), + Options: dispatchOptions, + }) + } } if includeSubscribedUsers { @@ -288,13 +263,48 @@ func evidenceDigestDispatchOptions(generatedAt time.Time) notification.DispatchO } } -func (s *Service) globalDigestSlackEnabled() bool { - if s.config == nil || s.config.Slack == nil || !s.config.Slack.Enabled { +func (s *Service) configuredDigestTargets(ctx context.Context) ([]notification.Target, error) { + if s.db == nil { + return []notification.Target{}, nil + } + + return notification.NewGORMSystemDestinationRepository(s.db, notificationproviders.NewLookup()). + ListTargetsByNotificationType(ctx, notification.NotificationTypeEvidenceDigest) +} + +func audiencesForTargets(targets []notification.Target) []notification.Audience { + if len(targets) == 0 { + return nil + } + + audiences := make([]notification.Audience, 0, len(targets)) + for i := range targets { + address := make(map[string]string, len(targets[i].Address)) + for key, value := range targets[i].Address { + address[key] = value + } + + audiences = append(audiences, notification.Audience{ + Direct: ¬ification.DirectAudience{ + Provider: targets[i].Provider, + Address: address, + }, + }) + } + + return audiences +} + +func (s *Service) hasGlobalDigestDestinations(ctx context.Context) bool { + targets, err := s.configuredDigestTargets(ctx) + if err != nil { + s.logger.Warnw("Failed to resolve configured digest destinations", "error", err) return false } - if strings.TrimSpace(s.config.Slack.DigestChannel) == "" { - s.logger.Debug("Slack digest channel is empty; skipping optional digest Slack message") + if len(targets) == 0 { + s.logger.Debug("Configured digest destinations are missing; skipping optional digest system notifications") return false } + return true } diff --git a/internal/service/digest/service.go b/internal/service/digest/service.go index 1dd4cd1a..066e5677 100644 --- a/internal/service/digest/service.go +++ b/internal/service/digest/service.go @@ -246,16 +246,16 @@ func (s *Service) SendGlobalDigest(ctx context.Context) error { return fmt.Errorf("failed to get evidence summary: %w", err) } - sendGlobalSlack := s.globalDigestSlackEnabled() + sendConfiguredDestinations := s.hasGlobalDigestDestinations(ctx) sendUserDigests := summary.TotalCount > 0 && (summary.NotSatisfiedCount > 0 || summary.ExpiredCount > 0) if !sendUserDigests { if summary.TotalCount == 0 { - if !sendGlobalSlack { + if !sendConfiguredDestinations { s.logger.Debug("No evidence found, skipping digest") return nil } - } else if !sendGlobalSlack { + } else if !sendConfiguredDestinations { s.logger.Debug("No issues found (no expired or not-satisfied evidence), skipping digest") return nil } @@ -268,7 +268,7 @@ func (s *Service) SendGlobalDigest(ctx context.Context) error { } if !sendUserDigests { - return s.dispatchEvidenceDigestNotifications(ctx, summary, webBaseURL, generatedAt, sendGlobalSlack, false) + return s.dispatchEvidenceDigestNotifications(ctx, summary, webBaseURL, generatedAt, sendConfiguredDestinations, false) } s.logger.Debugw("Sending global digest", @@ -277,5 +277,5 @@ func (s *Service) SendGlobalDigest(ctx context.Context) error { "expired", summary.ExpiredCount, ) - return s.dispatchEvidenceDigestNotifications(ctx, summary, webBaseURL, generatedAt, sendGlobalSlack, true) + return s.dispatchEvidenceDigestNotifications(ctx, summary, webBaseURL, generatedAt, sendConfiguredDestinations, true) } diff --git a/internal/service/digest/service_test.go b/internal/service/digest/service_test.go index 6d002726..918dec11 100644 --- a/internal/service/digest/service_test.go +++ b/internal/service/digest/service_test.go @@ -1,17 +1,44 @@ package digest import ( + "context" "strings" "testing" "time" "github.com/compliance-framework/api/internal/config" "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/relational" "github.com/google/uuid" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/zap" + "gorm.io/datatypes" + "gorm.io/driver/sqlite" + "gorm.io/gorm" ) +type digestStubTransport struct { + deliveries []notification.Delivery +} + +func (t *digestStubTransport) Enqueue(_ context.Context, deliveries []notification.Delivery) error { + t.deliveries = append(t.deliveries, deliveries...) + return nil +} + +func (t *digestStubTransport) byProvider(provider string) []notification.Delivery { + out := make([]notification.Delivery, 0) + for i := range t.deliveries { + if t.deliveries[i].Provider == provider { + out = append(out, t.deliveries[i]) + } + } + return out +} + func TestConvertToEvidenceItems(t *testing.T) { // Test the conversion logic without database dependencies now := time.Now() @@ -80,13 +107,81 @@ func TestEvidenceDigestDispatchOptions_UsesCorrelationAndSourceJobKind(t *testin assert.True(t, strings.HasPrefix(options.CorrelationID, "evidence-digest:")) } -func TestGlobalDigestSlackEnabled_RequiresConfiguredChannel(t *testing.T) { +func TestHasGlobalDigestDestinations_RequiresConfiguredDestination(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&relational.SystemNotificationDestination{})) + service := NewService( + db, nil, - nil, - &config.Config{Slack: &config.SlackConfig{Enabled: true, DigestChannel: ""}}, + &config.Config{Slack: &config.SlackConfig{Enabled: true}}, zap.NewNop().Sugar(), ) - assert.False(t, service.globalDigestSlackEnabled()) + assert.False(t, service.hasGlobalDigestDestinations(context.Background())) + + require.NoError(t, db.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelEmail, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + emailprovider.AddressKeyEmail: "alerts@example.com", + }, + }), + }).Error) + + assert.True(t, service.hasGlobalDigestDestinations(context.Background())) +} + +func TestDispatchEvidenceDigestNotificationsSupportsMultipleConfiguredDestinations(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&relational.SystemNotificationDestination{})) + + require.NoError(t, db.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + slackprovider.AddressKeyChannel: "ccf-alerts", + slackprovider.AddressKeyTargetType: slackprovider.TargetTypeChannel, + }, + }), + }).Error) + require.NoError(t, db.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelEmail, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + emailprovider.AddressKeyEmail: "alerts@example.com", + }, + }), + }).Error) + + registry := notification.MustNewRegistry(notification.NewDefinition( + evidenceDigestKind, + notification.NotificationTypeEvidenceDigest, + notification.BindRenderer(notification.DeliveryChannelEmail, notification.ProviderRenderer(notification.DeliveryChannelEmail, func(context.Context, any) (any, error) { + return emailprovider.Content{From: "from@example.com", Subject: "Digest", TextBody: "body"}, nil + })), + notification.BindRenderer(notification.DeliveryChannelSlack, notification.ProviderRenderer(notification.DeliveryChannelSlack, func(context.Context, any) (any, error) { + return slackprovider.Content{Text: "body"}, nil + })), + )) + transport := &digestStubTransport{} + notifier := notification.NewService(transport, registry, notification.NewResolver(nil, nil, nil)) + + service := NewService(db, notifier, &config.Config{}, zap.NewNop().Sugar()) + err = service.dispatchEvidenceDigestNotifications(context.Background(), &EvidenceSummary{TotalCount: 1}, "", time.Now().UTC(), true, false) + require.NoError(t, err) + + emails := transport.byProvider(notification.DeliveryChannelEmail) + slacks := transport.byProvider(notification.DeliveryChannelSlack) + + require.Len(t, emails, 1) + assert.Equal(t, "alerts@example.com", emails[0].Target.Address[emailprovider.AddressKeyEmail]) + + require.Len(t, slacks, 1) + assert.Equal(t, "ccf-alerts", slacks[0].Target.Address[slackprovider.AddressKeyChannel]) } diff --git a/internal/service/email/templates/service_test.go b/internal/service/email/templates/service_test.go index d1af78c5..ced383ce 100644 --- a/internal/service/email/templates/service_test.go +++ b/internal/service/email/templates/service_test.go @@ -45,6 +45,58 @@ func TestTemplateService_UseHTMLAndUseText(t *testing.T) { require.Contains(t, textContent, data["ResetURL"].(string)) } +func TestTemplateService_EvidenceDigest_WithUserName(t *testing.T) { + service, err := NewTemplateService() + require.NoError(t, err) + + data := TemplateData{ + "UserName": "Alice", + "TotalCount": int64(3), + "SatisfiedCount": int64(2), + "NotSatisfiedCount": int64(1), + "ExpiredCount": int64(0), + "TopNotSatisfied": []map[string]interface{}{}, + "TopExpired": []map[string]interface{}{}, + "WebBaseURL": "https://app.example.com", + "GeneratedAt": "Mon, 27 Apr 2026 10:00:00 UTC", + } + + html, text, err := service.Use("evidence-digest", data) + require.NoError(t, err) + require.Contains(t, html, "Hello Alice,") + require.Contains(t, html, "Here's your evidence compliance summary.") + require.Contains(t, text, "Hello Alice,") + require.Contains(t, text, "Here's your evidence compliance summary.") +} + +func TestTemplateService_EvidenceDigest_WithoutUserName(t *testing.T) { + service, err := NewTemplateService() + require.NoError(t, err) + + data := TemplateData{ + "UserName": "", + "TotalCount": int64(3), + "SatisfiedCount": int64(2), + "NotSatisfiedCount": int64(1), + "ExpiredCount": int64(0), + "TopNotSatisfied": []map[string]interface{}{}, + "TopExpired": []map[string]interface{}{}, + "WebBaseURL": "https://app.example.com", + "GeneratedAt": "Mon, 27 Apr 2026 10:00:00 UTC", + } + + html, text, err := service.Use("evidence-digest", data) + require.NoError(t, err) + require.Contains(t, html, "Evidence compliance summary") + require.Contains(t, html, "Here's the latest evidence compliance summary.") + require.NotContains(t, html, "Hello ,") + require.NotContains(t, html, "Here's your evidence compliance summary.") + require.Contains(t, text, "Evidence compliance summary") + require.Contains(t, text, "Here's the latest evidence compliance summary.") + require.NotContains(t, text, "Hello ,") + require.NotContains(t, text, "Here's your evidence compliance summary.") +} + func TestTemplateService_MissingTemplates(t *testing.T) { service, err := NewTemplateService() require.NoError(t, err, "Failed to create template service") diff --git a/internal/service/email/templates/templates/evidence-digest.html b/internal/service/email/templates/templates/evidence-digest.html index d887a274..fe9efc14 100644 --- a/internal/service/email/templates/templates/evidence-digest.html +++ b/internal/service/email/templates/templates/evidence-digest.html @@ -268,11 +268,19 @@

📊 Evidence Digest

+ {{if .UserName}}

Hello {{.UserName}},

- +

Here's your evidence compliance summary. This digest highlights evidence that requires your attention.

+ {{else}} +

Evidence compliance summary

+ +

+ Here's the latest evidence compliance summary. This digest highlights evidence that requires attention. +

+ {{end}} diff --git a/internal/service/email/templates/templates/evidence-digest.txt b/internal/service/email/templates/templates/evidence-digest.txt index e9d4a07d..3e01ee44 100644 --- a/internal/service/email/templates/templates/evidence-digest.txt +++ b/internal/service/email/templates/templates/evidence-digest.txt @@ -1,9 +1,13 @@ Evidence Compliance Digest ========================== -Hello {{.UserName}}, +{{if .UserName}}Hello {{.UserName}}, Here's your evidence compliance summary. This digest highlights evidence that requires your attention. +{{else}}Evidence compliance summary + +Here's the latest evidence compliance summary. This digest highlights evidence that requires attention. +{{end}} SUMMARY ------- diff --git a/internal/service/migrator.go b/internal/service/migrator.go index 5f61c068..87dc16a5 100644 --- a/internal/service/migrator.go +++ b/internal/service/migrator.go @@ -2,13 +2,20 @@ package service import ( "context" + "errors" + "fmt" + "os" + "strings" + "github.com/compliance-framework/api/internal/config" "github.com/compliance-framework/api/internal/service/notification" + slackprovider "github.com/compliance-framework/api/internal/service/notification/providers/slack" "github.com/compliance-framework/api/internal/service/relational" poamrel "github.com/compliance-framework/api/internal/service/relational/poam" riskrel "github.com/compliance-framework/api/internal/service/relational/risks" templaterel "github.com/compliance-framework/api/internal/service/relational/templates" "github.com/compliance-framework/api/internal/service/relational/workflows" + "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -20,7 +27,12 @@ type legacySubscribedUser struct { } func MigrateUp(db *gorm.DB) error { + return MigrateUpWithConfig(db, nil) +} + +func MigrateUpWithConfig(db *gorm.DB, cfg *config.Config) error { workflowEntities := workflows.GetWorkflowEntities() + systemNotificationDestinationTableExists := db.Migrator().HasTable(&relational.SystemNotificationDestination{}) err := db.AutoMigrate( &relational.ResponsiblePartyParties{}, @@ -160,6 +172,7 @@ func MigrateUp(db *gorm.DB) error { &relational.AgentServiceAccountKey{}, &relational.AgentAuthEvent{}, &relational.UserNotificationSubscription{}, + &relational.SystemNotificationDestination{}, &Heartbeat{}, &relational.Evidence{}, &relational.Labels{}, @@ -181,6 +194,9 @@ func MigrateUp(db *gorm.DB) error { return err } + if err := migrateLegacySystemNotificationDestinations(db, cfg, !systemNotificationDestinationTableExists); err != nil { + return err + } if err := migrateLegacyTaskAvailableEmailSubscriptions(db); err != nil { return err } @@ -229,6 +245,70 @@ func MigrateUp(db *gorm.DB) error { return err } +func migrateLegacySystemNotificationDestinations(db *gorm.DB, cfg *config.Config, tableJustCreated bool) error { + if !tableJustCreated { + db.Logger.Info( + context.Background(), + "Skipping legacy system notification destination migration: ccf_system_notification_destinations already exists", + ) + return nil + } + + channel := legacySlackDigestChannel(cfg) + if channel == "" { + db.Logger.Info( + context.Background(), + "Skipping legacy system notification destination migration: CCF_SLACK_DIGEST_CHANNEL is empty", + ) + return nil + } + + var existing relational.SystemNotificationDestination + if err := db. + Where("notification_type = ? AND provider = ?", notification.NotificationTypeEvidenceDigest, notification.DeliveryChannelSlack). + First(&existing).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("failed to query existing system notification destination %q: %w", slackprovider.ConfiguredDestinationDigestChan, err) + } + + row := relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + slackprovider.AddressKeyChannel: channel, + slackprovider.AddressKeyTargetType: slackprovider.TargetTypeChannel, + }, + }), + } + if err := db.Create(&row).Error; err != nil { + return fmt.Errorf("failed to migrate legacy system notification destination %q: %w", slackprovider.ConfiguredDestinationDigestChan, err) + } + + db.Logger.Info( + context.Background(), + "Migrated legacy Slack digest channel into ccf_system_notification_destinations", + ) + return nil + } + + db.Logger.Info( + context.Background(), + "Skipping legacy system notification destination migration: configured destination already exists", + ) + return nil +} + +func legacySlackDigestChannel(cfg *config.Config) string { + if cfg != nil && cfg.Slack != nil { + if channel := strings.TrimSpace(cfg.Slack.DigestChannel); channel != "" { + return channel + } + } + + return strings.TrimSpace(os.Getenv("CCF_SLACK_DIGEST_CHANNEL")) +} + func migrateLegacyTaskAvailableEmailSubscriptions(db *gorm.DB) error { // Nothing to migrate after the legacy column has been removed. if !db.Migrator().HasColumn(&relational.User{}, "task_available_email_subscribed") { @@ -538,6 +618,7 @@ func MigrateDown(db *gorm.DB) error { &relational.SlackUserLink{}, &relational.User{}, &relational.UserNotificationSubscription{}, + &relational.SystemNotificationDestination{}, &Heartbeat{}, &relational.Evidence{}, diff --git a/internal/service/migrator_test.go b/internal/service/migrator_test.go index ace3cfb3..285ee4ee 100644 --- a/internal/service/migrator_test.go +++ b/internal/service/migrator_test.go @@ -3,10 +3,13 @@ package service import ( "testing" + "github.com/compliance-framework/api/internal/config" "github.com/compliance-framework/api/internal/service/notification" + slackprovider "github.com/compliance-framework/api/internal/service/notification/providers/slack" "github.com/compliance-framework/api/internal/service/relational" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/datatypes" "gorm.io/driver/sqlite" "gorm.io/gorm" ) @@ -138,3 +141,75 @@ func TestBackfillLegacyRiskNotificationSubscriptions(t *testing.T) { require.NoError(t, db.Model(&relational.UserNotificationSubscription{}).Count(&count).Error) assert.Equal(t, int64(1), count) } + +func TestMigrateLegacySystemNotificationDestinationsBackfillsSlackDigestChannel(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&relational.SystemNotificationDestination{})) + + cfg := &config.Config{ + Slack: &config.SlackConfig{ + DigestChannel: "ccf-alerts", + }, + } + + require.NoError(t, migrateLegacySystemNotificationDestinations(db, cfg, true)) + + var rows []relational.SystemNotificationDestination + require.NoError(t, db.Find(&rows).Error) + require.Len(t, rows, 1) + assert.Equal(t, notification.NotificationTypeEvidenceDigest, rows[0].NotificationType) + assert.Equal(t, notification.DeliveryChannelSlack, rows[0].Provider) + target := rows[0].Target.Data() + assert.Equal(t, "ccf-alerts", target.Address[slackprovider.AddressKeyChannel]) + assert.Equal(t, slackprovider.TargetTypeChannel, target.Address[slackprovider.AddressKeyTargetType]) +} + +func TestMigrateLegacySystemNotificationDestinationsDoesNotOverwriteExistingRow(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&relational.SystemNotificationDestination{})) + + existing := relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + slackprovider.AddressKeyChannel: "existing-channel", + slackprovider.AddressKeyTargetType: slackprovider.TargetTypeChannel, + }, + }), + } + require.NoError(t, db.Create(&existing).Error) + + cfg := &config.Config{ + Slack: &config.SlackConfig{ + DigestChannel: "env-channel", + }, + } + + require.NoError(t, migrateLegacySystemNotificationDestinations(db, cfg, true)) + + var rows []relational.SystemNotificationDestination + require.NoError(t, db.Find(&rows).Error) + require.Len(t, rows, 1) + assert.Equal(t, "existing-channel", rows[0].Target.Data().Address[slackprovider.AddressKeyChannel]) +} + +func TestMigrateLegacySystemNotificationDestinationsSkipsWhenTableAlreadyExists(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&relational.SystemNotificationDestination{})) + + cfg := &config.Config{ + Slack: &config.SlackConfig{ + DigestChannel: "ccf-alerts", + }, + } + + require.NoError(t, migrateLegacySystemNotificationDestinations(db, cfg, false)) + + var count int64 + require.NoError(t, db.Model(&relational.SystemNotificationDestination{}).Count(&count).Error) + assert.Zero(t, count) +} diff --git a/internal/service/notification/constants.go b/internal/service/notification/constants.go index 4ee4e2fd..b3d64999 100644 --- a/internal/service/notification/constants.go +++ b/internal/service/notification/constants.go @@ -41,6 +41,10 @@ var notificationTypeWireValues = map[string]string{ NotificationTypeRiskNotifications: NotificationTypeRiskNotificationsWire, } +var systemNotificationTypes = []string{ + NotificationTypeEvidenceDigest, +} + func normalizeToken(value string) string { return strings.ToLower(strings.TrimSpace(value)) } @@ -74,3 +78,7 @@ func WireNotificationType(notificationType string) (string, bool) { return wireValue, true } + +func SystemNotificationTypes() []string { + return append([]string(nil), systemNotificationTypes...) +} diff --git a/internal/service/notification/constants_test.go b/internal/service/notification/constants_test.go index 52c50d3b..cc3a3717 100644 --- a/internal/service/notification/constants_test.go +++ b/internal/service/notification/constants_test.go @@ -89,3 +89,7 @@ func TestNormalizeNotificationType_Invalid(t *testing.T) { assert.False(t, ok) assert.Equal(t, "", normalized) } + +func TestSystemNotificationTypes(t *testing.T) { + assert.Equal(t, []string{NotificationTypeEvidenceDigest}, SystemNotificationTypes()) +} diff --git a/internal/service/notification/gorm_configured_destination_resolver.go b/internal/service/notification/gorm_configured_destination_resolver.go new file mode 100644 index 00000000..8dc06e1f --- /dev/null +++ b/internal/service/notification/gorm_configured_destination_resolver.go @@ -0,0 +1,86 @@ +package notification + +import ( + "context" + "fmt" + "strings" + + "github.com/compliance-framework/api/internal/service/relational" + "gorm.io/gorm" +) + +const configuredDestinationKeySlackDigest = "slack.digest_channel" + +type GORMConfiguredDestinationResolver struct { + db *gorm.DB +} + +func NewGORMConfiguredDestinationResolver(db *gorm.DB) *GORMConfiguredDestinationResolver { + return &GORMConfiguredDestinationResolver{db: db} +} + +func (r *GORMConfiguredDestinationResolver) ResolveConfiguredDestination(ctx context.Context, key string) (ConfiguredDestination, error) { + trimmedKey := strings.TrimSpace(key) + if trimmedKey == "" { + return ConfiguredDestination{}, fmt.Errorf("%w: %q", ErrConfiguredDestinationNotFound, key) + } + if r == nil || r.db == nil { + return ConfiguredDestination{}, fmt.Errorf("%w: %q", ErrConfiguredDestinationNotFound, key) + } + + lookup, ok := configuredDestinationLookup(trimmedKey) + if !ok { + return ConfiguredDestination{}, fmt.Errorf("%w: %q", ErrConfiguredDestinationNotFound, key) + } + + var record relational.SystemNotificationDestination + result := r.db.WithContext(ctx). + Where("notification_type = ? AND provider = ?", lookup.NotificationType, lookup.Provider). + Order("updated_at DESC, created_at DESC, id DESC"). + Limit(1). + Find(&record) + if result.Error != nil { + return ConfiguredDestination{}, fmt.Errorf("failed to fetch configured destination %q: %w", trimmedKey, result.Error) + } + if result.RowsAffected == 0 { + return ConfiguredDestination{}, fmt.Errorf("%w: %q", ErrConfiguredDestinationNotFound, key) + } + + provider, ok := NormalizeDeliveryChannel(record.Provider) + if !ok { + return ConfiguredDestination{}, fmt.Errorf("%w: configured destination %q uses unsupported provider %q", ErrUnsupportedChannel, trimmedKey, record.Provider) + } + + target := record.Target.Data() + address := make(map[string]string, len(target.Address)) + for rawKey, rawValue := range target.Address { + address[strings.TrimSpace(rawKey)] = strings.TrimSpace(rawValue) + } + + destination := ConfiguredDestination{ + Provider: provider, + Address: address, + } + if err := destination.Validate(); err != nil { + return ConfiguredDestination{}, fmt.Errorf("configured destination %q is invalid: %w", trimmedKey, err) + } + + return destination, nil +} + +type configuredDestinationRecordLookup struct { + NotificationType string + Provider string +} + +func configuredDestinationLookup(key string) (configuredDestinationRecordLookup, bool) { + switch strings.TrimSpace(key) { + case configuredDestinationKeySlackDigest: + return configuredDestinationRecordLookup{ + NotificationType: NotificationTypeEvidenceDigest, + Provider: DeliveryChannelSlack, + }, true + default: + return configuredDestinationRecordLookup{}, false + } +} diff --git a/internal/service/notification/gorm_configured_destination_resolver_test.go b/internal/service/notification/gorm_configured_destination_resolver_test.go new file mode 100644 index 00000000..c9f122d9 --- /dev/null +++ b/internal/service/notification/gorm_configured_destination_resolver_test.go @@ -0,0 +1,98 @@ +package notification + +import ( + "context" + "testing" + "time" + + "github.com/compliance-framework/api/internal/service/relational" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +const ( + testDigestDestinationKey = "slack.digest_channel" + testAddressKeyChannel = "channel" + testAddressKeyTargetType = "target_type" + testSlackTargetChannel = "channel" +) + +func TestGORMConfiguredDestinationResolverResolveConfiguredDestination(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&relational.SystemNotificationDestination{})) + + record := relational.SystemNotificationDestination{ + NotificationType: NotificationTypeEvidenceDigest, + Provider: DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + testAddressKeyChannel: "ccf-alerts", + testAddressKeyTargetType: testSlackTargetChannel, + }, + }), + } + require.NoError(t, db.Create(&record).Error) + + resolver := NewGORMConfiguredDestinationResolver(db) + destination, err := resolver.ResolveConfiguredDestination(context.Background(), testDigestDestinationKey) + require.NoError(t, err) + + assert.Equal(t, DeliveryChannelSlack, destination.Provider) + assert.Equal(t, "ccf-alerts", destination.Address[testAddressKeyChannel]) + assert.Equal(t, testSlackTargetChannel, destination.Address[testAddressKeyTargetType]) +} + +func TestGORMConfiguredDestinationResolverResolveConfiguredDestinationUsesNewestMatchingRecord(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&relational.SystemNotificationDestination{})) + + older := time.Date(2026, 4, 26, 9, 0, 0, 0, time.UTC) + newer := time.Date(2026, 4, 27, 9, 0, 0, 0, time.UTC) + + require.NoError(t, db.Create(&relational.SystemNotificationDestination{ + CreatedAt: older, + UpdatedAt: older, + NotificationType: NotificationTypeEvidenceDigest, + Provider: DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + testAddressKeyChannel: "old-alerts", + testAddressKeyTargetType: testSlackTargetChannel, + }, + }), + }).Error) + require.NoError(t, db.Create(&relational.SystemNotificationDestination{ + CreatedAt: newer, + UpdatedAt: newer, + NotificationType: NotificationTypeEvidenceDigest, + Provider: DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + testAddressKeyChannel: "new-alerts", + testAddressKeyTargetType: testSlackTargetChannel, + }, + }), + }).Error) + + resolver := NewGORMConfiguredDestinationResolver(db) + destination, err := resolver.ResolveConfiguredDestination(context.Background(), testDigestDestinationKey) + require.NoError(t, err) + + assert.Equal(t, "new-alerts", destination.Address[testAddressKeyChannel]) +} + +func TestGORMConfiguredDestinationResolverResolveConfiguredDestinationNotFound(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&relational.SystemNotificationDestination{})) + + resolver := NewGORMConfiguredDestinationResolver(db) + _, err = resolver.ResolveConfiguredDestination(context.Background(), testDigestDestinationKey) + require.Error(t, err) + assert.ErrorIs(t, err, ErrConfiguredDestinationNotFound) +} diff --git a/internal/service/notification/providers/email/provider.go b/internal/service/notification/providers/email/provider.go index 7f72cc09..a40617e2 100644 --- a/internal/service/notification/providers/email/provider.go +++ b/internal/service/notification/providers/email/provider.go @@ -3,8 +3,10 @@ package email import ( "context" "fmt" + "net/mail" "strings" + "github.com/compliance-framework/api/internal/config" emailtypes "github.com/compliance-framework/api/internal/service/email/types" "github.com/compliance-framework/api/internal/service/notification" ) @@ -14,6 +16,11 @@ type Sender interface { Send(ctx context.Context, message *emailtypes.Message) (*emailtypes.SendResult, error) } +type ServiceProviderDescriptor struct { + Name string + Type string +} + type Enqueuer interface { IsStarted() bool EnqueueNotificationEmail(ctx context.Context, delivery Delivery) error @@ -29,14 +36,51 @@ type EnqueuerProvider func() Enqueuer type ContentRendererProvider func() ContentRenderer +type ServiceProviderResolver func() ServiceProviderDescriptor + +type EnabledResolver func() bool + +type ProviderOption func(*Provider) + type Provider struct { senderProvider SenderProvider enqueuerProvider EnqueuerProvider contentRendererProvider ContentRendererProvider + serviceProviderResolver ServiceProviderResolver + enabledResolver EnabledResolver +} + +const ( + MetadataKeyServiceProviderName = "service-provider-name" + MetadataKeyServiceProviderType = "service-provider-type" +) + +func NewCatalogProvider(cfg *config.Config) *Provider { + var emailConfig *config.EmailConfig + if cfg != nil { + emailConfig = cfg.Email + } + + return NewProvider( + nil, + nil, + WithEnabledResolver(func() bool { + return emailEnabledFromConfig(emailConfig) + }), + WithServiceProviderResolver(func() ServiceProviderDescriptor { + return serviceProviderDescriptorFromConfig(emailConfig) + }), + ) } -func NewProvider(senderProvider SenderProvider, enqueuerProvider EnqueuerProvider) *Provider { - return &Provider{senderProvider: senderProvider, enqueuerProvider: enqueuerProvider} +func NewProvider(senderProvider SenderProvider, enqueuerProvider EnqueuerProvider, opts ...ProviderOption) *Provider { + provider := &Provider{senderProvider: senderProvider, enqueuerProvider: enqueuerProvider} + for _, opt := range opts { + if opt != nil { + opt(provider) + } + } + return provider } func NewProviderWithTemplateRenderer( @@ -44,15 +88,138 @@ func NewProviderWithTemplateRenderer( enqueuerProvider EnqueuerProvider, contentRendererProvider ContentRendererProvider, ) *Provider { - return &Provider{ + return NewProviderWithTemplateRendererOptions(senderProvider, enqueuerProvider, contentRendererProvider) +} + +func NewProviderWithTemplateRendererOptions( + senderProvider SenderProvider, + enqueuerProvider EnqueuerProvider, + contentRendererProvider ContentRendererProvider, + opts ...ProviderOption, +) *Provider { + provider := &Provider{ senderProvider: senderProvider, enqueuerProvider: enqueuerProvider, contentRendererProvider: contentRendererProvider, } + for _, opt := range opts { + if opt != nil { + opt(provider) + } + } + return provider +} + +func WithServiceProviderResolver(resolver ServiceProviderResolver) ProviderOption { + return func(provider *Provider) { + if provider == nil { + return + } + provider.serviceProviderResolver = resolver + } +} + +func WithEnabledResolver(resolver EnabledResolver) ProviderOption { + return func(provider *Provider) { + if provider == nil { + return + } + provider.enabledResolver = resolver + } } func (p *Provider) ID() string { return ChannelID } +func (p *Provider) ProviderMetadata() notification.ProviderMetadata { + metadata := notification.ProviderMetadata{ + ProviderType: ChannelID, + DisplayName: "Email", + Description: "Configured provider for email service", + Enabled: p.enabled(), + } + + serviceProvider := p.serviceProvider() + if serviceProvider.Type != "" { + metadata.Description = fmt.Sprintf("Configured %s provider for email service", strings.ToUpper(serviceProvider.Type)) + } + if serviceProvider.Name != "" || serviceProvider.Type != "" { + metadata.Metadata = map[string]string{} + if serviceProvider.Name != "" { + metadata.Metadata[MetadataKeyServiceProviderName] = serviceProvider.Name + } + if serviceProvider.Type != "" { + metadata.Metadata[MetadataKeyServiceProviderType] = serviceProvider.Type + } + } + + return metadata +} + +type senderServiceProviderDescriptor interface { + GetDefaultProviderName() string + GetDefaultProviderType() string +} + +func serviceProviderDescriptorFromConfig(cfg *config.EmailConfig) ServiceProviderDescriptor { + if cfg == nil { + return ServiceProviderDescriptor{} + } + + provider := cfg.GetDefaultProvider() + if provider == nil { + return ServiceProviderDescriptor{} + } + + return ServiceProviderDescriptor{ + Name: strings.TrimSpace(provider.GetName()), + Type: strings.TrimSpace(provider.GetType()), + } +} + +func emailEnabledFromConfig(cfg *config.EmailConfig) bool { + if cfg == nil || !cfg.Enabled { + return false + } + + provider := cfg.GetDefaultProvider() + return provider != nil && provider.IsEnabled() +} + +func (p *Provider) serviceProvider() ServiceProviderDescriptor { + if p != nil && p.serviceProviderResolver != nil { + descriptor := p.serviceProviderResolver() + descriptor.Name = strings.TrimSpace(descriptor.Name) + descriptor.Type = strings.TrimSpace(descriptor.Type) + if descriptor.Name != "" || descriptor.Type != "" { + return descriptor + } + } + + sender := p.sender() + if sender == nil { + return ServiceProviderDescriptor{} + } + + descriptorProvider, ok := sender.(senderServiceProviderDescriptor) + if !ok { + return ServiceProviderDescriptor{} + } + + return ServiceProviderDescriptor{ + Name: strings.TrimSpace(descriptorProvider.GetDefaultProviderName()), + Type: strings.TrimSpace(descriptorProvider.GetDefaultProviderType()), + } +} + +func (p *Provider) enabled() bool { + if p != nil && p.enabledResolver != nil { + return p.enabledResolver() + } + + sender := p.sender() + return sender != nil && sender.IsEnabled() +} + func (p *Provider) ResolveUserTarget(user notification.User) (notification.Target, bool, error) { if len(user.Identities) > 0 { identity, ok := user.Identities[ChannelID] @@ -83,6 +250,47 @@ func (p *Provider) ResolveUserTarget(user notification.User) (notification.Targe }, true, nil } +func (p *Provider) BuildTarget(rawTarget string) (notification.Target, error) { + return p.NormalizeTarget(notification.Target{ + Provider: ChannelID, + Address: map[string]string{ + AddressKeyEmail: rawTarget, + }, + }) +} + +func (p *Provider) NormalizeTarget(target notification.Target) (notification.Target, error) { + address := strings.TrimSpace(target.Address[AddressKeyEmail]) + if address == "" { + return notification.Target{}, fmt.Errorf("%w: email provider requires email address", notification.ErrInvalidTarget) + } + + parsedAddress, err := mail.ParseAddress(address) + if err != nil || strings.TrimSpace(parsedAddress.Address) == "" { + return notification.Target{}, fmt.Errorf("%w: email provider requires email address", notification.ErrInvalidTarget) + } + + normalized := notification.Target{ + Provider: ChannelID, + UserID: strings.TrimSpace(target.UserID), + Address: Identity(parsedAddress.Address), + } + if err := p.ValidateTarget(normalized); err != nil { + return notification.Target{}, err + } + + return normalized, nil +} + +func (p *Provider) DisplayTarget(target notification.Target) (string, error) { + normalized, err := p.NormalizeTarget(target) + if err != nil { + return "", err + } + + return normalized.Address[AddressKeyEmail], nil +} + func (p *Provider) ValidateTarget(target notification.Target) error { address := strings.TrimSpace(target.Address[AddressKeyEmail]) if address == "" { diff --git a/internal/service/notification/providers/email/provider_test.go b/internal/service/notification/providers/email/provider_test.go index 12a41fe6..c9b8db57 100644 --- a/internal/service/notification/providers/email/provider_test.go +++ b/internal/service/notification/providers/email/provider_test.go @@ -5,6 +5,7 @@ import ( "errors" "testing" + "github.com/compliance-framework/api/internal/config" emailtypes "github.com/compliance-framework/api/internal/service/email/types" "github.com/compliance-framework/api/internal/service/notification" "github.com/stretchr/testify/assert" @@ -67,6 +68,56 @@ func TestResolveUserTargetFallsBackToUserEmail(t *testing.T) { assert.Equal(t, map[string]string{AddressKeyEmail: "alice@example.com"}, target.Address) } +func TestBuildTargetNormalizesEmailAddress(t *testing.T) { + provider := NewProvider(nil, nil) + + target, err := provider.BuildTarget(" Alice ") + require.NoError(t, err) + assert.Equal(t, ChannelID, target.Provider) + assert.Equal(t, map[string]string{AddressKeyEmail: "alice@example.com"}, target.Address) +} + +func TestProviderMetadataUsesConfiguredEmailProvider(t *testing.T) { + provider := NewCatalogProvider(&config.Config{ + Email: &config.EmailConfig{ + Enabled: true, + Provider: "smtp", + Providers: &config.SupportedEmailProviders{ + SMTP: &config.SMTPConfig{ + Name: "smtp-primary", + Enabled: true, + Host: "smtp.example.com", + Port: 587, + From: "alerts@example.com", + }, + }, + }, + }) + + metadata := provider.ProviderMetadata() + assert.Equal(t, notification.ProviderMetadata{ + ProviderType: ChannelID, + DisplayName: "Email", + Description: "Configured SMTP provider for email service", + Enabled: true, + Metadata: map[string]string{ + MetadataKeyServiceProviderName: "smtp-primary", + MetadataKeyServiceProviderType: "smtp", + }, + }, metadata) +} + +func TestDisplayTargetRejectsInvalidEmailAddress(t *testing.T) { + provider := NewProvider(nil, nil) + + _, err := provider.DisplayTarget(notification.Target{ + Provider: ChannelID, + Address: map[string]string{AddressKeyEmail: "not-an-email"}, + }) + require.Error(t, err) + assert.ErrorIs(t, err, notification.ErrInvalidTarget) +} + func TestDeliverUsesEnqueuerWhenStarted(t *testing.T) { enqueuer := &stubEnqueuer{started: true} sender := &stubSender{enabled: true} diff --git a/internal/service/notification/providers/lookup.go b/internal/service/notification/providers/lookup.go new file mode 100644 index 00000000..4660f89d --- /dev/null +++ b/internal/service/notification/providers/lookup.go @@ -0,0 +1,37 @@ +package providers + +import ( + "github.com/compliance-framework/api/internal/config" + "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" +) + +type LookupOption func(*lookupOptions) + +type lookupOptions struct { + config *config.Config +} + +func WithConfig(cfg *config.Config) LookupOption { + return func(options *lookupOptions) { + if options == nil { + return + } + options.config = cfg + } +} + +func NewLookup(opts ...LookupOption) notification.ProviderLookup { + options := lookupOptions{} + for _, opt := range opts { + if opt != nil { + opt(&options) + } + } + + return notification.NewDeliveryTransport( + notification.WithProvider(emailprovider.NewCatalogProvider(options.config)), + notification.WithProvider(slackprovider.NewCatalogProvider(options.config)), + ) +} diff --git a/internal/service/notification/providers/slack/provider.go b/internal/service/notification/providers/slack/provider.go index 0f2d2731..1e562bd4 100644 --- a/internal/service/notification/providers/slack/provider.go +++ b/internal/service/notification/providers/slack/provider.go @@ -4,10 +4,15 @@ import ( "context" "fmt" "strings" + "sync" + "time" + "github.com/compliance-framework/api/internal/config" "github.com/compliance-framework/api/internal/service/notification" + slacksvc "github.com/compliance-framework/api/internal/service/slack" slacktypes "github.com/compliance-framework/api/internal/service/slack/types" "github.com/slack-go/slack" + "go.uber.org/zap" ) const ( @@ -71,17 +76,168 @@ type SenderProvider func() Sender type EnqueuerProvider func() Enqueuer +type WorkspaceConfigurationResolver func(context.Context) (slacksvc.WorkspaceConfiguration, error) + +type EnabledResolver func() bool + +type ProviderOption func(*Provider) + type Provider struct { - senderProvider SenderProvider - enqueuerProvider EnqueuerProvider + senderProvider SenderProvider + enqueuerProvider EnqueuerProvider + enabledResolver EnabledResolver + workspaceConfigurationResolver WorkspaceConfigurationResolver + workspaceConfigurationMu sync.Mutex + workspaceConfigurationLoaded bool + workspaceConfiguration slacksvc.WorkspaceConfiguration } -func NewProvider(senderProvider SenderProvider, enqueuerProvider EnqueuerProvider) *Provider { - return &Provider{senderProvider: senderProvider, enqueuerProvider: enqueuerProvider} +const ( + MetadataKeyWorkspaceName = "workspace-name" + MetadataKeyWorkspaceURL = "workspace-url" + MetadataKeyWorkspaceDomain = "workspace-domain" + MetadataKeyEmailDomain = "email-domain" + MetadataKeyTeamID = "team-id" + MetadataKeyBotID = "bot-id" + MetadataKeyBotName = "bot-name" + MetadataKeyEnterpriseID = "enterprise-id" +) + +func NewCatalogProvider(cfg *config.Config) *Provider { + return NewProvider( + nil, + nil, + WithEnabledResolver(func() bool { + return slackEnabledFromConfig(cfg) + }), + WithWorkspaceConfigurationResolver(func(ctx context.Context) (slacksvc.WorkspaceConfiguration, error) { + if cfg == nil || cfg.Slack == nil || !cfg.Slack.Enabled || strings.TrimSpace(cfg.Slack.Token) == "" { + return slacksvc.WorkspaceConfiguration{}, nil + } + + service, err := slacksvc.NewService(cfg.Slack, zap.NewNop().Sugar()) + if err != nil { + return slacksvc.WorkspaceConfiguration{}, err + } + + return service.GetConfiguration(ctx) + }), + ) +} + +func NewProvider(senderProvider SenderProvider, enqueuerProvider EnqueuerProvider, opts ...ProviderOption) *Provider { + provider := &Provider{senderProvider: senderProvider, enqueuerProvider: enqueuerProvider} + for _, opt := range opts { + if opt != nil { + opt(provider) + } + } + return provider } func (p *Provider) ID() string { return ChannelID } +func WithEnabledResolver(resolver EnabledResolver) ProviderOption { + return func(provider *Provider) { + if provider == nil { + return + } + provider.enabledResolver = resolver + } +} + +func WithWorkspaceConfigurationResolver(resolver WorkspaceConfigurationResolver) ProviderOption { + return func(provider *Provider) { + if provider == nil { + return + } + provider.workspaceConfigurationResolver = resolver + } +} + +func (p *Provider) ProviderMetadata() notification.ProviderMetadata { + metadata := notification.ProviderMetadata{ + ProviderType: ChannelID, + DisplayName: "Slack", + Description: "Configured Slack workspace", + Enabled: p.enabled(), + } + + configuration := p.workspaceConfigurationDetails() + if configuration.WorkspaceName != "" { + metadata.Description = fmt.Sprintf("Configured Slack workspace %s", configuration.WorkspaceName) + } + + metadataMap := map[string]string{} + if configuration.WorkspaceName != "" { + metadataMap[MetadataKeyWorkspaceName] = configuration.WorkspaceName + } + if configuration.WorkspaceURL != "" { + metadataMap[MetadataKeyWorkspaceURL] = configuration.WorkspaceURL + } + if configuration.WorkspaceDomain != "" { + metadataMap[MetadataKeyWorkspaceDomain] = configuration.WorkspaceDomain + } + if configuration.EmailDomain != "" { + metadataMap[MetadataKeyEmailDomain] = configuration.EmailDomain + } + if configuration.TeamID != "" { + metadataMap[MetadataKeyTeamID] = configuration.TeamID + } + if configuration.BotID != "" { + metadataMap[MetadataKeyBotID] = configuration.BotID + } + if configuration.BotName != "" { + metadataMap[MetadataKeyBotName] = configuration.BotName + } + if configuration.EnterpriseID != "" { + metadataMap[MetadataKeyEnterpriseID] = configuration.EnterpriseID + } + if len(metadataMap) > 0 { + metadata.Metadata = metadataMap + } + + return metadata +} + +func slackEnabledFromConfig(cfg *config.Config) bool { + return cfg != nil && cfg.Slack != nil && cfg.Slack.Enabled +} + +func (p *Provider) enabled() bool { + if p != nil && p.enabledResolver != nil { + return p.enabledResolver() + } + + sender := p.sender() + return sender != nil && sender.IsEnabled() +} + +func (p *Provider) workspaceConfigurationDetails() slacksvc.WorkspaceConfiguration { + if p == nil || p.workspaceConfigurationResolver == nil { + return slacksvc.WorkspaceConfiguration{} + } + + p.workspaceConfigurationMu.Lock() + defer p.workspaceConfigurationMu.Unlock() + + if p.workspaceConfigurationLoaded { + return p.workspaceConfiguration + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + configuration, err := p.workspaceConfigurationResolver(ctx) + if err != nil { + return p.workspaceConfiguration + } + + p.workspaceConfiguration = configuration + p.workspaceConfigurationLoaded = true + return p.workspaceConfiguration +} + func (p *Provider) ResolveUserTarget(user notification.User) (notification.Target, bool, error) { if len(user.Identities) == 0 { return notification.Target{}, false, nil @@ -107,6 +263,51 @@ func (p *Provider) ResolveUserTarget(user notification.User) (notification.Targe }, true, nil } +func (p *Provider) BuildTarget(rawTarget string) (notification.Target, error) { + return p.NormalizeTarget(notification.Target{ + Provider: ChannelID, + Address: map[string]string{ + AddressKeyChannel: rawTarget, + AddressKeyTargetType: TargetTypeChannel, + }, + }) +} + +func (p *Provider) NormalizeTarget(target notification.Target) (notification.Target, error) { + channel := strings.TrimSpace(target.Address[AddressKeyChannel]) + if channel == "" { + return notification.Target{}, fmt.Errorf("%w: slack provider requires channel address", notification.ErrInvalidTarget) + } + + targetType, ok := NormalizeTargetType(target.Address[AddressKeyTargetType]) + if !ok { + return notification.Target{}, fmt.Errorf("%w: slack target requires a supported target type", notification.ErrInvalidTarget) + } + + normalized := notification.Target{ + Provider: ChannelID, + UserID: strings.TrimSpace(target.UserID), + Address: map[string]string{ + AddressKeyChannel: channel, + AddressKeyTargetType: targetType, + }, + } + if err := p.ValidateTarget(normalized); err != nil { + return notification.Target{}, err + } + + return normalized, nil +} + +func (p *Provider) DisplayTarget(target notification.Target) (string, error) { + normalized, err := p.NormalizeTarget(target) + if err != nil { + return "", err + } + + return normalized.Address[AddressKeyChannel], nil +} + func (p *Provider) ValidateTarget(target notification.Target) error { channel := strings.TrimSpace(target.Address["channel"]) if channel == "" { diff --git a/internal/service/notification/providers/slack/provider_test.go b/internal/service/notification/providers/slack/provider_test.go new file mode 100644 index 00000000..17330863 --- /dev/null +++ b/internal/service/notification/providers/slack/provider_test.go @@ -0,0 +1,143 @@ +package slack + +import ( + "context" + "errors" + "testing" + + "github.com/compliance-framework/api/internal/config" + "github.com/compliance-framework/api/internal/service/notification" + slacksvc "github.com/compliance-framework/api/internal/service/slack" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildTargetDefaultsToChannelTargetType(t *testing.T) { + provider := NewProvider(nil, nil) + + target, err := provider.BuildTarget(" ccf-alerts ") + require.NoError(t, err) + assert.Equal(t, ChannelID, target.Provider) + assert.Equal(t, "ccf-alerts", target.Address[AddressKeyChannel]) + assert.Equal(t, TargetTypeChannel, target.Address[AddressKeyTargetType]) +} + +func TestDisplayTargetReturnsNormalizedChannel(t *testing.T) { + provider := NewProvider(nil, nil) + + channel, err := provider.DisplayTarget(notification.Target{ + Provider: ChannelID, + Address: map[string]string{ + AddressKeyChannel: " ccf-alerts ", + AddressKeyTargetType: " channel ", + }, + }) + require.NoError(t, err) + assert.Equal(t, "ccf-alerts", channel) +} + +func TestProviderMetadataIncludesWorkspaceDetails(t *testing.T) { + provider := NewProvider( + nil, + nil, + WithEnabledResolver(func() bool { return true }), + WithWorkspaceConfigurationResolver(func(context.Context) (slacksvc.WorkspaceConfiguration, error) { + return slacksvc.WorkspaceConfiguration{ + WorkspaceName: "Acme Security", + WorkspaceURL: "https://acme.slack.com/", + WorkspaceDomain: "acme", + EmailDomain: "acme.example.com", + TeamID: "T123", + BotID: "B123", + BotName: "Compliance Bot", + EnterpriseID: "E123", + }, nil + }), + ) + + metadata := provider.ProviderMetadata() + assert.Equal(t, "Configured Slack workspace Acme Security", metadata.Description) + assert.True(t, metadata.Enabled) + assert.Equal(t, "Acme Security", metadata.Metadata[MetadataKeyWorkspaceName]) + assert.Equal(t, "https://acme.slack.com/", metadata.Metadata[MetadataKeyWorkspaceURL]) + assert.Equal(t, "acme", metadata.Metadata[MetadataKeyWorkspaceDomain]) + assert.Equal(t, "acme.example.com", metadata.Metadata[MetadataKeyEmailDomain]) + assert.Equal(t, "T123", metadata.Metadata[MetadataKeyTeamID]) + assert.Equal(t, "B123", metadata.Metadata[MetadataKeyBotID]) + assert.Equal(t, "Compliance Bot", metadata.Metadata[MetadataKeyBotName]) + assert.Equal(t, "E123", metadata.Metadata[MetadataKeyEnterpriseID]) +} + +func TestProviderMetadataRetriesWorkspaceDetailsAfterResolverError(t *testing.T) { + attempts := 0 + provider := NewProvider( + nil, + nil, + WithWorkspaceConfigurationResolver(func(context.Context) (slacksvc.WorkspaceConfiguration, error) { + attempts++ + if attempts == 1 { + return slacksvc.WorkspaceConfiguration{}, errors.New("temporary slack failure") + } + + return slacksvc.WorkspaceConfiguration{ + WorkspaceName: "Recovered Workspace", + TeamID: "T456", + }, nil + }), + ) + + firstMetadata := provider.ProviderMetadata() + assert.Equal(t, "Configured Slack workspace", firstMetadata.Description) + assert.Empty(t, firstMetadata.Metadata) + + secondMetadata := provider.ProviderMetadata() + assert.Equal(t, "Configured Slack workspace Recovered Workspace", secondMetadata.Description) + assert.Equal(t, "Recovered Workspace", secondMetadata.Metadata[MetadataKeyWorkspaceName]) + assert.Equal(t, "T456", secondMetadata.Metadata[MetadataKeyTeamID]) + assert.Equal(t, 2, attempts) + + provider.ProviderMetadata() + assert.Equal(t, 2, attempts) +} + +func TestNewCatalogProviderCachesEmptyWorkspaceConfigurationWhenSlackDisabled(t *testing.T) { + provider := NewCatalogProvider(&config.Config{ + Slack: &config.SlackConfig{ + Enabled: false, + Token: "xoxb-test-token", + }, + }) + + metadata := provider.ProviderMetadata() + assert.False(t, metadata.Enabled) + assert.Equal(t, "Configured Slack workspace", metadata.Description) + assert.Empty(t, metadata.Metadata) + assert.True(t, provider.workspaceConfigurationLoaded) + + provider.ProviderMetadata() + assert.True(t, provider.workspaceConfigurationLoaded) +} + +func TestProviderMetadataIncludesEnabledStateFromResolver(t *testing.T) { + provider := NewProvider( + nil, + nil, + WithEnabledResolver(func() bool { return true }), + ) + + metadata := provider.ProviderMetadata() + assert.True(t, metadata.Enabled) +} + +func TestDisplayTargetRejectsInvalidSlackTarget(t *testing.T) { + provider := NewProvider(nil, nil) + + _, err := provider.DisplayTarget(notification.Target{ + Provider: ChannelID, + Address: map[string]string{ + AddressKeyChannel: "ccf-alerts", + }, + }) + require.Error(t, err) + assert.ErrorIs(t, err, notification.ErrInvalidTarget) +} diff --git a/internal/service/notification/system_destination_repository.go b/internal/service/notification/system_destination_repository.go new file mode 100644 index 00000000..9dd25713 --- /dev/null +++ b/internal/service/notification/system_destination_repository.go @@ -0,0 +1,104 @@ +package notification + +import ( + "context" + "fmt" + + "github.com/compliance-framework/api/internal/service/relational" + "gorm.io/gorm" +) + +type SystemDestinationRepository interface { + ListTargetsByNotificationType(ctx context.Context, notificationType string) ([]Target, error) +} + +type GORMSystemDestinationRepository struct { + db *gorm.DB + providers ProviderLookup +} + +func NewGORMSystemDestinationRepository(db *gorm.DB, providers ProviderLookup) *GORMSystemDestinationRepository { + return &GORMSystemDestinationRepository{ + db: db, + providers: providers, + } +} + +func (r *GORMSystemDestinationRepository) ListTargetsByNotificationType(ctx context.Context, notificationType string) ([]Target, error) { + if r == nil || r.db == nil || r.providers == nil { + return nil, fmt.Errorf("system notification destination repository is not configured") + } + + canonicalType, ok := NormalizeNotificationType(notificationType) + if !ok { + return []Target{}, nil + } + + var records []relational.SystemNotificationDestination + if err := r.db.WithContext(ctx). + Where("notification_type = ?", canonicalType). + Find(&records).Error; err != nil { + return nil, fmt.Errorf("failed to fetch system notification destinations for type %s: %w", canonicalType, err) + } + + targets := make([]Target, 0, len(records)) + seen := make(map[string]struct{}, len(records)) + + for i := range records { + record := records[i] + recordID := "" + if record.ID != nil { + recordID = record.ID.String() + } + + provider, ok := NormalizeDeliveryChannel(record.Provider) + if !ok { + return nil, fmt.Errorf( + "system notification destination %s has unsupported provider %q", + recordID, + record.Provider, + ) + } + + configurator, ok := LookupTargetConfigurator(r.providers, provider) + if !ok { + return nil, fmt.Errorf( + "system notification destination %s has unsupported provider %q", + recordID, + record.Provider, + ) + } + + target, err := configurator.NormalizeTarget(Target{ + Provider: provider, + Address: record.Target.Data().Address, + }) + if err != nil { + return nil, fmt.Errorf( + "invalid system notification destination %s for type %s provider %s: %w", + recordID, + canonicalType, + provider, + err, + ) + } + if err := target.Validate(); err != nil { + return nil, fmt.Errorf( + "invalid system notification destination %s for type %s provider %s: %w", + recordID, + canonicalType, + provider, + err, + ) + } + + key := target.dedupKey() + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + targets = append(targets, target) + } + + return targets, nil +} diff --git a/internal/service/notification/system_destination_repository_test.go b/internal/service/notification/system_destination_repository_test.go new file mode 100644 index 00000000..be1efc31 --- /dev/null +++ b/internal/service/notification/system_destination_repository_test.go @@ -0,0 +1,132 @@ +package notification_test + +import ( + "context" + "testing" + + "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/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestGORMSystemDestinationRepositoryListTargetsByNotificationTypeExpandsTargets(t *testing.T) { + db := newNotificationSystemDestinationTestDB(t) + + require.NoError(t, db.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + "channel": " C-DIGEST ", + "target_type": " channel ", + }, + }), + }).Error) + + repo := notification.NewGORMSystemDestinationRepository(db, notificationproviders.NewLookup()) + targets, err := repo.ListTargetsByNotificationType(context.Background(), notification.NotificationTypeEvidenceDigest) + require.NoError(t, err) + require.Len(t, targets, 1) + + assert.Equal(t, notification.DeliveryChannelSlack, targets[0].Provider) + assert.Equal(t, "C-DIGEST", targets[0].Address["channel"]) + assert.Equal(t, "channel", targets[0].Address["target_type"]) +} + +func TestGORMSystemDestinationRepositoryListTargetsByNotificationTypeExpandsMultipleRowsForSameProvider(t *testing.T) { + db := newNotificationSystemDestinationTestDB(t) + + require.NoError(t, db.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + "channel": "C-PRIMARY", + "target_type": "channel", + }, + }), + }).Error) + require.NoError(t, db.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + "channel": "C-SECONDARY", + "target_type": "channel", + }, + }), + }).Error) + + repo := notification.NewGORMSystemDestinationRepository(db, notificationproviders.NewLookup()) + targets, err := repo.ListTargetsByNotificationType(context.Background(), notification.NotificationTypeEvidenceDigest) + require.NoError(t, err) + require.Len(t, targets, 2) + + channels := []string{targets[0].Address["channel"], targets[1].Address["channel"]} + assert.ElementsMatch(t, []string{"C-PRIMARY", "C-SECONDARY"}, channels) +} + +func TestGORMSystemDestinationRepositoryListTargetsByNotificationTypeDeduplicatesTargets(t *testing.T) { + db := newNotificationSystemDestinationTestDB(t) + + require.NoError(t, db.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + "channel": "C-DIGEST", + "target_type": "channel", + }, + }), + }).Error) + require.NoError(t, db.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + "channel": "C-DIGEST", + "target_type": "channel", + }, + }), + }).Error) + + repo := notification.NewGORMSystemDestinationRepository(db, notificationproviders.NewLookup()) + targets, err := repo.ListTargetsByNotificationType(context.Background(), notification.NotificationTypeEvidenceDigest) + require.NoError(t, err) + require.Len(t, targets, 1) + assert.Equal(t, "C-DIGEST", targets[0].Address["channel"]) +} + +func TestGORMSystemDestinationRepositoryListTargetsByNotificationTypeRejectsInvalidTargets(t *testing.T) { + db := newNotificationSystemDestinationTestDB(t) + + require.NoError(t, db.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.NotificationTypeEvidenceDigest, + Provider: notification.DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + "channel": "C-DIGEST", + }, + }), + }).Error) + + repo := notification.NewGORMSystemDestinationRepository(db, notificationproviders.NewLookup()) + _, err := repo.ListTargetsByNotificationType(context.Background(), notification.NotificationTypeEvidenceDigest) + require.Error(t, err) + assert.ErrorContains(t, err, "invalid system notification destination") +} + +func newNotificationSystemDestinationTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&relational.SystemNotificationDestination{})) + + return db +} diff --git a/internal/service/notification/target_configurator.go b/internal/service/notification/target_configurator.go new file mode 100644 index 00000000..c65584f1 --- /dev/null +++ b/internal/service/notification/target_configurator.go @@ -0,0 +1,23 @@ +package notification + +// TargetConfigurator extends a delivery provider with target parsing, +// normalization, and display helpers used by admin/system configuration flows. +type TargetConfigurator interface { + BuildTarget(rawTarget string) (Target, error) + NormalizeTarget(target Target) (Target, error) + DisplayTarget(target Target) (string, error) +} + +func LookupTargetConfigurator(lookup ProviderLookup, providerID string) (TargetConfigurator, bool) { + if lookup == nil { + return nil, false + } + + provider, ok := lookup.Provider(providerID) + if !ok || provider == nil { + return nil, false + } + + configurator, ok := provider.(TargetConfigurator) + return configurator, ok +} diff --git a/internal/service/notification/transport.go b/internal/service/notification/transport.go index 073c0fb0..6d91e455 100644 --- a/internal/service/notification/transport.go +++ b/internal/service/notification/transport.go @@ -3,6 +3,7 @@ package notification import ( "context" "fmt" + "sort" ) type WorkerEnqueuer interface { @@ -22,6 +23,23 @@ type ProviderLookup interface { Provider(providerID string) (Provider, bool) } +type ProviderMetadata struct { + ProviderType string + DisplayName string + Description string + Enabled bool + Metadata map[string]string +} + +type ProviderMetadataProvider interface { + ProviderMetadata() ProviderMetadata +} + +type ProviderCatalog interface { + ProviderIDs() []string + Providers() []ProviderMetadata +} + type DeliveryTransport struct { providers map[string]Provider } @@ -90,6 +108,59 @@ func (t *DeliveryTransport) Provider(providerID string) (Provider, bool) { return provider, exists } +func (t *DeliveryTransport) ProviderIDs() []string { + if t == nil { + return nil + } + + providerIDs := make([]string, 0, len(t.providers)) + for providerID := range t.providers { + providerIDs = append(providerIDs, providerID) + } + sort.Strings(providerIDs) + + return providerIDs +} + +func (t *DeliveryTransport) Providers() []ProviderMetadata { + if t == nil { + return nil + } + + providerIDs := t.ProviderIDs() + providers := make([]ProviderMetadata, 0, len(providerIDs)) + for _, providerID := range providerIDs { + metadata := ProviderMetadata{ + ProviderType: providerID, + DisplayName: providerID, + } + + if providerWithMetadata, ok := t.providers[providerID].(ProviderMetadataProvider); ok { + metadata = providerWithMetadata.ProviderMetadata() + } + + if canonicalProviderType, ok := NormalizeDeliveryChannel(metadata.ProviderType); ok { + metadata.ProviderType = canonicalProviderType + } else { + metadata.ProviderType = providerID + } + if metadata.DisplayName == "" { + metadata.DisplayName = metadata.ProviderType + } + if len(metadata.Metadata) > 0 { + cloned := make(map[string]string, len(metadata.Metadata)) + for key, value := range metadata.Metadata { + cloned[key] = value + } + metadata.Metadata = cloned + } + + providers = append(providers, metadata) + } + + return providers +} + func (t *DeliveryTransport) registerProvider(provider Provider) { if t == nil || provider == nil { return diff --git a/internal/service/notification/transport_test.go b/internal/service/notification/transport_test.go index bebf0fa8..859b9aca 100644 --- a/internal/service/notification/transport_test.go +++ b/internal/service/notification/transport_test.go @@ -60,6 +60,14 @@ type testEmailProvider struct { func (p *testEmailProvider) ID() string { return DeliveryChannelEmail } +func (p *testEmailProvider) ProviderMetadata() ProviderMetadata { + return ProviderMetadata{ + ProviderType: DeliveryChannelEmail, + DisplayName: "Email", + Description: "Configured SMTP provider for email service", + } +} + func (p *testEmailProvider) ResolveUserTarget(_ User) (Target, bool, error) { return Target{}, false, nil } @@ -128,6 +136,14 @@ type testSlackProvider struct { func (p *testSlackProvider) ID() string { return DeliveryChannelSlack } +func (p *testSlackProvider) ProviderMetadata() ProviderMetadata { + return ProviderMetadata{ + ProviderType: DeliveryChannelSlack, + DisplayName: "Slack", + Description: "Configured Slack workspace", + } +} + func (p *testSlackProvider) ResolveUserTarget(_ User) (Target, bool, error) { return Target{}, false, nil } @@ -228,3 +244,24 @@ func TestDeliveryTransportFallsBackToDirectSend(t *testing.T) { require.Len(t, slackSender.messages, 1) assert.Equal(t, "C-DIGEST", slackSender.channels[0]) } + +func TestDeliveryTransportProvidersReturnsSortedMetadata(t *testing.T) { + transport := NewDeliveryTransport( + WithProvider(&testSlackProvider{}), + WithProvider(&testEmailProvider{}), + ) + + providers := transport.Providers() + require.Len(t, providers, 2) + + assert.Equal(t, ProviderMetadata{ + ProviderType: DeliveryChannelEmail, + DisplayName: "Email", + Description: "Configured SMTP provider for email service", + }, providers[0]) + assert.Equal(t, ProviderMetadata{ + ProviderType: DeliveryChannelSlack, + DisplayName: "Slack", + Description: "Configured Slack workspace", + }, providers[1]) +} diff --git a/internal/service/relational/system_notification_destination.go b/internal/service/relational/system_notification_destination.go new file mode 100644 index 00000000..da484bda --- /dev/null +++ b/internal/service/relational/system_notification_destination.go @@ -0,0 +1,33 @@ +package relational + +import ( + "time" + + "gorm.io/datatypes" + "gorm.io/gorm" +) + +// SystemNotificationTarget stores the provider-specific address attributes for +// a system-wide notification destination target. +type SystemNotificationTarget struct { + Address map[string]string `json:"address"` +} + +// SystemNotificationDestination stores system-wide notification delivery +// targets for a notification type and provider combination. +type SystemNotificationDestination struct { + UUIDModel + + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"deletedAt" gorm:"index"` + + NotificationType string `json:"notificationType" gorm:"not null;index:idx_ccf_system_notification_destinations_notification_type,WHERE:deleted_at IS NULL;index:idx_ccf_system_notification_destinations_type_provider,priority:1,WHERE:deleted_at IS NULL"` + Provider string `json:"provider" gorm:"not null;index:idx_ccf_system_notification_destinations_type_provider,priority:2,WHERE:deleted_at IS NULL"` + + Target datatypes.JSONType[SystemNotificationTarget] `json:"target"` +} + +func (SystemNotificationDestination) TableName() string { + return "ccf_system_notification_destinations" +} diff --git a/internal/service/relational/system_notification_destination_test.go b/internal/service/relational/system_notification_destination_test.go new file mode 100644 index 00000000..a481019d --- /dev/null +++ b/internal/service/relational/system_notification_destination_test.go @@ -0,0 +1,26 @@ +package relational + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestSystemNotificationDestinationAutoMigrateCreatesNotificationIndexes(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + + require.NoError(t, db.AutoMigrate(&SystemNotificationDestination{})) + + assert.True( + t, + db.Migrator().HasIndex(&SystemNotificationDestination{}, "idx_ccf_system_notification_destinations_notification_type"), + ) + assert.True( + t, + db.Migrator().HasIndex(&SystemNotificationDestination{}, "idx_ccf_system_notification_destinations_type_provider"), + ) +} diff --git a/internal/service/slack/service.go b/internal/service/slack/service.go index 42d0c3e9..2ac68ca2 100644 --- a/internal/service/slack/service.go +++ b/internal/service/slack/service.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "sync" "github.com/compliance-framework/api/internal/config" "github.com/compliance-framework/api/internal/service/slack/types" @@ -11,10 +12,29 @@ import ( "go.uber.org/zap" ) +type apiClient interface { + SendMessageContext(ctx context.Context, channelID string, options ...slack.MsgOption) (string, string, string, error) + AuthTestContext(ctx context.Context) (*slack.AuthTestResponse, error) + GetTeamInfoContext(ctx context.Context) (*slack.TeamInfo, error) + GetBotInfoContext(ctx context.Context, parameters slack.GetBotInfoParameters) (*slack.Bot, error) +} + +type WorkspaceConfiguration struct { + WorkspaceName string + WorkspaceURL string + WorkspaceDomain string + EmailDomain string + TeamID string + BotID string + BotName string + EnterpriseID string +} + type Service struct { - config *config.SlackConfig - logger *zap.SugaredLogger - client *slack.Client + config *config.SlackConfig + logger *zap.SugaredLogger + clientMu sync.Mutex + client apiClient } func NewService(cfg *config.SlackConfig, logger *zap.SugaredLogger) (*Service, error) { @@ -28,6 +48,76 @@ func NewService(cfg *config.SlackConfig, logger *zap.SugaredLogger) (*Service, e return service, nil } +func (s *Service) GetConfiguration(ctx context.Context) (WorkspaceConfiguration, error) { + if s == nil || s.config == nil { + return WorkspaceConfiguration{}, fmt.Errorf("slack service is not configured") + } + + return s.GetConfigurationForToken(ctx, s.config.Token) +} + +func (s *Service) GetConfigurationForToken(ctx context.Context, token string) (WorkspaceConfiguration, error) { + if s == nil || s.config == nil { + return WorkspaceConfiguration{}, fmt.Errorf("slack service is not configured") + } + + if !s.config.Enabled { + return WorkspaceConfiguration{}, fmt.Errorf("slack service is not enabled") + } + + trimmedToken := strings.TrimSpace(token) + if trimmedToken == "" { + return WorkspaceConfiguration{}, fmt.Errorf("slack token is required") + } + + api := s.clientForToken(trimmedToken) + auth, err := api.AuthTestContext(ctx) + if err != nil { + return WorkspaceConfiguration{}, err + } + + configuration := WorkspaceConfiguration{ + WorkspaceName: strings.TrimSpace(auth.Team), + WorkspaceURL: strings.TrimSpace(auth.URL), + TeamID: strings.TrimSpace(auth.TeamID), + BotID: strings.TrimSpace(auth.BotID), + EnterpriseID: strings.TrimSpace(auth.EnterpriseID), + } + + teamInfo, err := api.GetTeamInfoContext(ctx) + if err != nil { + if s != nil && s.logger != nil { + s.logger.Warnw("Failed to retrieve Slack team info", "error", err) + } + } else if teamInfo != nil { + if name := strings.TrimSpace(teamInfo.Name); name != "" { + configuration.WorkspaceName = name + } + configuration.WorkspaceDomain = strings.TrimSpace(teamInfo.Domain) + configuration.EmailDomain = strings.TrimSpace(teamInfo.EmailDomain) + } + + if configuration.BotID == "" { + return configuration, nil + } + + botInfo, err := api.GetBotInfoContext(ctx, slack.GetBotInfoParameters{ + Bot: configuration.BotID, + TeamID: configuration.TeamID, + }) + if err != nil { + if s != nil && s.logger != nil { + s.logger.Warnw("Failed to retrieve Slack bot info", "error", err, "botID", configuration.BotID) + } + return configuration, nil + } + if botInfo != nil { + configuration.BotName = strings.TrimSpace(botInfo.Name) + } + + return configuration, nil +} + func (s *Service) SendMessage(ctx context.Context, channel string, message *types.Message) (*types.SendResult, error) { if s == nil || s.config == nil { err := fmt.Errorf("slack service is not configured") @@ -41,11 +131,7 @@ func (s *Service) SendMessage(ctx context.Context, channel string, message *type return sendFailureResult(err), err } - api := s.client - if api == nil { - api = slack.New(s.config.Token) - s.client = api - } + api := s.clientForToken(s.config.Token) opts := []slack.MsgOption{ slack.MsgOptionText(message.Text, false), @@ -77,6 +163,26 @@ func (s *Service) IsEnabled() bool { return s.config != nil && s.config.Enabled } +func (s *Service) clientForToken(token string) apiClient { + trimmedToken := strings.TrimSpace(token) + if s == nil || s.config == nil { + return slack.New(trimmedToken) + } + + if trimmedToken != strings.TrimSpace(s.config.Token) { + return slack.New(trimmedToken) + } + + s.clientMu.Lock() + defer s.clientMu.Unlock() + + if s.client == nil { + s.client = slack.New(trimmedToken) + } + + return s.client +} + func validateSendInput(channel string, message *types.Message) error { if message == nil { return fmt.Errorf("message is required") diff --git a/internal/service/slack/service_test.go b/internal/service/slack/service_test.go index ffc9b47c..999184d8 100644 --- a/internal/service/slack/service_test.go +++ b/internal/service/slack/service_test.go @@ -1,14 +1,53 @@ package slack import ( + "context" + "errors" + "sync" "testing" "github.com/compliance-framework/api/internal/config" + slacktypes "github.com/compliance-framework/api/internal/service/slack/types" + goslack "github.com/slack-go/slack" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" ) +type fakeAPIClient struct { + authResponse *goslack.AuthTestResponse + authErr error + teamInfo *goslack.TeamInfo + teamInfoErr error + botInfo *goslack.Bot + botInfoErr error +} + +func (f *fakeAPIClient) SendMessageContext(_ context.Context, _ string, _ ...goslack.MsgOption) (string, string, string, error) { + return "", "", "", nil +} + +func (f *fakeAPIClient) AuthTestContext(_ context.Context) (*goslack.AuthTestResponse, error) { + if f.authErr != nil { + return nil, f.authErr + } + return f.authResponse, nil +} + +func (f *fakeAPIClient) GetTeamInfoContext(_ context.Context) (*goslack.TeamInfo, error) { + if f.teamInfoErr != nil { + return nil, f.teamInfoErr + } + return f.teamInfo, nil +} + +func (f *fakeAPIClient) GetBotInfoContext(_ context.Context, _ goslack.GetBotInfoParameters) (*goslack.Bot, error) { + if f.botInfoErr != nil { + return nil, f.botInfoErr + } + return f.botInfo, nil +} + func TestNewService_WithToken_InitializesClient(t *testing.T) { service, err := NewService(&config.SlackConfig{ Enabled: true, @@ -30,3 +69,160 @@ func TestNewService_WithoutToken_DoesNotInitializeClient(t *testing.T) { require.NotNil(t, service) assert.Nil(t, service.client) } + +func TestGetConfigurationUsesAuthTestTeamAndBotInfo(t *testing.T) { + service := &Service{ + config: &config.SlackConfig{ + Enabled: true, + Token: "xoxb-test-token", + }, + logger: zap.NewNop().Sugar(), + client: &fakeAPIClient{ + authResponse: &goslack.AuthTestResponse{ + URL: "https://acme.slack.com/", + Team: "Acme", + TeamID: "T123", + BotID: "B123", + EnterpriseID: "E123", + }, + teamInfo: &goslack.TeamInfo{ + ID: "T123", + Name: "Acme Security", + Domain: "acme", + EmailDomain: "acme.example.com", + }, + botInfo: &goslack.Bot{ + ID: "B123", + Name: "Compliance Bot", + }, + }, + } + + configuration, err := service.GetConfiguration(context.Background()) + require.NoError(t, err) + assert.Equal(t, WorkspaceConfiguration{ + WorkspaceName: "Acme Security", + WorkspaceURL: "https://acme.slack.com/", + WorkspaceDomain: "acme", + EmailDomain: "acme.example.com", + TeamID: "T123", + BotID: "B123", + BotName: "Compliance Bot", + EnterpriseID: "E123", + }, configuration) +} + +func TestGetConfigurationReturnsPartialMetadataWhenTeamOrBotLookupsFail(t *testing.T) { + service := &Service{ + config: &config.SlackConfig{ + Enabled: true, + Token: "xoxb-test-token", + }, + logger: zap.NewNop().Sugar(), + client: &fakeAPIClient{ + authResponse: &goslack.AuthTestResponse{ + URL: "https://acme.slack.com/", + Team: "Acme", + TeamID: "T123", + BotID: "B123", + }, + teamInfoErr: errors.New("team info failed"), + botInfoErr: errors.New("bot info failed"), + }, + } + + configuration, err := service.GetConfiguration(context.Background()) + require.NoError(t, err) + assert.Equal(t, WorkspaceConfiguration{ + WorkspaceName: "Acme", + WorkspaceURL: "https://acme.slack.com/", + TeamID: "T123", + BotID: "B123", + }, configuration) +} + +func TestGetConfigurationForTokenRequiresToken(t *testing.T) { + service := &Service{ + config: &config.SlackConfig{Enabled: true}, + logger: zap.NewNop().Sugar(), + client: &fakeAPIClient{}, + } + + configuration, err := service.GetConfigurationForToken(context.Background(), " ") + require.Error(t, err) + assert.Equal(t, WorkspaceConfiguration{}, configuration) +} + +func TestGetConfigurationForTokenRequiresConfiguredService(t *testing.T) { + tests := []struct { + name string + service *Service + }{ + { + name: "nil service", + service: nil, + }, + { + name: "nil config", + service: &Service{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configuration, err := tt.service.GetConfigurationForToken(context.Background(), "xoxb-test-token") + require.Error(t, err) + assert.Equal(t, "slack service is not configured", err.Error()) + assert.Equal(t, WorkspaceConfiguration{}, configuration) + }) + } +} + +func TestClientForTokenCachesConfiguredClientConcurrently(t *testing.T) { + service := &Service{ + config: &config.SlackConfig{ + Enabled: true, + Token: "xoxb-test-token", + }, + logger: zap.NewNop().Sugar(), + } + + const workers = 20 + start := make(chan struct{}) + clients := make(chan apiClient, workers) + var wg sync.WaitGroup + wg.Add(workers) + + for range workers { + go func() { + defer wg.Done() + <-start + clients <- service.clientForToken("xoxb-test-token") + }() + } + + close(start) + wg.Wait() + close(clients) + + require.NotNil(t, service.client) + cached := service.client + for client := range clients { + assert.True(t, client == cached) + } +} + +func TestSendMessageUsesExistingClientInterface(t *testing.T) { + service := &Service{ + config: &config.SlackConfig{ + Enabled: true, + Token: "xoxb-test-token", + }, + logger: zap.NewNop().Sugar(), + client: &fakeAPIClient{}, + } + + result, err := service.SendMessage(context.Background(), "C123", &slacktypes.Message{Text: "hello"}) + require.NoError(t, err) + assert.True(t, result.Success) +} diff --git a/internal/tests/migrate.go b/internal/tests/migrate.go index ad5a4014..9cd1ef69 100644 --- a/internal/tests/migrate.go +++ b/internal/tests/migrate.go @@ -171,6 +171,7 @@ func (t *TestMigrator) Up() error { &relational.SlackUserLink{}, &relational.User{}, &relational.UserNotificationSubscription{}, + &relational.SystemNotificationDestination{}, &service.Heartbeat{}, &poamrel.PoamItem{}, @@ -374,6 +375,7 @@ func (t *TestMigrator) Down() error { &relational.SlackUserLink{}, &relational.User{}, &relational.UserNotificationSubscription{}, + &relational.SystemNotificationDestination{}, &service.Heartbeat{}, &relational.Evidence{},