diff --git a/cmd/digest.go b/cmd/digest.go new file mode 100644 index 00000000..68eaceeb --- /dev/null +++ b/cmd/digest.go @@ -0,0 +1,187 @@ +package cmd + +import ( + "context" + "fmt" + "log" + + "github.com/compliance-framework/api/internal/config" + "github.com/compliance-framework/api/internal/service" + "github.com/compliance-framework/api/internal/service/digest" + "github.com/compliance-framework/api/internal/service/email" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "go.uber.org/zap" +) + +var ( + dryRun bool + + DigestCmd = &cobra.Command{ + Use: "digest", + Short: "Digest management commands", + } + + digestTestCmd = &cobra.Command{ + Use: "test", + Short: "Test the digest by sending it immediately to all subscribed users", + Run: runDigestTest, + } + + digestPreviewCmd = &cobra.Command{ + Use: "preview", + Short: "Preview the digest summary without sending emails", + Run: runDigestPreview, + } +) + +func init() { + digestTestCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be sent without sending emails") + DigestCmd.AddCommand(digestTestCmd) + DigestCmd.AddCommand(digestPreviewCmd) +} + +func runDigestTest(cmd *cobra.Command, args []string) { + ctx := context.Background() + + var sugar *zap.SugaredLogger + if viper.GetBool("use_dev_logger") { + sugar = zap.Must(zap.NewDevelopment()).Sugar() + } else { + sugar = zap.Must(zap.NewProduction()).Sugar() + } + + defer func() { + if err := sugar.Sync(); err != nil { + log.Printf("failed to sync zap logger: %v", err) + } + }() + + cfg := config.NewConfig(sugar) + + // Check if this is production and add confirmation if not dry-run + if cfg.Environment == "production" && !dryRun { + fmt.Print("āš ļø WARNING: You are about to send digest emails to all subscribed users in PRODUCTION!\n") + fmt.Print("This will send emails to real users. Are you sure you want to continue? (type 'yes' to confirm): ") + + var response string + _, err := fmt.Scanln(&response) + if err != nil { + sugar.Fatalw("Failed to read user input", "error", err) + } + if response != "yes" { + fmt.Println("Operation cancelled.") + return + } + } + + db, err := service.ConnectSQLDb(ctx, cfg, sugar) + if err != nil { + sugar.Fatalw("Failed to connect to SQL database", "error", err) + } + + emailService, err := email.NewService(cfg.Email, sugar) + if err != nil { + sugar.Fatalw("Failed to initialize email service", "error", err) + } + + digestService := digest.NewService(db, emailService, cfg, sugar) + + if dryRun { + sugar.Info("Running digest test in DRY-RUN mode (no emails will be sent)...") + + // Get the digest summary without sending + summary, err := digestService.GetGlobalEvidenceSummary(ctx) + if err != nil { + sugar.Fatalw("Failed to get digest summary", "error", err) + } + + sugar.Infow("Digest summary (dry-run)", + "total_evidence", summary.TotalCount, + "satisfied", summary.SatisfiedCount, + "not_satisfied", summary.NotSatisfiedCount, + "expired", summary.ExpiredCount, + "top_not_satisfied_count", len(summary.TopNotSatisfied), + "top_expired_count", len(summary.TopExpired), + ) + + sugar.Info("Dry-run completed successfully - no emails were sent") + return + } + + sugar.Info("Running digest test...") + if err := digestService.SendGlobalDigest(ctx); err != nil { + sugar.Fatalw("Failed to send digest", "error", err) + } + + sugar.Info("Digest test completed successfully") +} + +func runDigestPreview(cmd *cobra.Command, args []string) { + ctx := context.Background() + + var sugar *zap.SugaredLogger + if viper.GetBool("use_dev_logger") { + sugar = zap.Must(zap.NewDevelopment()).Sugar() + } else { + sugar = zap.Must(zap.NewProduction()).Sugar() + } + + defer func() { + if err := sugar.Sync(); err != nil { + log.Printf("failed to sync zap logger: %v", err) + } + }() + + cfg := config.NewConfig(sugar) + + db, err := service.ConnectSQLDb(ctx, cfg, sugar) + if err != nil { + sugar.Fatalw("Failed to connect to SQL database", "error", err) + } + + emailService, err := email.NewService(cfg.Email, sugar) + if err != nil { + sugar.Warnw("Failed to initialize email service", "error", err) + } + + digestService := digest.NewService(db, emailService, cfg, sugar) + + summary, err := digestService.GetGlobalEvidenceSummary(ctx) + if err != nil { + sugar.Fatalw("Failed to get evidence summary", "error", err) + } + + users, err := digestService.GetSubscribedUsers(ctx) + if err != nil { + sugar.Fatalw("Failed to get subscribed users", "error", err) + } + + fmt.Println("\n=== Evidence Digest Preview ===") + fmt.Printf("Total Evidence: %d\n", summary.TotalCount) + fmt.Printf("Satisfied: %d\n", summary.SatisfiedCount) + fmt.Printf("Not Satisfied: %d\n", summary.NotSatisfiedCount) + fmt.Printf("Expired: %d\n", summary.ExpiredCount) + fmt.Printf("Other: %d\n", summary.OtherCount) + fmt.Printf("\nSubscribed Users: %d\n", len(users)) + + if len(summary.TopNotSatisfied) > 0 { + fmt.Println("\nTop Not Satisfied Evidence:") + for i, item := range summary.TopNotSatisfied { + fmt.Printf(" %d. %s (UUID: %s)\n", i+1, item.Title, item.UUID) + } + } + + if len(summary.TopExpired) > 0 { + fmt.Println("\nTop Expired Evidence:") + for i, item := range summary.TopExpired { + fmt.Printf(" %d. %s (UUID: %s, Expired: %v)\n", i+1, item.Title, item.UUID, item.ExpiresAt) + } + } + + if summary.NotSatisfiedCount == 0 && summary.ExpiredCount == 0 { + fmt.Println("\nāœ“ No issues found - digest would be skipped") + } else { + fmt.Println("\nāœ“ Digest would be sent to subscribed users") + } +} diff --git a/cmd/root.go b/cmd/root.go index 23a782b4..ac41393b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,6 +26,9 @@ func setDefaultEnvironmentVariables() { viper.SetDefault("db_debug", "false") viper.SetDefault("metrics_enabled", "true") viper.SetDefault("metrics_port", ":9090") + viper.SetDefault("evidence_default_expiry_months", "1") + viper.SetDefault("digest_enabled", "true") + viper.SetDefault("digest_schedule", "@weekly") } func bindEnvironmentVariables() { @@ -45,6 +48,9 @@ func bindEnvironmentVariables() { viper.MustBindEnv("metrics_enabled") viper.MustBindEnv("metrics_port") viper.MustBindEnv("use_dev_logger") + viper.MustBindEnv("evidence_default_expiry_months") + viper.MustBindEnv("digest_enabled") + viper.MustBindEnv("digest_schedule") } func init() { @@ -71,6 +77,7 @@ func init() { rootCmd.AddCommand(seed.RootCmd) rootCmd.AddCommand(newMigrateCMD()) rootCmd.AddCommand(dashboards.RootCmd) + rootCmd.AddCommand(DigestCmd) } func Execute() error { diff --git a/cmd/run.go b/cmd/run.go index aa1dd533..2e7bbf74 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -3,6 +3,7 @@ package cmd import ( "context" "log" + "time" "github.com/compliance-framework/api/internal/api" "github.com/compliance-framework/api/internal/api/handler" @@ -10,6 +11,9 @@ import ( "github.com/compliance-framework/api/internal/api/handler/oscal" "github.com/compliance-framework/api/internal/config" "github.com/compliance-framework/api/internal/service" + "github.com/compliance-framework/api/internal/service/digest" + "github.com/compliance-framework/api/internal/service/email" + "github.com/compliance-framework/api/internal/service/scheduler" "github.com/spf13/cobra" "github.com/spf13/viper" "go.uber.org/zap" @@ -51,9 +55,46 @@ func RunServer(cmd *cobra.Command, args []string) { sugar.Fatalw("Failed to migrate database", "error", err) } + // Initialize email service + emailService, err := email.NewService(cfg.Email, sugar) + if err != nil { + sugar.Warnw("Failed to initialize email service, digests will be disabled", "error", err) + } + + // Initialize digest service + digestService := digest.NewService(db, emailService, cfg, sugar) + + // Initialize scheduler + sched := scheduler.NewCronScheduler(sugar) + + // Register digest job using config + if cfg.DigestEnabled { + digestJob := digest.NewGlobalDigestJob(digestService, sugar) + if err := sched.ScheduleCron(cfg.DigestSchedule, digestJob); err != nil { + sugar.Warnw("Failed to schedule digest job", "schedule", cfg.DigestSchedule, "error", err) + } else { + sugar.Debugw("Digest job scheduled", "schedule", cfg.DigestSchedule) + } + } else { + sugar.Debugw("Digest scheduler disabled") + } + + // Start the scheduler + sched.Start() + defer func() { + stopCtx := sched.Stop() + // Wait for jobs to finish gracefully with a 10-second timeout + select { + case <-stopCtx.Done(): + sugar.Debug("All scheduled jobs completed gracefully") + case <-time.After(10 * time.Second): + sugar.Warn("Scheduler shutdown timeout, some jobs may not have completed") + } + }() + metrics := api.NewMetricsHandler(ctx, sugar) server := api.NewServer(ctx, sugar, cfg, metrics) - handler.RegisterHandlers(server, sugar, db, cfg) + handler.RegisterHandlers(server, sugar, db, cfg, digestService, sched) oscal.RegisterHandlers(server, sugar, db, cfg) auth.RegisterHandlers(server, sugar, db, cfg, metrics) diff --git a/docs/docs.go b/docs/docs.go index facf994d..1da491bf 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -21,6 +21,85 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/admin/digest/preview": { + "get": { + "description": "Returns the current evidence summary that would be included in a digest email", + "produces": [ + "application/json" + ], + "tags": [ + "Digest" + ], + "summary": "Preview evidence digest", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-digest_EvidenceSummary" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/digest/trigger": { + "post": { + "description": "Manually triggers the evidence digest job to send emails to all users", + "produces": [ + "application/json" + ], + "tags": [ + "Digest" + ], + "summary": "Trigger evidence digest", + "parameters": [ + { + "type": "string", + "description": "Job name to trigger (default: global-evidence-digest)", + "name": "job", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/admin/users": { "get": { "description": "Lists all users in the system", @@ -16246,6 +16325,110 @@ const docTemplate = `{ ] } }, + "/users/me/digest-subscription": { + "get": { + "description": "Gets the current user's digest email subscription status", + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get digest subscription status", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_UserHandler" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "put": { + "description": "Updates the current user's digest email subscription status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Update digest subscription status", + "parameters": [ + { + "description": "Subscription status", + "name": "subscription", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.UserHandler" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_UserHandler" + } + }, + "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": [] + } + ] + } + }, "/users/{id}/change-password": { "post": { "description": "Changes the password for a user by ID", @@ -16377,6 +16560,74 @@ const docTemplate = `{ "datatypes.JSONType-relational_SystemComponentStatus": { "type": "object" }, + "digest.EvidenceItem": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "expiresAt": { + "description": "Formatted expiration date string (empty if no expiration)", + "type": "string" + }, + "id": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, + "digest.EvidenceSummary": { + "type": "object", + "properties": { + "expiredCount": { + "type": "integer", + "format": "int64" + }, + "notSatisfiedCount": { + "type": "integer", + "format": "int64" + }, + "otherCount": { + "type": "integer", + "format": "int64" + }, + "satisfiedCount": { + "type": "integer", + "format": "int64" + }, + "topExpired": { + "description": "Top items for the digest email", + "type": "array", + "items": { + "$ref": "#/definitions/digest.EvidenceItem" + } + }, + "topNotSatisfied": { + "type": "array", + "items": { + "$ref": "#/definitions/digest.EvidenceItem" + } + }, + "totalCount": { + "type": "integer", + "format": "int64" + } + } + }, "gorm.DeletedAt": { "type": "object", "properties": { @@ -17307,6 +17558,19 @@ const docTemplate = `{ } } }, + "handler.GenericDataResponse-digest_EvidenceSummary": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/digest.EvidenceSummary" + } + ] + } + } + }, "handler.GenericDataResponse-handler_FilterImportResponse": { "type": "object", "properties": { @@ -17346,6 +17610,19 @@ const docTemplate = `{ } } }, + "handler.GenericDataResponse-handler_UserHandler": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/handler.UserHandler" + } + ] + } + } + }, "handler.GenericDataResponse-oscalTypes_1_1_3_Activity": { "type": "object", "properties": { @@ -24794,6 +25071,10 @@ const docTemplate = `{ } ] }, + "digestSubscribed": { + "description": "DigestSubscribed indicates if the user wants to receive evidence digest emails", + "type": "boolean" + }, "email": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index 8d75d563..941ed07a 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -15,6 +15,85 @@ "host": "localhost:8080", "basePath": "/api", "paths": { + "/admin/digest/preview": { + "get": { + "description": "Returns the current evidence summary that would be included in a digest email", + "produces": [ + "application/json" + ], + "tags": [ + "Digest" + ], + "summary": "Preview evidence digest", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-digest_EvidenceSummary" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/digest/trigger": { + "post": { + "description": "Manually triggers the evidence digest job to send emails to all users", + "produces": [ + "application/json" + ], + "tags": [ + "Digest" + ], + "summary": "Trigger evidence digest", + "parameters": [ + { + "type": "string", + "description": "Job name to trigger (default: global-evidence-digest)", + "name": "job", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/admin/users": { "get": { "description": "Lists all users in the system", @@ -16240,6 +16319,110 @@ ] } }, + "/users/me/digest-subscription": { + "get": { + "description": "Gets the current user's digest email subscription status", + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get digest subscription status", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_UserHandler" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "put": { + "description": "Updates the current user's digest email subscription status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Update digest subscription status", + "parameters": [ + { + "description": "Subscription status", + "name": "subscription", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.UserHandler" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_UserHandler" + } + }, + "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": [] + } + ] + } + }, "/users/{id}/change-password": { "post": { "description": "Changes the password for a user by ID", @@ -16371,6 +16554,74 @@ "datatypes.JSONType-relational_SystemComponentStatus": { "type": "object" }, + "digest.EvidenceItem": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "expiresAt": { + "description": "Formatted expiration date string (empty if no expiration)", + "type": "string" + }, + "id": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, + "digest.EvidenceSummary": { + "type": "object", + "properties": { + "expiredCount": { + "type": "integer", + "format": "int64" + }, + "notSatisfiedCount": { + "type": "integer", + "format": "int64" + }, + "otherCount": { + "type": "integer", + "format": "int64" + }, + "satisfiedCount": { + "type": "integer", + "format": "int64" + }, + "topExpired": { + "description": "Top items for the digest email", + "type": "array", + "items": { + "$ref": "#/definitions/digest.EvidenceItem" + } + }, + "topNotSatisfied": { + "type": "array", + "items": { + "$ref": "#/definitions/digest.EvidenceItem" + } + }, + "totalCount": { + "type": "integer", + "format": "int64" + } + } + }, "gorm.DeletedAt": { "type": "object", "properties": { @@ -17301,6 +17552,19 @@ } } }, + "handler.GenericDataResponse-digest_EvidenceSummary": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/digest.EvidenceSummary" + } + ] + } + } + }, "handler.GenericDataResponse-handler_FilterImportResponse": { "type": "object", "properties": { @@ -17340,6 +17604,19 @@ } } }, + "handler.GenericDataResponse-handler_UserHandler": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/handler.UserHandler" + } + ] + } + } + }, "handler.GenericDataResponse-oscalTypes_1_1_3_Activity": { "type": "object", "properties": { @@ -24788,6 +25065,10 @@ } ] }, + "digestSubscribed": { + "description": "DigestSubscribed indicates if the user wants to receive evidence digest emails", + "type": "boolean" + }, "email": { "type": "string" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d7e51823..7e3ebe99 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -43,6 +43,53 @@ definitions: type: object datatypes.JSONType-relational_SystemComponentStatus: type: object + digest.EvidenceItem: + properties: + description: + type: string + expiresAt: + description: Formatted expiration date string (empty if no expiration) + type: string + id: + type: string + labels: + items: + type: string + type: array + status: + type: string + title: + type: string + uuid: + type: string + type: object + digest.EvidenceSummary: + properties: + expiredCount: + format: int64 + type: integer + notSatisfiedCount: + format: int64 + type: integer + otherCount: + format: int64 + type: integer + satisfiedCount: + format: int64 + type: integer + topExpired: + description: Top items for the digest email + items: + $ref: '#/definitions/digest.EvidenceItem' + type: array + topNotSatisfied: + items: + $ref: '#/definitions/digest.EvidenceItem' + type: array + totalCount: + format: int64 + type: integer + type: object gorm.DeletedAt: properties: time: @@ -698,6 +745,13 @@ definitions: - $ref: '#/definitions/auth.AuthHandler' description: Items from the list response type: object + handler.GenericDataResponse-digest_EvidenceSummary: + properties: + data: + allOf: + - $ref: '#/definitions/digest.EvidenceSummary' + description: Items from the list response + type: object handler.GenericDataResponse-handler_FilterImportResponse: properties: data: @@ -719,6 +773,13 @@ definitions: - $ref: '#/definitions/handler.OscalLikeEvidence' description: Items from the list response type: object + handler.GenericDataResponse-handler_UserHandler: + properties: + data: + allOf: + - $ref: '#/definitions/handler.UserHandler' + description: Items from the list response + type: object handler.GenericDataResponse-oscal_ImportResponse: properties: data: @@ -5557,6 +5618,10 @@ definitions: allOf: - $ref: '#/definitions/gorm.DeletedAt' description: Soft delete + digestSubscribed: + description: DigestSubscribed indicates if the user wants to receive evidence + digest emails + type: boolean email: type: string failedLogins: @@ -5588,6 +5653,57 @@ info: title: Continuous Compliance Framework API version: "1" paths: + /admin/digest/preview: + get: + description: Returns the current evidence summary that would be included in + a digest email + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-digest_EvidenceSummary' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Preview evidence digest + tags: + - Digest + /admin/digest/trigger: + post: + description: Manually triggers the evidence digest job to send emails to all + users + parameters: + - description: 'Job name to trigger (default: global-evidence-digest)' + in: query + name: job + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Trigger evidence digest + tags: + - Digest /admin/users: get: description: Lists all users in the system @@ -16246,6 +16362,72 @@ paths: summary: Change password for logged-in user tags: - Users + /users/me/digest-subscription: + get: + description: Gets the current user's digest email subscription status + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_UserHandler' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Get digest subscription status + tags: + - Users + put: + consumes: + - application/json + description: Updates the current user's digest email subscription status + parameters: + - description: Subscription status + in: body + name: subscription + required: true + schema: + $ref: '#/definitions/handler.UserHandler' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_UserHandler' + "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: Update digest subscription status + tags: + - Users produces: - application/json securityDefinitions: diff --git a/go.mod b/go.mod index 2a747824..5c7f06d0 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/labstack/echo-contrib v0.17.4 github.com/labstack/echo/v4 v4.13.4 github.com/prometheus/client_golang v1.23.2 + github.com/robfig/cron/v3 v3.0.1 github.com/schollz/progressbar/v3 v3.18.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 diff --git a/go.sum b/go.sum index 73bc0e7f..0195fffe 100644 --- a/go.sum +++ b/go.sum @@ -379,6 +379,8 @@ github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4 github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= diff --git a/internal/api/handler/api.go b/internal/api/handler/api.go index 2a3e78c4..a65675f9 100644 --- a/internal/api/handler/api.go +++ b/internal/api/handler/api.go @@ -4,11 +4,13 @@ import ( "github.com/compliance-framework/api/internal/api" "github.com/compliance-framework/api/internal/api/middleware" "github.com/compliance-framework/api/internal/config" + "github.com/compliance-framework/api/internal/service/digest" + "github.com/compliance-framework/api/internal/service/scheduler" "go.uber.org/zap" "gorm.io/gorm" ) -func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB, config *config.Config) { +func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB, config *config.Config, digestService *digest.Service, sched scheduler.Scheduler) { healthHandler := NewHealthHandler(logger, db) healthHandler.Register(server.API().Group("/health")) @@ -18,7 +20,7 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB heartbeatHandler := NewHeartbeatHandler(logger, db) heartbeatHandler.Register(server.API().Group("/agent/heartbeat")) - evidenceHandler := NewEvidenceHandler(logger, db) + evidenceHandler := NewEvidenceHandler(logger, db, config) evidenceHandler.Register(server.API().Group("/evidence")) userHandler := NewUserHandler(logger, db) @@ -32,4 +34,12 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB userGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) userHandler.RegisterSelfRoutes(userGroup) + // Digest handler (admin only) + if digestService != nil && sched != nil { + digestHandler := NewDigestHandler(digestService, sched, logger) + digestGroup := server.API().Group("/admin/digest") + digestGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) + digestGroup.Use(middleware.RequireAdminGroups(db, config, logger)) + digestHandler.Register(digestGroup) + } } diff --git a/internal/api/handler/digest.go b/internal/api/handler/digest.go new file mode 100644 index 00000000..bce83bda --- /dev/null +++ b/internal/api/handler/digest.go @@ -0,0 +1,87 @@ +package handler + +import ( + "fmt" + "net/http" + + "github.com/compliance-framework/api/internal/api" + "github.com/compliance-framework/api/internal/service/digest" + "github.com/compliance-framework/api/internal/service/scheduler" + "github.com/labstack/echo/v4" + "go.uber.org/zap" +) + +// DigestHandler handles digest-related API endpoints +type DigestHandler struct { + digestService *digest.Service + scheduler scheduler.Scheduler + logger *zap.SugaredLogger +} + +// NewDigestHandler creates a new digest handler +func NewDigestHandler(digestService *digest.Service, sched scheduler.Scheduler, logger *zap.SugaredLogger) *DigestHandler { + return &DigestHandler{ + digestService: digestService, + scheduler: sched, + logger: logger, + } +} + +// Register registers the digest endpoints +func (h *DigestHandler) Register(api *echo.Group) { + api.POST("/trigger", h.TriggerDigest) + api.GET("/preview", h.PreviewDigest) +} + +// TriggerDigest godoc +// +// @Summary Trigger evidence digest +// @Description Manually triggers the evidence digest job to send emails to all users +// @Tags Digest +// @Produce json +// @Param job query string false "Job name to trigger (default: global-evidence-digest)" +// @Success 200 {object} map[string]string +// @Failure 400 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/digest/trigger [post] +func (h *DigestHandler) TriggerDigest(ctx echo.Context) error { + jobName := ctx.QueryParam("job") + if jobName == "" { + jobName = "global-evidence-digest" + } + + if h.scheduler == nil { + return ctx.JSON(http.StatusInternalServerError, api.NewError(fmt.Errorf("scheduler is not available"))) + } + + if err := h.scheduler.RunNow(ctx.Request().Context(), jobName); err != nil { + h.logger.Errorw("Failed to trigger digest job", "job", jobName, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.JSON(http.StatusOK, map[string]string{ + "message": "Digest job triggered successfully", + "job": jobName, + }) +} + +// PreviewDigest godoc +// +// @Summary Preview evidence digest +// @Description Returns the current evidence summary that would be included in a digest email +// @Tags Digest +// @Produce json +// @Success 200 {object} GenericDataResponse[digest.EvidenceSummary] +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/digest/preview [get] +func (h *DigestHandler) PreviewDigest(ctx echo.Context) error { + summary, err := h.digestService.GetGlobalEvidenceSummary(ctx.Request().Context()) + if err != nil { + h.logger.Errorw("Failed to get evidence summary", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.JSON(http.StatusOK, GenericDataResponse[*digest.EvidenceSummary]{Data: summary}) +} diff --git a/internal/api/handler/digest_integration_test.go b/internal/api/handler/digest_integration_test.go new file mode 100644 index 00000000..824486ed --- /dev/null +++ b/internal/api/handler/digest_integration_test.go @@ -0,0 +1,224 @@ +//go:build integration + +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http/httptest" + "testing" + + "github.com/compliance-framework/api/internal/api" + "github.com/compliance-framework/api/internal/service/digest" + "github.com/compliance-framework/api/internal/service/email" + "github.com/compliance-framework/api/internal/service/scheduler" + "github.com/compliance-framework/api/internal/tests" + "github.com/stretchr/testify/suite" + "go.uber.org/zap" +) + +func TestDigestApi(t *testing.T) { + suite.Run(t, new(DigestApiIntegrationSuite)) +} + +type DigestApiIntegrationSuite struct { + tests.IntegrationTestSuite + server *api.Server + logger *zap.SugaredLogger + digestHandler *DigestHandler + mockScheduler *MockScheduler + emailService *email.Service +} + +// MockScheduler implements the scheduler.Service interface for testing +type MockScheduler struct { + jobs map[string]bool +} + +func NewMockScheduler() *MockScheduler { + return &MockScheduler{ + jobs: make(map[string]bool), + } +} + +func (m *MockScheduler) Start() { + // Mock implementation +} + +func (m *MockScheduler) Stop() context.Context { + // Mock implementation + return context.Background() +} + +func (m *MockScheduler) Schedule(schedule scheduler.Schedule, job scheduler.Job) error { + m.jobs[job.Name()] = true + return nil +} + +func (m *MockScheduler) ScheduleCron(cronExpr string, job scheduler.Job) error { + m.jobs[job.Name()] = true + return nil +} + +func (m *MockScheduler) RunNow(ctx context.Context, name string) error { + if _, exists := m.jobs[name]; !exists { + return fmt.Errorf("job %q not found", name) + } + // Mock job execution + return nil +} + +func (m *MockScheduler) ListJobs() []string { + var jobs []string + for name := range m.jobs { + jobs = append(jobs, name) + } + return jobs +} + +func (suite *DigestApiIntegrationSuite) SetupSuite() { + suite.IntegrationTestSuite.SetupSuite() + + logger, _ := zap.NewDevelopment() + suite.logger = logger.Sugar() + + // Create email service + emailService, err := email.NewService(suite.Config.Email, suite.logger) + suite.Require().NoError(err, "Failed to create email service") + suite.emailService = emailService + + // Create mock scheduler + suite.mockScheduler = NewMockScheduler() + + // Create digest handler + digestService := digest.NewService(suite.DB, suite.emailService, suite.Config, suite.logger) + suite.digestHandler = NewDigestHandler(digestService, suite.mockScheduler, suite.logger) + + // Setup server + metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) + suite.server = api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) + + // Register handlers + RegisterHandlers(suite.server, suite.logger, suite.DB, suite.Config, digestService, suite.mockScheduler) +} + +func (suite *DigestApiIntegrationSuite) SetupTest() { + err := suite.Migrator.Refresh() + suite.Require().NoError(err) + + // Pre-register the default job in the mock scheduler + suite.mockScheduler.jobs["global-evidence-digest"] = true +} + +func (suite *DigestApiIntegrationSuite) TestTriggerDigest() { + token, err := suite.GetAuthToken() + suite.Require().NoError(err) + + suite.Run("TriggerDigestSuccess", func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/admin/digest/trigger", nil) + req.Header.Set("Authorization", "Bearer "+*token) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(200, rec.Code, "Expected OK response for TriggerDigest") + + var response map[string]string + err = json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal TriggerDigest response") + + suite.Equal("Digest job triggered successfully", response["message"]) + suite.Equal("global-evidence-digest", response["job"]) + }) + + suite.Run("TriggerDigestWithCustomJob", func() { + // Pre-register the custom job + suite.mockScheduler.jobs["custom-job"] = true + + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/admin/digest/trigger?job=custom-job", nil) + req.Header.Set("Authorization", "Bearer "+*token) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(200, rec.Code, "Expected OK response for TriggerDigest with custom job") + + var response map[string]string + err = json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal TriggerDigest response") + + suite.Equal("Digest job triggered successfully", response["message"]) + suite.Equal("custom-job", response["job"]) + }) + + suite.Run("TriggerDigestUnauthorized", func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/admin/digest/trigger", nil) + // No authorization header + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(401, rec.Code, "Expected Unauthorized response for missing token") + }) +} + +func (suite *DigestApiIntegrationSuite) TestPreviewDigest() { + token, err := suite.GetAuthToken() + suite.Require().NoError(err) + + suite.Run("PreviewDigestSuccess", func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/admin/digest/preview", nil) + req.Header.Set("Authorization", "Bearer "+*token) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(200, rec.Code, "Expected OK response for PreviewDigest") + + var response struct { + Data *digest.EvidenceSummary `json:"data"` + } + err = json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal PreviewDigest response") + + suite.NotNil(response.Data, "Expected evidence summary data") + }) + + suite.Run("PreviewDigestUnauthorized", func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/admin/digest/preview", nil) + // No authorization header + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(401, rec.Code, "Expected Unauthorized response for missing token") + }) +} + +func (suite *DigestApiIntegrationSuite) TestTriggerDigestWithNilScheduler() { + // Test with nil scheduler to verify error handling + token, err := suite.GetAuthToken() + suite.Require().NoError(err) + + // Create handler with nil scheduler + digestService := digest.NewService(suite.DB, suite.emailService, suite.Config, suite.logger) + nilSchedulerHandler := NewDigestHandler(digestService, nil, suite.logger) + + // Create a temporary echo context for testing + e := suite.server.E() + req := httptest.NewRequest("POST", "/api/admin/digest/trigger", nil) + req.Header.Set("Authorization", "Bearer "+*token) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + err = nilSchedulerHandler.TriggerDigest(c) + suite.NoError(err, "Expected no error from TriggerDigest with nil scheduler") + suite.Equal(500, rec.Code, "Expected Internal Server Error when scheduler is nil") + + var response api.Error + err = json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal error response") + + // Check if the error contains our expected message + for _, errMsg := range response.Errors { + if msgStr, ok := errMsg.(string); ok { + suite.Contains(msgStr, "scheduler is not available") + } + } +} diff --git a/internal/api/handler/evidence.go b/internal/api/handler/evidence.go index 79fea6f2..ae4cb4de 100644 --- a/internal/api/handler/evidence.go +++ b/internal/api/handler/evidence.go @@ -7,6 +7,7 @@ import ( "github.com/compliance-framework/api/internal" "github.com/compliance-framework/api/internal/api" + "github.com/compliance-framework/api/internal/config" "github.com/compliance-framework/api/internal/converters/labelfilter" "github.com/compliance-framework/api/internal/service/relational" oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" @@ -19,14 +20,16 @@ import ( ) type EvidenceHandler struct { - db *gorm.DB - sugar *zap.SugaredLogger + db *gorm.DB + sugar *zap.SugaredLogger + config *config.Config } -func NewEvidenceHandler(sugar *zap.SugaredLogger, db *gorm.DB) *EvidenceHandler { +func NewEvidenceHandler(sugar *zap.SugaredLogger, db *gorm.DB, cfg *config.Config) *EvidenceHandler { return &EvidenceHandler{ - sugar: sugar, - db: db, + sugar: sugar, + db: db, + config: cfg, } } @@ -323,6 +326,24 @@ func (h *EvidenceHandler) Create(ctx echo.Context) error { backMatter.UnmarshalOscal(*input.BackMatter) } + // Auto-set expiration if not provided + expires := input.Expires + if expires == nil { + // Use End date if available, otherwise use current time + baseDate := input.End + if baseDate.IsZero() { + baseDate = time.Now().UTC() + } + // Add configured months to base date + expiryDate := baseDate.AddDate(0, h.config.EvidenceDefaultExpiryMonths, 0) + expires = &expiryDate + h.sugar.Debugw("Auto-set evidence expiration", + "uuid", input.UUID, + "baseDate", baseDate, + "expiryMonths", h.config.EvidenceDefaultExpiryMonths, + "expiresAt", expiryDate) + } + evidence := relational.Evidence{ UUIDModel: relational.UUIDModel{ ID: internal.Pointer(uuid.New()), @@ -333,7 +354,7 @@ func (h *EvidenceHandler) Create(ctx echo.Context) error { Remarks: input.Remarks, Start: input.Start, End: input.End, - Expires: input.Expires, + Expires: expires, Props: relational.ConvertOscalToProps(&input.Props), Links: relational.ConvertOscalToLinks(&input.Links), Origins: relational.ConvertList(&input.Origins, func(ol oscalTypes_1_1_3.Origin) relational.Origin { diff --git a/internal/api/handler/evidence_integration_test.go b/internal/api/handler/evidence_integration_test.go index 8ed20f35..9ac3a47d 100644 --- a/internal/api/handler/evidence_integration_test.go +++ b/internal/api/handler/evidence_integration_test.go @@ -158,7 +158,7 @@ func (suite *EvidenceApiIntegrationSuite) TestCreate() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(evidence) req := httptest.NewRequest(http.MethodPost, "/api/evidence", bytes.NewReader(reqBody)) @@ -211,7 +211,7 @@ func (suite *EvidenceApiIntegrationSuite) TestSearch() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(struct { Filter labelfilter.Filter @@ -264,7 +264,7 @@ func (suite *EvidenceApiIntegrationSuite) TestSearch() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(struct { Filter labelfilter.Filter @@ -317,7 +317,7 @@ func (suite *EvidenceApiIntegrationSuite) TestSearch() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() var reqBody, _ = json.Marshal(struct { Filter labelfilter.Filter @@ -381,7 +381,7 @@ func (suite *EvidenceApiIntegrationSuite) TestSearch() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() var reqBody, _ = json.Marshal(struct { Filter labelfilter.Filter @@ -453,7 +453,7 @@ func (suite *EvidenceApiIntegrationSuite) TestSearch() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() var reqBody, _ = json.Marshal(struct { Filter labelfilter.Filter @@ -552,7 +552,7 @@ func (suite *EvidenceApiIntegrationSuite) TestStatusOverTime() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(struct { Filter labelfilter.Filter @@ -673,7 +673,7 @@ func (suite *EvidenceApiIntegrationSuite) TestComplianceByFilter() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/evidence/compliance-by-filter/%s", filter.ID), nil) server.E().ServeHTTP(rec, req) @@ -704,7 +704,7 @@ func (suite *EvidenceApiIntegrationSuite) TestComplianceByFilter() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/evidence/compliance-by-filter/%s", uuid.New()), nil) server.E().ServeHTTP(rec, req) @@ -718,7 +718,7 @@ func (suite *EvidenceApiIntegrationSuite) TestComplianceByFilter() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/evidence/compliance-by-filter/invalid-uuid", nil) server.E().ServeHTTP(rec, req) diff --git a/internal/api/handler/evidence_test.go b/internal/api/handler/evidence_test.go index 95da6ce5..0e5ed7bb 100644 --- a/internal/api/handler/evidence_test.go +++ b/internal/api/handler/evidence_test.go @@ -25,7 +25,7 @@ func TestEvidenceHandler_Create_WithFutureDate_ReturnsError(t *testing.T) { req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) rec := httptest.NewRecorder() ctx := e.NewContext(req, rec) - h := NewEvidenceHandler(nil, nil) + h := NewEvidenceHandler(nil, nil, nil) // Assertions if assert.NoError(t, h.Create(ctx)) { diff --git a/internal/api/handler/filter_integration_test.go b/internal/api/handler/filter_integration_test.go index 3b7bb297..b6b523a6 100644 --- a/internal/api/handler/filter_integration_test.go +++ b/internal/api/handler/filter_integration_test.go @@ -52,7 +52,7 @@ func (suite *FilterApiIntegrationSuite) TestCreate() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(createReq) req := httptest.NewRequest(http.MethodPost, "/api/filters", bytes.NewReader(reqBody)) @@ -96,7 +96,7 @@ func (suite *FilterApiIntegrationSuite) TestCreate() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(createReq) req := httptest.NewRequest(http.MethodPost, "/api/filters", bytes.NewReader(reqBody)) @@ -137,7 +137,7 @@ func (suite *FilterApiIntegrationSuite) TestCreate() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(createReq) req := httptest.NewRequest(http.MethodPost, "/api/filters", bytes.NewReader(reqBody)) @@ -166,7 +166,7 @@ func (suite *FilterApiIntegrationSuite) TestList() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) // Create filter linked to AC-1 withControlReq := createFilterRequest{ @@ -244,7 +244,7 @@ func (suite *FilterApiIntegrationSuite) TestList() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) // Create filter linked to our system component withComponentReq := createFilterRequest{ @@ -341,7 +341,7 @@ func (suite *FilterApiIntegrationSuite) TestUpdate() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(updateReq) req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/filters/%s", filter.ID), bytes.NewReader(reqBody)) @@ -399,7 +399,7 @@ func (suite *FilterApiIntegrationSuite) TestUpdate() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(updateReq) req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/filters/%s", filter.ID), bytes.NewReader(reqBody)) @@ -476,7 +476,7 @@ func (suite *FilterApiIntegrationSuite) TestUpdate() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(updateReq) req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/filters/%s", filter.ID), bytes.NewReader(reqBody)) @@ -551,7 +551,7 @@ func (suite *FilterApiIntegrationSuite) TestUpdate() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(updateReq) req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/filters/%s", filter.ID), bytes.NewReader(reqBody)) diff --git a/internal/api/handler/heartbeat_integration_test.go b/internal/api/handler/heartbeat_integration_test.go index 70f78cab..fa95c54e 100644 --- a/internal/api/handler/heartbeat_integration_test.go +++ b/internal/api/handler/heartbeat_integration_test.go @@ -40,7 +40,7 @@ func (suite *HeartbeatApiIntegrationSuite) TestHeartbeatCreateValidation() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(heartbeat) req := httptest.NewRequest(http.MethodPost, "/api/agent/heartbeat", bytes.NewReader(reqBody)) @@ -62,7 +62,7 @@ func (suite *HeartbeatApiIntegrationSuite) TestHeartbeatCreate() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(heartbeat) req := httptest.NewRequest(http.MethodPost, "/api/agent/heartbeat", bytes.NewReader(reqBody)) @@ -95,7 +95,7 @@ func (suite *HeartbeatApiIntegrationSuite) TestHeartbeatOverTime() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/agent/heartbeat/over-time/", nil) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) diff --git a/internal/api/handler/oscal/inventory_integration_test.go b/internal/api/handler/oscal/inventory_integration_test.go index 9b9ab0ec..4803e69f 100644 --- a/internal/api/handler/oscal/inventory_integration_test.go +++ b/internal/api/handler/oscal/inventory_integration_test.go @@ -41,7 +41,7 @@ func (suite *InventoryApiIntegrationSuite) SetupSuite() { suite.handler = NewInventoryHandler(logger, suite.DB) suite.sspHandler = NewSystemSecurityPlanHandler(logger, suite.DB) suite.poamHandler = NewPlanOfActionAndMilestonesHandler(logger, suite.DB) - suite.evidenceHandler = handler.NewEvidenceHandler(logger, suite.DB) + suite.evidenceHandler = handler.NewEvidenceHandler(logger, suite.DB, suite.Config) // Initialize server metrics := api.NewMetricsHandler(context.Background(), logger) diff --git a/internal/api/handler/users.go b/internal/api/handler/users.go index f8725c2c..315afb9c 100644 --- a/internal/api/handler/users.go +++ b/internal/api/handler/users.go @@ -42,6 +42,8 @@ func (h *UserHandler) Register(api *echo.Group) { func (h *UserHandler) RegisterSelfRoutes(api *echo.Group) { api.GET("/me", h.GetMe) api.POST("/me/change-password", h.ChangeLoggedInUserPassword) + api.GET("/me/digest-subscription", h.GetDigestSubscription) + api.PUT("/me/digest-subscription", h.UpdateDigestSubscription) } // ListUsers godoc @@ -393,6 +395,94 @@ func (h *UserHandler) ChangeLoggedInUserPassword(ctx echo.Context) error { return ctx.NoContent(204) } +// GetDigestSubscription godoc +// +// @Summary Get digest subscription status +// @Description Gets the current user's digest email subscription status +// @Tags Users +// @Produce json +// @Success 200 {object} handler.GenericDataResponse[handler.UserHandler.GetDigestSubscription.digestSubscriptionResponse] +// @Failure 401 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /users/me/digest-subscription [get] +func (h *UserHandler) GetDigestSubscription(ctx echo.Context) error { + type digestSubscriptionResponse struct { + Subscribed bool `json:"subscribed"` + } + + userClaims := ctx.Get("user").(*authn.UserClaims) + + email := userClaims.Subject + var user relational.User + if err := h.db.Where("email = ?", email).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(404, api.NewError(err)) + } + h.sugar.Errorw("Failed to get user by email", "error", err) + return ctx.JSON(500, api.NewError(err)) + } + + return ctx.JSON(200, GenericDataResponse[digestSubscriptionResponse]{ + Data: digestSubscriptionResponse{Subscribed: user.DigestSubscribed}, + }) +} + +// UpdateDigestSubscription godoc +// +// @Summary Update digest subscription status +// @Description Updates the current user's digest email subscription status +// @Tags Users +// @Accept json +// @Produce json +// @Param subscription body handler.UserHandler.UpdateDigestSubscription.updateDigestSubscriptionRequest true "Subscription status" +// @Success 200 {object} handler.GenericDataResponse[handler.UserHandler.UpdateDigestSubscription.digestSubscriptionResponse] +// @Failure 400 {object} api.Error +// @Failure 401 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /users/me/digest-subscription [put] +func (h *UserHandler) UpdateDigestSubscription(ctx echo.Context) error { + type updateDigestSubscriptionRequest struct { + Subscribed bool `json:"subscribed"` + } + type digestSubscriptionResponse struct { + Subscribed bool `json:"subscribed"` + } + + userClaims := ctx.Get("user").(*authn.UserClaims) + + var req updateDigestSubscriptionRequest + if err := ctx.Bind(&req); err != nil { + h.sugar.Errorw("Failed to bind update digest subscription request", "error", err) + return ctx.JSON(400, api.NewError(err)) + } + + email := userClaims.Subject + var user relational.User + if err := h.db.Where("email = ?", email).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(404, api.NewError(err)) + } + h.sugar.Errorw("Failed to get user by email", "error", err) + return ctx.JSON(500, api.NewError(err)) + } + + user.DigestSubscribed = req.Subscribed + if err := h.db.Save(&user).Error; err != nil { + h.sugar.Errorw("Failed to update user digest subscription", "error", err) + return ctx.JSON(500, api.NewError(err)) + } + + h.sugar.Debugw("User digest subscription updated", "email", email, "subscribed", req.Subscribed) + + return ctx.JSON(200, GenericDataResponse[digestSubscriptionResponse]{ + Data: digestSubscriptionResponse{Subscribed: user.DigestSubscribed}, + }) +} + // ChangePassword godoc // // @Summary Change password for a specific user diff --git a/internal/api/handler/users_integration_test.go b/internal/api/handler/users_integration_test.go index 8d0c1013..b5b435a9 100644 --- a/internal/api/handler/users_integration_test.go +++ b/internal/api/handler/users_integration_test.go @@ -33,7 +33,7 @@ func (suite *UserApiIntegrationSuite) SetupSuite() { 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) + RegisterHandlers(suite.server, suite.logger, suite.DB, suite.Config, nil, nil) } func (suite *UserApiIntegrationSuite) SetupTest() { @@ -384,3 +384,86 @@ func (suite *UserApiIntegrationSuite) TestChangePassword() { suite.True(updatedUser.CheckPassword("NewPa55w0rd"), "Expected password to be updated successfully") }) } + +func (suite *UserApiIntegrationSuite) TestDigestSubscription() { + token, err := suite.GetAuthToken() + suite.Require().NoError(err) + + suite.Run("GetDigestSubscription", func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/users/me/digest-subscription", nil) + req.Header.Set("Authorization", "Bearer "+*token) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(200, rec.Code, "Expected OK response for GetDigestSubscription") + + var response struct { + Data struct { + Subscribed bool `json:"subscribed"` + } `json:"data"` + } + err = json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal GetDigestSubscription response") + + // The default should be false for new users + suite.False(response.Data.Subscribed, "Expected default digest subscription to be false") + }) + + suite.Run("UpdateDigestSubscription", func() { + // Test subscribing to digest + payload := map[string]bool{"subscribed": true} + payloadJSON, err := json.Marshal(payload) + suite.Require().NoError(err, "Failed to marshal update digest subscription request") + + rec := httptest.NewRecorder() + req := httptest.NewRequest("PUT", "/api/users/me/digest-subscription", bytes.NewReader(payloadJSON)) + req.Header.Set("Authorization", "Bearer "+*token) + req.Header.Set("Content-Type", "application/json") + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(200, rec.Code, "Expected OK response for UpdateDigestSubscription") + + var response struct { + Data struct { + Subscribed bool `json:"subscribed"` + } `json:"data"` + } + err = json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal UpdateDigestSubscription response") + + suite.True(response.Data.Subscribed, "Expected digest subscription to be updated to true") + + // Test unsubscribing from digest + payload = map[string]bool{"subscribed": false} + payloadJSON, err = json.Marshal(payload) + suite.Require().NoError(err, "Failed to marshal unsubscribe digest request") + + rec = httptest.NewRecorder() + req = httptest.NewRequest("PUT", "/api/users/me/digest-subscription", bytes.NewReader(payloadJSON)) + req.Header.Set("Authorization", "Bearer "+*token) + req.Header.Set("Content-Type", "application/json") + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(200, rec.Code, "Expected OK response for unsubscribe digest") + + err = json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal unsubscribe digest response") + + suite.False(response.Data.Subscribed, "Expected digest subscription to be updated to false") + }) + + suite.Run("UpdateDigestSubscriptionInvalidPayload", func() { + // Test with invalid payload + payload := map[string]string{"subscribed": "invalid"} + payloadJSON, err := json.Marshal(payload) + suite.Require().NoError(err, "Failed to marshal invalid digest subscription request") + + rec := httptest.NewRecorder() + req := httptest.NewRequest("PUT", "/api/users/me/digest-subscription", bytes.NewReader(payloadJSON)) + req.Header.Set("Authorization", "Bearer "+*token) + req.Header.Set("Content-Type", "application/json") + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(400, rec.Code, "Expected Bad Request response for invalid payload") + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index 857844c0..64557bfa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,20 +19,23 @@ var ( ) type Config struct { - AppPort string - Environment string - DBDriver string - DBConnectionString string - DBDebug bool - JWTSecret string - JWTPrivateKey *rsa.PrivateKey - JWTPublicKey *rsa.PublicKey - APIAllowedOrigins []string - MetricsEnabled bool - MetricsPort string - WebBaseURL string - SSO *SSOConfig - Email *EmailConfig + AppPort string + Environment string + DBDriver string + DBConnectionString string + DBDebug bool + JWTSecret string + JWTPrivateKey *rsa.PrivateKey + JWTPublicKey *rsa.PublicKey + APIAllowedOrigins []string + MetricsEnabled bool + MetricsPort string + WebBaseURL string + SSO *SSOConfig + Email *EmailConfig + EvidenceDefaultExpiryMonths int // Default expiration in months for evidence without explicit expiry + DigestEnabled bool // Enable or disable the digest scheduler + DigestSchedule string // Cron schedule for digest emails } func NewConfig(logger *zap.SugaredLogger) *Config { @@ -146,21 +149,37 @@ func NewConfig(logger *zap.SugaredLogger) *Config { emailConfig = &EmailConfig{Enabled: false} } + // Evidence default expiry in months (default: 1 month) + evidenceDefaultExpiryMonths := viper.GetInt("evidence_default_expiry_months") + if evidenceDefaultExpiryMonths <= 0 { + evidenceDefaultExpiryMonths = 1 + } + + // Digest configuration + digestEnabled := viper.GetBool("digest_enabled") + digestSchedule := viper.GetString("digest_schedule") + if digestSchedule == "" { + digestSchedule = "@weekly" + } + return &Config{ - AppPort: appPort, - Environment: environment, - DBDriver: dbDriver, - DBConnectionString: stripQuotes(viper.GetString("db_connection")), - DBDebug: viper.GetBool("db_debug"), - JWTSecret: stripQuotes(viper.GetString("jwt_secret")), - JWTPrivateKey: jwtPrivateKey, - JWTPublicKey: jwtPublicKey, - APIAllowedOrigins: allowedOrigins, - MetricsEnabled: metricsEnabled, - MetricsPort: metricsPort, - WebBaseURL: webBaseURL, - SSO: ssoConfig, - Email: emailConfig, + AppPort: appPort, + Environment: environment, + DBDriver: dbDriver, + DBConnectionString: stripQuotes(viper.GetString("db_connection")), + DBDebug: viper.GetBool("db_debug"), + JWTSecret: stripQuotes(viper.GetString("jwt_secret")), + JWTPrivateKey: jwtPrivateKey, + JWTPublicKey: jwtPublicKey, + APIAllowedOrigins: allowedOrigins, + MetricsEnabled: metricsEnabled, + MetricsPort: metricsPort, + WebBaseURL: webBaseURL, + SSO: ssoConfig, + Email: emailConfig, + EvidenceDefaultExpiryMonths: evidenceDefaultExpiryMonths, + DigestEnabled: digestEnabled, + DigestSchedule: digestSchedule, } } diff --git a/internal/service/digest/job.go b/internal/service/digest/job.go new file mode 100644 index 00000000..67db3883 --- /dev/null +++ b/internal/service/digest/job.go @@ -0,0 +1,32 @@ +package digest + +import ( + "context" + + "go.uber.org/zap" +) + +// GlobalDigestJob is a scheduled job that sends global evidence digests +type GlobalDigestJob struct { + service *Service + logger *zap.SugaredLogger +} + +// NewGlobalDigestJob creates a new global digest job +func NewGlobalDigestJob(service *Service, logger *zap.SugaredLogger) *GlobalDigestJob { + return &GlobalDigestJob{ + service: service, + logger: logger, + } +} + +// Name returns the unique name of the job +func (j *GlobalDigestJob) Name() string { + return "global-evidence-digest" +} + +// Execute runs the digest job +func (j *GlobalDigestJob) Execute(ctx context.Context) error { + j.logger.Debug("Executing global evidence digest job") + return j.service.SendGlobalDigest(ctx) +} diff --git a/internal/service/digest/service.go b/internal/service/digest/service.go new file mode 100644 index 00000000..5a1b5c18 --- /dev/null +++ b/internal/service/digest/service.go @@ -0,0 +1,274 @@ +package digest + +import ( + "context" + "fmt" + "time" + + "github.com/compliance-framework/api/internal/config" + "github.com/compliance-framework/api/internal/service/email" + "github.com/compliance-framework/api/internal/service/email/types" + "github.com/compliance-framework/api/internal/service/relational" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// EvidenceSummary contains aggregated evidence statistics +type EvidenceSummary struct { + TotalCount int64 + SatisfiedCount int64 + NotSatisfiedCount int64 + ExpiredCount int64 + OtherCount int64 + + // Top items for the digest email + TopExpired []EvidenceItem + TopNotSatisfied []EvidenceItem +} + +// EvidenceItem represents a single evidence entry for display +type EvidenceItem struct { + ID string + UUID string + Title string + Description string + Status string + ExpiresAt string // Formatted expiration date string (empty if no expiration) + Labels []string +} + +// Service handles digest generation and delivery +type Service struct { + db *gorm.DB + emailService *email.Service + config *config.Config + logger *zap.SugaredLogger +} + +// NewService creates a new digest service +func NewService(db *gorm.DB, emailService *email.Service, cfg *config.Config, logger *zap.SugaredLogger) *Service { + return &Service{ + db: db, + emailService: emailService, + config: cfg, + logger: logger, + } +} + +// GetGlobalEvidenceSummary retrieves evidence summary across all evidence (Phase 0) +func (s *Service) GetGlobalEvidenceSummary(ctx context.Context) (*EvidenceSummary, error) { + summary := &EvidenceSummary{} + + // Get latest evidence streams once using CTE to avoid recomputing the subquery multiple times + now := time.Now() + zeroTime := time.Unix(0, 0) + + // Create a single CTE query for latest evidence streams with all aggregations + summaryQuery := s.db.Raw(` + WITH latest_evidence AS ( + SELECT DISTINCT ON (uuid) * + FROM evidences + ORDER BY uuid, evidences.end DESC + ) + SELECT + COUNT(*) as total_count, + COUNT(CASE WHEN status->>'state' = 'satisfied' THEN 1 END) as satisfied_count, + COUNT(CASE WHEN status->>'state' = 'not-satisfied' THEN 1 END) as not_satisfied_count, + COUNT(CASE WHEN status->>'state' NOT IN ('satisfied', 'not-satisfied') THEN 1 END) as other_count, + COUNT(CASE WHEN expires IS NOT NULL AND expires > ? AND expires <= ? THEN 1 END) as expired_count + FROM latest_evidence + `, zeroTime, now) + + var result struct { + TotalCount int64 + SatisfiedCount int64 + NotSatisfiedCount int64 + OtherCount int64 + ExpiredCount int64 + } + + if err := summaryQuery.Scan(&result).Error; err != nil { + return nil, fmt.Errorf("failed to get evidence summary: %w", err) + } + + summary.TotalCount = result.TotalCount + summary.SatisfiedCount = result.SatisfiedCount + summary.NotSatisfiedCount = result.NotSatisfiedCount + summary.ExpiredCount = result.ExpiredCount + summary.OtherCount = result.OtherCount + + // Get top 5 expired evidence items (only those with explicit expiration dates, excluding zero time) + var expiredEvidence []relational.Evidence + expiredItemsQuery := s.db.Session(&gorm.Session{}) + expiredItemsQuery = relational.GetLatestEvidenceStreamsQuery(expiredItemsQuery) + if err := expiredItemsQuery. + Where("expires IS NOT NULL AND expires > ? AND expires <= ?", zeroTime, now). + Preload("Labels"). + Order("expires ASC"). + Limit(5). + Find(&expiredEvidence).Error; err != nil { + s.logger.Warnw("Failed to fetch top expired evidence", "error", err) + } else { + summary.TopExpired = s.convertToEvidenceItems(expiredEvidence) + } + + // Get top 5 not-satisfied evidence items + var notSatisfiedEvidence []relational.Evidence + if err := s.db.Table("(?) as latest", relational.GetLatestEvidenceStreamsQuery(s.db)). + Where("status->>'state' = ?", "not-satisfied"). + Preload("Labels"). + Order("latest.end DESC"). + Limit(5). + Find(¬SatisfiedEvidence).Error; err != nil { + s.logger.Warnw("Failed to fetch top not-satisfied evidence", "error", err) + } else { + summary.TopNotSatisfied = s.convertToEvidenceItems(notSatisfiedEvidence) + } + + return summary, nil +} + +func (s *Service) convertToEvidenceItems(evidences []relational.Evidence) []EvidenceItem { + items := make([]EvidenceItem, 0, len(evidences)) + for _, e := range evidences { + labels := make([]string, 0, len(e.Labels)) + for _, l := range e.Labels { + labels = append(labels, fmt.Sprintf("%s:%s", l.Name, l.Value)) + } + + status := "" + if statusData := e.Status.Data(); statusData.State != "" { + status = statusData.State + } + + // Format expiration date for display + expiresAt := "" + if e.Expires != nil && !e.Expires.IsZero() { + expiresAt = e.Expires.Format("2006-01-02 15:04 MST") + } + + items = append(items, EvidenceItem{ + ID: e.ID.String(), + UUID: e.UUID.String(), + Title: e.Title, + Description: e.Description, + Status: status, + ExpiresAt: expiresAt, + Labels: labels, + }) + } + return items +} + +// GetSubscribedUsers returns all active users who are subscribed to digest emails +func (s *Service) GetSubscribedUsers(ctx context.Context) ([]relational.User, error) { + var users []relational.User + if err := s.db.Where("is_active = ? AND is_locked = ? AND digest_subscribed = ?", true, false, true).Find(&users).Error; err != nil { + return nil, fmt.Errorf("failed to fetch subscribed users: %w", err) + } + return users, nil +} + +// GetAllActiveUsers returns all active users (for admin purposes) +func (s *Service) GetAllActiveUsers(ctx context.Context) ([]relational.User, error) { + var users []relational.User + if err := s.db.Where("is_active = ? AND is_locked = ?", true, false).Find(&users).Error; err != nil { + return nil, fmt.Errorf("failed to fetch active users: %w", err) + } + return users, nil +} + +// SendDigestEmail sends a digest email to a user +func (s *Service) SendDigestEmail(ctx context.Context, user *relational.User, summary *EvidenceSummary) error { + if s.emailService == nil || !s.emailService.IsEnabled() { + return fmt.Errorf("email service is not enabled") + } + + // Prepare template data + data := map[string]interface{}{ + "UserName": user.FirstName, + "TotalCount": summary.TotalCount, + "SatisfiedCount": summary.SatisfiedCount, + "NotSatisfiedCount": summary.NotSatisfiedCount, + "ExpiredCount": summary.ExpiredCount, + "TopExpired": summary.TopExpired, + "TopNotSatisfied": summary.TopNotSatisfied, + "WebBaseURL": s.config.WebBaseURL, + "GeneratedAt": time.Now().UTC().Format(time.RFC1123), + } + + htmlContent, textContent, err := s.emailService.UseTemplate("evidence-digest", data) + if err != nil { + return fmt.Errorf("failed to render digest template: %w", err) + } + + message := &types.Message{ + To: []string{user.Email}, + Subject: "Evidence Compliance Digest", + HTMLBody: htmlContent, + TextBody: textContent, + } + + result, err := s.emailService.Send(ctx, message) + if err != nil { + return fmt.Errorf("failed to send digest email: %w", err) + } + + if !result.Success { + return fmt.Errorf("digest email send failed: %s", result.Error) + } + + s.logger.Debugw("Digest email sent", "user", user.Email, "messageId", result.MessageID) + return nil +} + +// SendGlobalDigest sends the global digest to all active users (Phase 0) +func (s *Service) SendGlobalDigest(ctx context.Context) error { + summary, err := s.GetGlobalEvidenceSummary(ctx) + if err != nil { + return fmt.Errorf("failed to get evidence summary: %w", err) + } + + // Skip if there's nothing to report + if summary.TotalCount == 0 { + s.logger.Debug("No evidence found, skipping digest") + return nil + } + + // Skip if there are no issues to report + if summary.NotSatisfiedCount == 0 && summary.ExpiredCount == 0 { + s.logger.Debug("No issues found (no expired or not-satisfied evidence), skipping digest") + return nil + } + + users, err := s.GetSubscribedUsers(ctx) + if err != nil { + return fmt.Errorf("failed to get subscribed users: %w", err) + } + + if len(users) == 0 { + s.logger.Debug("No subscribed users found, skipping digest") + return nil + } + + s.logger.Debugw("Sending global digest", + "totalEvidence", summary.TotalCount, + "notSatisfied", summary.NotSatisfiedCount, + "expired", summary.ExpiredCount, + "userCount", len(users), + ) + + var sendErrors []error + for _, user := range users { + if err := s.SendDigestEmail(ctx, &user, summary); err != nil { + s.logger.Errorw("Failed to send digest to user", "user", user.Email, "error", err) + sendErrors = append(sendErrors, err) + } + } + + if len(sendErrors) > 0 { + return fmt.Errorf("failed to send digest to %d users", len(sendErrors)) + } + + return nil +} diff --git a/internal/service/digest/service_test.go b/internal/service/digest/service_test.go new file mode 100644 index 00000000..604b2af4 --- /dev/null +++ b/internal/service/digest/service_test.go @@ -0,0 +1,60 @@ +package digest + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestConvertToEvidenceItems(t *testing.T) { + // Test the conversion logic without database dependencies + now := time.Now() + items := []EvidenceItem{ + { + ID: uuid.New().String(), + UUID: uuid.New().String(), + Title: "Test Evidence", + Description: "Test Description", + Status: "not-satisfied", + ExpiresAt: now.Format("2006-01-02 15:04 MST"), + Labels: []string{"provider:aws", "env:prod"}, + }, + } + + assert.Len(t, items, 1) + assert.Equal(t, "Test Evidence", items[0].Title) + assert.Equal(t, "not-satisfied", items[0].Status) + assert.Len(t, items[0].Labels, 2) + assert.NotEmpty(t, items[0].ExpiresAt) +} + +func TestEvidenceSummaryStructure(t *testing.T) { + summary := &EvidenceSummary{ + TotalCount: 100, + SatisfiedCount: 80, + NotSatisfiedCount: 15, + ExpiredCount: 5, + OtherCount: 0, + TopExpired: []EvidenceItem{ + {Title: "Expired 1"}, + {Title: "Expired 2"}, + }, + TopNotSatisfied: []EvidenceItem{ + {Title: "Failed 1"}, + }, + } + + assert.Equal(t, int64(100), summary.TotalCount) + assert.Equal(t, int64(80), summary.SatisfiedCount) + assert.Equal(t, int64(15), summary.NotSatisfiedCount) + assert.Equal(t, int64(5), summary.ExpiredCount) + assert.Len(t, summary.TopExpired, 2) + assert.Len(t, summary.TopNotSatisfied, 1) +} + +func TestGlobalDigestJobName(t *testing.T) { + job := &GlobalDigestJob{} + assert.Equal(t, "global-evidence-digest", job.Name()) +} diff --git a/internal/service/email/providers/ses.go b/internal/service/email/providers/ses.go index c0e0fb43..019f5eb7 100644 --- a/internal/service/email/providers/ses.go +++ b/internal/service/email/providers/ses.go @@ -63,7 +63,7 @@ func NewSESProvider(ctx context.Context, cfg *config.SESConfig, logger *zap.Suga return nil, fmt.Errorf("SES connection test failed: %w", err) } - logger.Infow("SES provider initialized", "region", cfg.Region, "from", cfg.From) + logger.Debugw("SES provider initialized", "region", cfg.Region, "from", cfg.From) return provider, nil } @@ -132,7 +132,7 @@ func (p *sesProvider) Send(ctx context.Context, message *emailtypes.Message) (*e messageId = *result.MessageId } - p.logger.Infow("Email sent successfully via SES", "to", message.To, "subject", message.Subject, "message_id", messageId) + p.logger.Debugw("Email sent successfully via SES", "to", message.To, "subject", message.Subject, "message_id", messageId) return &emailtypes.SendResult{ Success: true, MessageID: messageId, diff --git a/internal/service/email/providers/smtp.go b/internal/service/email/providers/smtp.go index 1b97e517..433b815e 100644 --- a/internal/service/email/providers/smtp.go +++ b/internal/service/email/providers/smtp.go @@ -49,7 +49,7 @@ func NewSMTPProvider(ctx context.Context, cfg *config.SMTPConfig, logger *zap.Su return nil, fmt.Errorf("SMTP connection test failed: %w", err) } - logger.Infow("SMTP provider initialized", "host", cfg.Host, "port", cfg.Port, "from", cfg.From) + logger.Debugw("SMTP provider initialized", "host", cfg.Host, "port", cfg.Port, "from", cfg.From) return provider, nil } @@ -91,7 +91,7 @@ func (p *smtpProvider) Send(ctx context.Context, message *types.Message) (*types }, err } - p.logger.Infow("Email sent successfully", "to", message.To, "subject", message.Subject) + p.logger.Debugw("Email sent successfully", "to", message.To, "subject", message.Subject) return &types.SendResult{ Success: true, }, nil diff --git a/internal/service/email/templates/templates/evidence-digest.html b/internal/service/email/templates/templates/evidence-digest.html new file mode 100644 index 00000000..d887a274 --- /dev/null +++ b/internal/service/email/templates/templates/evidence-digest.html @@ -0,0 +1,359 @@ + + + + + + Evidence Compliance Digest + + + +
+ +
+

šŸ“Š Evidence Digest

+

Compliance Framework

+
+ + +
+

Hello {{.UserName}},

+ +

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

+ + + + + + + + + + + +
+
{{.TotalCount}}
+
Total Evidence
+
+
{{.SatisfiedCount}}
+
Satisfied
+
+
{{.NotSatisfiedCount}}
+
Not Satisfied
+
+
{{.ExpiredCount}}
+
Expired
+
+ + {{if .TopNotSatisfied}} + +

āš ļø Not Satisfied Evidence

+ + {{end}} + + {{if .TopExpired}} + +

ā° Expired Evidence

+ + {{end}} + + {{if and (not .TopNotSatisfied) (not .TopExpired)}} +
+ āœ… All evidence is in good standing! +
+ {{end}} + + +
+ + View All Evidence + +
+
+ + + +
+ + diff --git a/internal/service/email/templates/templates/evidence-digest.txt b/internal/service/email/templates/templates/evidence-digest.txt new file mode 100644 index 00000000..e9d4a07d --- /dev/null +++ b/internal/service/email/templates/templates/evidence-digest.txt @@ -0,0 +1,47 @@ +Evidence Compliance Digest +========================== + +Hello {{.UserName}}, + +Here's your evidence compliance summary. This digest highlights evidence that requires your attention. + +SUMMARY +------- +Total Evidence: {{.TotalCount}} +Satisfied: {{.SatisfiedCount}} +Not Satisfied: {{.NotSatisfiedCount}} +Expired: {{.ExpiredCount}} + +{{if .TopNotSatisfied}} +NOT SATISFIED EVIDENCE +---------------------- +{{range .TopNotSatisfied}} +* {{.Title}} + {{if .Description}}Description: {{.Description}}{{end}} + Status: {{.Status}} + View: {{$.WebBaseURL}}/evidence/{{.ID}} + +{{end}} +{{end}} + +{{if .TopExpired}} +EXPIRED EVIDENCE +---------------- +{{range .TopExpired}} +* {{.Title}} + {{if .Description}}Description: {{.Description}}{{end}} + Expired: {{if .ExpiresAt}}{{.ExpiresAt}}{{else}}N/A{{end}} + View: {{$.WebBaseURL}}/evidence/{{.ID}} + +{{end}} +{{end}} + +{{if and (not .TopNotSatisfied) (not .TopExpired)}} +All evidence is in good standing! +{{end}} + +View all evidence: {{.WebBaseURL}}/evidence + +--- +Generated at {{.GeneratedAt}} +This is an automated digest from Compliance Framework. diff --git a/internal/service/relational/ccf_internal.go b/internal/service/relational/ccf_internal.go index 037480dc..b5cb1b48 100644 --- a/internal/service/relational/ccf_internal.go +++ b/internal/service/relational/ccf_internal.go @@ -27,6 +27,9 @@ type User struct { AuthMethod string `json:"authMethod"` UserAttributes string `json:"userAttributes"` + + // DigestSubscribed indicates if the user wants to receive evidence digest emails + DigestSubscribed bool `json:"digestSubscribed" gorm:"default:false"` } func (User) TableName() string { diff --git a/internal/service/scheduler/cron.go b/internal/service/scheduler/cron.go new file mode 100644 index 00000000..3de16e28 --- /dev/null +++ b/internal/service/scheduler/cron.go @@ -0,0 +1,124 @@ +package scheduler + +import ( + "context" + "fmt" + "sync" + + "github.com/robfig/cron/v3" + "go.uber.org/zap" +) + +// CronScheduler implements Scheduler using robfig/cron +type CronScheduler struct { + cron *cron.Cron + logger *zap.SugaredLogger + jobs map[string]Job + mu sync.RWMutex + ctx context.Context + cancel context.CancelFunc +} + +// NewCronScheduler creates a new cron-based scheduler +func NewCronScheduler(logger *zap.SugaredLogger) *CronScheduler { + ctx, cancel := context.WithCancel(context.Background()) + return &CronScheduler{ + cron: cron.New(cron.WithSeconds()), + logger: logger, + jobs: make(map[string]Job), + ctx: ctx, + cancel: cancel, + } +} + +// Schedule adds a job to run on the given schedule +func (s *CronScheduler) Schedule(schedule Schedule, job Job) error { + var cronExpr string + switch schedule { + case ScheduleDaily: + cronExpr = "0 0 0 * * *" // Every day at midnight + case ScheduleWeekly: + cronExpr = "0 0 0 * * 0" // Every Sunday at midnight + case ScheduleMonthly: + cronExpr = "0 0 0 1 * *" // First day of every month at midnight + default: + return fmt.Errorf("unknown schedule: %s", schedule) + } + return s.ScheduleCron(cronExpr, job) +} + +// ScheduleCron adds a job with a custom cron expression +// Cron format: second minute hour day month weekday (6 fields with seconds support) +// Examples: +// +// "0 0 * * * *" - Every hour at minute 0 +// "0 */5 * * * *" - Every 5 minutes +// "0 0 0 * * *" - Every day at midnight +// "@hourly" - Every hour (equivalent to "0 0 * * * *") +func (s *CronScheduler) ScheduleCron(cronExpr string, job Job) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.jobs[job.Name()]; exists { + return fmt.Errorf("job %q already registered", job.Name()) + } + + _, err := s.cron.AddFunc(cronExpr, func() { + s.logger.Debugw("Starting scheduled job", "job", job.Name()) + if err := job.Execute(s.ctx); err != nil { + s.logger.Errorw("Scheduled job failed", "job", job.Name(), "error", err) + } else { + s.logger.Debugw("Scheduled job completed", "job", job.Name()) + } + }) + if err != nil { + return fmt.Errorf("failed to schedule job %q: %w", job.Name(), err) + } + + s.jobs[job.Name()] = job + s.logger.Debugw("Job scheduled", "job", job.Name(), "cron", cronExpr) + return nil +} + +// Start begins processing scheduled jobs +func (s *CronScheduler) Start() { + s.logger.Debug("Starting scheduler") + s.cron.Start() +} + +// Stop gracefully stops the scheduler +func (s *CronScheduler) Stop() context.Context { + s.logger.Debug("Stopping scheduler") + + // Cancel the context to signal running jobs to stop + s.cancel() + + // Stop the cron scheduler + return s.cron.Stop() +} + +// RunNow executes a job immediately by name +func (s *CronScheduler) RunNow(ctx context.Context, jobName string) error { + s.mu.RLock() + job, exists := s.jobs[jobName] + s.mu.RUnlock() + + if !exists { + return fmt.Errorf("job %q not found", jobName) + } + + s.logger.Debugw("Running job manually", "job", jobName) + return job.Execute(ctx) +} + +// ListJobs returns all registered job names +func (s *CronScheduler) ListJobs() []string { + s.mu.RLock() + defer s.mu.RUnlock() + + names := make([]string, 0, len(s.jobs)) + for name := range s.jobs { + names = append(names, name) + } + return names +} diff --git a/internal/service/scheduler/scheduler.go b/internal/service/scheduler/scheduler.go new file mode 100644 index 00000000..8ea0f0bb --- /dev/null +++ b/internal/service/scheduler/scheduler.go @@ -0,0 +1,43 @@ +package scheduler + +import ( + "context" +) + +// Job represents a scheduled job that can be executed +type Job interface { + // Name returns the unique name of the job + Name() string + // Execute runs the job + Execute(ctx context.Context) error +} + +// Schedule represents when a job should run +type Schedule string + +const ( + // ScheduleDaily runs the job once per day at midnight UTC + ScheduleDaily Schedule = "@daily" + // ScheduleWeekly runs the job once per week on Sunday at midnight UTC + ScheduleWeekly Schedule = "@weekly" + // ScheduleMonthly runs the job once per month on the 1st at midnight UTC + ScheduleMonthly Schedule = "@monthly" +) + +// Scheduler is the interface for scheduling and managing jobs +// This abstraction allows swapping the underlying scheduler implementation +// (e.g., from built-in cron to an external scheduler like Temporal or a message queue) +type Scheduler interface { + // Schedule adds a job to run on the given schedule + Schedule(schedule Schedule, job Job) error + // ScheduleCron adds a job with a custom cron expression + ScheduleCron(cronExpr string, job Job) error + // Start begins processing scheduled jobs + Start() + // Stop gracefully stops the scheduler + Stop() context.Context + // RunNow executes a job immediately by name (useful for testing/manual triggers) + RunNow(ctx context.Context, jobName string) error + // ListJobs returns all registered job names + ListJobs() []string +} diff --git a/internal/service/scheduler/scheduler_test.go b/internal/service/scheduler/scheduler_test.go new file mode 100644 index 00000000..d65340ce --- /dev/null +++ b/internal/service/scheduler/scheduler_test.go @@ -0,0 +1,109 @@ +package scheduler + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +type mockJob struct { + name string + executed bool + execCount int +} + +func (j *mockJob) Name() string { + return j.name +} + +func (j *mockJob) Execute(ctx context.Context) error { + j.executed = true + j.execCount++ + return nil +} + +func TestCronScheduler_Schedule(t *testing.T) { + logger := zap.NewNop().Sugar() + sched := NewCronScheduler(logger) + + job := &mockJob{name: "test-job"} + + err := sched.Schedule(ScheduleDaily, job) + require.NoError(t, err) + + jobs := sched.ListJobs() + assert.Contains(t, jobs, "test-job") +} + +func TestCronScheduler_ScheduleDuplicate(t *testing.T) { + logger := zap.NewNop().Sugar() + sched := NewCronScheduler(logger) + + job1 := &mockJob{name: "test-job"} + job2 := &mockJob{name: "test-job"} + + err := sched.Schedule(ScheduleDaily, job1) + require.NoError(t, err) + + err = sched.Schedule(ScheduleWeekly, job2) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already registered") +} + +func TestCronScheduler_RunNow(t *testing.T) { + logger := zap.NewNop().Sugar() + sched := NewCronScheduler(logger) + + job := &mockJob{name: "test-job"} + + err := sched.Schedule(ScheduleDaily, job) + require.NoError(t, err) + + ctx := context.Background() + err = sched.RunNow(ctx, "test-job") + require.NoError(t, err) + + assert.True(t, job.executed) + assert.Equal(t, 1, job.execCount) +} + +func TestCronScheduler_RunNow_NotFound(t *testing.T) { + logger := zap.NewNop().Sugar() + sched := NewCronScheduler(logger) + + ctx := context.Background() + err := sched.RunNow(ctx, "nonexistent-job") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestCronScheduler_StartStop(t *testing.T) { + logger := zap.NewNop().Sugar() + sched := NewCronScheduler(logger) + + job := &mockJob{name: "test-job"} + err := sched.ScheduleCron("* * * * * *", job) // Every second + require.NoError(t, err) + + sched.Start() + + // Wait a bit for the job to potentially execute + time.Sleep(1500 * time.Millisecond) + + ctx := sched.Stop() + <-ctx.Done() + + // Job should have executed at least once + assert.True(t, job.executed) + assert.GreaterOrEqual(t, job.execCount, 1) +} + +func TestScheduleConstants(t *testing.T) { + assert.Equal(t, Schedule("@daily"), ScheduleDaily) + assert.Equal(t, Schedule("@weekly"), ScheduleWeekly) + assert.Equal(t, Schedule("@monthly"), ScheduleMonthly) +} diff --git a/sdk/integration_base_test.go b/sdk/integration_base_test.go index 76f663b8..8d7258b7 100644 --- a/sdk/integration_base_test.go +++ b/sdk/integration_base_test.go @@ -98,7 +98,7 @@ func (suite *IntegrationBaseTestSuite) SetupSuite() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), cfg, metrics) - handler.RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + handler.RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) suite.Server = server