Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ CCF_JWT_PRIVATE_KEY=private.pem
CCF_JWT_PUBLIC_KEY=public.pem

CCF_API_ALLOWED_ORIGINS="http://localhost:3000,http://localhost:8000"
CCF_RISK_CONFIG="risk.yaml"

CCF_ENVIRONMENT="" # Defaults to production.
## This configuration disables cookie setting to allow testing on Safari
## It is insecure so use it with caution
#CCF_ENVIRONMENT="local"
#CCF_ENVIRONMENT="local"
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func bindEnvironmentVariables() {
viper.MustBindEnv("sso_config")
viper.MustBindEnv("email_config")
viper.MustBindEnv("workflow_config")
viper.MustBindEnv("risk_config")
viper.MustBindEnv("metrics_enabled")
viper.MustBindEnv("metrics_port")
viper.MustBindEnv("use_dev_logger")
Expand Down
10 changes: 10 additions & 0 deletions docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -24309,6 +24309,9 @@ const docTemplate = `{
"handler.SubscriptionsResponse": {
"type": "object",
"properties": {
"riskNotificationsSubscribed": {
"type": "boolean"
},
"subscribed": {
"type": "boolean"
},
Expand All @@ -24323,6 +24326,9 @@ const docTemplate = `{
"handler.UpdateSubscriptionsRequest": {
"type": "object",
"properties": {
"riskNotificationsSubscribed": {
"type": "boolean"
},
"subscribed": {
"type": "boolean"
},
Expand Down Expand Up @@ -32321,6 +32327,10 @@ const docTemplate = `{
"lastName": {
"type": "string"
},
"riskNotificationsSubscribed": {
"description": "RiskNotificationsSubscribed indicates if the user wants to receive risk lifecycle notifications.\nThe DB default is intentionally true so existing users are opted in when the column is introduced.",
"type": "boolean"
},
"taskAvailableEmailSubscribed": {
"description": "TaskAvailableEmailSubscribed indicates if the user wants an email when tasks become available",
"type": "boolean"
Expand Down
10 changes: 10 additions & 0 deletions docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -24303,6 +24303,9 @@
"handler.SubscriptionsResponse": {
"type": "object",
"properties": {
"riskNotificationsSubscribed": {
"type": "boolean"
},
"subscribed": {
"type": "boolean"
},
Expand All @@ -24317,6 +24320,9 @@
"handler.UpdateSubscriptionsRequest": {
"type": "object",
"properties": {
"riskNotificationsSubscribed": {
"type": "boolean"
},
"subscribed": {
"type": "boolean"
},
Expand Down Expand Up @@ -32315,6 +32321,10 @@
"lastName": {
"type": "string"
},
"riskNotificationsSubscribed": {
"description": "RiskNotificationsSubscribed indicates if the user wants to receive risk lifecycle notifications.\nThe DB default is intentionally true so existing users are opted in when the column is introduced.",
"type": "boolean"
},
"taskAvailableEmailSubscribed": {
"description": "TaskAvailableEmailSubscribed indicates if the user wants an email when tasks become available",
"type": "boolean"
Expand Down
9 changes: 9 additions & 0 deletions docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1344,6 +1344,8 @@ definitions:
type: object
handler.SubscriptionsResponse:
properties:
riskNotificationsSubscribed:
type: boolean
subscribed:
type: boolean
taskAvailableEmailSubscribed:
Expand All @@ -1353,6 +1355,8 @@ definitions:
type: object
handler.UpdateSubscriptionsRequest:
properties:
riskNotificationsSubscribed:
type: boolean
subscribed:
type: boolean
taskAvailableEmailSubscribed:
Expand Down Expand Up @@ -6642,6 +6646,11 @@ definitions:
type: string
lastName:
type: string
riskNotificationsSubscribed:
description: |-
RiskNotificationsSubscribed indicates if the user wants to receive risk lifecycle notifications.
The DB default is intentionally true so existing users are opted in when the column is introduced.
type: boolean
taskAvailableEmailSubscribed:
description: TaskAvailableEmailSubscribed indicates if the user wants an email
when tasks become available
Expand Down
8 changes: 8 additions & 0 deletions internal/api/handler/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@ type SubscriptionsResponse struct {
Subscribed bool `json:"subscribed"`
TaskAvailableEmailSubscribed bool `json:"taskAvailableEmailSubscribed"`
TaskDailyDigestSubscribed bool `json:"taskDailyDigestSubscribed"`
RiskNotificationsSubscribed bool `json:"riskNotificationsSubscribed"`
}

type UpdateSubscriptionsRequest struct {
Subscribed *bool `json:"subscribed"`
TaskAvailableEmailSubscribed *bool `json:"taskAvailableEmailSubscribed"`
TaskDailyDigestSubscribed *bool `json:"taskDailyDigestSubscribed"`
RiskNotificationsSubscribed *bool `json:"riskNotificationsSubscribed"`
}

func NewUserHandler(sugar *zap.SugaredLogger, db *gorm.DB) *UserHandler {
Expand Down Expand Up @@ -437,6 +439,7 @@ func (h *UserHandler) GetSubscriptions(ctx echo.Context) error {
Subscribed: user.DigestSubscribed,
TaskAvailableEmailSubscribed: user.TaskAvailableEmailSubscribed,
TaskDailyDigestSubscribed: user.TaskDailyDigestSubscribed,
RiskNotificationsSubscribed: user.RiskNotificationsSubscribed,
},
})
}
Expand Down Expand Up @@ -484,6 +487,9 @@ func (h *UserHandler) UpdateSubscriptions(ctx echo.Context) error {
if req.TaskDailyDigestSubscribed != nil {
user.TaskDailyDigestSubscribed = *req.TaskDailyDigestSubscribed
}
if req.RiskNotificationsSubscribed != nil {
user.RiskNotificationsSubscribed = *req.RiskNotificationsSubscribed
}

if err := h.db.Save(&user).Error; err != nil {
h.sugar.Errorw("Failed to update user subscriptions", "error", err)
Expand All @@ -496,13 +502,15 @@ func (h *UserHandler) UpdateSubscriptions(ctx echo.Context) error {
"subscribed", user.DigestSubscribed,
"taskAvailableEmailSubscribed", user.TaskAvailableEmailSubscribed,
"taskDailyDigestSubscribed", user.TaskDailyDigestSubscribed,
"riskNotificationsSubscribed", user.RiskNotificationsSubscribed,
)

return ctx.JSON(200, GenericDataResponse[SubscriptionsResponse]{
Data: SubscriptionsResponse{
Subscribed: user.DigestSubscribed,
TaskAvailableEmailSubscribed: user.TaskAvailableEmailSubscribed,
TaskDailyDigestSubscribed: user.TaskDailyDigestSubscribed,
RiskNotificationsSubscribed: user.RiskNotificationsSubscribed,
},
})
}
Expand Down
11 changes: 9 additions & 2 deletions internal/api/handler/users_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ func (suite *UserApiIntegrationSuite) TestSubscriptions() {
Subscribed bool `json:"subscribed"`
TaskAvailableEmailSubscribed bool `json:"taskAvailableEmailSubscribed"`
TaskDailyDigestSubscribed bool `json:"taskDailyDigestSubscribed"`
RiskNotificationsSubscribed bool `json:"riskNotificationsSubscribed"`
} `json:"data"`
}
err = json.Unmarshal(rec.Body.Bytes(), &response)
Expand All @@ -412,6 +413,7 @@ func (suite *UserApiIntegrationSuite) TestSubscriptions() {
suite.False(response.Data.Subscribed, "Expected default digest subscription to be false")
suite.False(response.Data.TaskAvailableEmailSubscribed, "Expected task available email subscription to default to false")
suite.False(response.Data.TaskDailyDigestSubscribed, "Expected task daily digest subscription to default to false")
suite.True(response.Data.RiskNotificationsSubscribed, "Expected risk notifications subscription to default to true")
})

suite.Run("UpdateSubscriptions", func() {
Expand All @@ -420,6 +422,7 @@ func (suite *UserApiIntegrationSuite) TestSubscriptions() {
"subscribed": true,
"taskAvailableEmailSubscribed": true,
"taskDailyDigestSubscribed": true,
"riskNotificationsSubscribed": false,
}
payloadJSON, err := json.Marshal(payload)
suite.Require().NoError(err, "Failed to marshal update subscriptions request")
Expand All @@ -437,6 +440,7 @@ func (suite *UserApiIntegrationSuite) TestSubscriptions() {
Subscribed bool `json:"subscribed"`
TaskAvailableEmailSubscribed bool `json:"taskAvailableEmailSubscribed"`
TaskDailyDigestSubscribed bool `json:"taskDailyDigestSubscribed"`
RiskNotificationsSubscribed bool `json:"riskNotificationsSubscribed"`
} `json:"data"`
}
err = json.Unmarshal(rec.Body.Bytes(), &response)
Expand All @@ -445,11 +449,13 @@ func (suite *UserApiIntegrationSuite) TestSubscriptions() {
suite.True(response.Data.Subscribed, "Expected digest subscription to be updated to true")
suite.True(response.Data.TaskAvailableEmailSubscribed, "Expected task available email subscription to be updated to true")
suite.True(response.Data.TaskDailyDigestSubscribed, "Expected task daily digest subscription to be updated to true")
suite.False(response.Data.RiskNotificationsSubscribed, "Expected risk notifications subscription to be updated to false")

// Test unsubscribing from digest
payload = map[string]interface{}{
"subscribed": false,
"taskDailyDigestSubscribed": false,
"subscribed": false,
"taskDailyDigestSubscribed": false,
"riskNotificationsSubscribed": true,
}
payloadJSON, err = json.Marshal(payload)
suite.Require().NoError(err, "Failed to marshal unsubscribe request")
Expand All @@ -468,6 +474,7 @@ func (suite *UserApiIntegrationSuite) TestSubscriptions() {
suite.False(response.Data.Subscribed, "Expected digest subscription to be updated to false")
suite.True(response.Data.TaskAvailableEmailSubscribed, "Expected task available email subscription to remain unchanged when omitted")
suite.False(response.Data.TaskDailyDigestSubscribed, "Expected task daily digest subscription to be updated to false")
suite.True(response.Data.RiskNotificationsSubscribed, "Expected risk notifications subscription to be updated to true")
})

suite.Run("UpdateSubscriptionsInvalidPayload", func() {
Expand Down
12 changes: 12 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type Config struct {
DigestEnabled bool // Enable or disable the digest scheduler
DigestSchedule string // Cron schedule for digest emails
Workflow *WorkflowConfig
Risk *RiskConfig
}

func NewConfig(logger *zap.SugaredLogger) *Config {
Expand Down Expand Up @@ -174,6 +175,16 @@ func NewConfig(logger *zap.SugaredLogger) *Config {
workflowConfig = &WorkflowConfig{SchedulerEnabled: false}
}

riskConfigPath := viper.GetString("risk_config")
if riskConfigPath == "" {
riskConfigPath = "risk.yaml"
}
riskConfig, err := LoadRiskConfig(riskConfigPath)
if err != nil {
logger.Warnw("Failed to load risk config, risk jobs will be disabled", "error", err, "path", riskConfigPath)
riskConfig = DefaultRiskConfig()
}

// Worker configuration
workerConfig := DefaultWorkerConfig()
if viper.IsSet("worker_enabled") {
Expand Down Expand Up @@ -206,6 +217,7 @@ func NewConfig(logger *zap.SugaredLogger) *Config {
DigestEnabled: digestEnabled,
DigestSchedule: digestSchedule,
Workflow: workflowConfig,
Risk: riskConfig,
}

}
Expand Down
118 changes: 118 additions & 0 deletions internal/config/risk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package config

import (
"errors"
"fmt"
"os"
"strings"

"github.com/robfig/cron/v3"
"github.com/spf13/viper"
)

// RiskConfig contains configuration for risk-related periodic workers.
type RiskConfig struct {
ReviewDeadlineReminderEnabled bool `mapstructure:"review_deadline_reminder_enabled" yaml:"review_deadline_reminder_enabled" json:"reviewDeadlineReminderEnabled"`
ReviewDeadlineReminderSchedule string `mapstructure:"review_deadline_reminder_schedule" yaml:"review_deadline_reminder_schedule" json:"reviewDeadlineReminderSchedule"`

ReviewOverdueEscalationEnabled bool `mapstructure:"review_overdue_escalation_enabled" yaml:"review_overdue_escalation_enabled" json:"reviewOverdueEscalationEnabled"`
ReviewOverdueEscalationSchedule string `mapstructure:"review_overdue_escalation_schedule" yaml:"review_overdue_escalation_schedule" json:"reviewOverdueEscalationSchedule"`

StaleRiskScannerEnabled bool `mapstructure:"stale_risk_scanner_enabled" yaml:"stale_risk_scanner_enabled" json:"staleRiskScannerEnabled"`
StaleRiskScannerSchedule string `mapstructure:"stale_risk_scanner_schedule" yaml:"stale_risk_scanner_schedule" json:"staleRiskScannerSchedule"`

EvidenceReconciliationEnabled bool `mapstructure:"evidence_reconciliation_enabled" yaml:"evidence_reconciliation_enabled" json:"evidenceReconciliationEnabled"`
EvidenceReconciliationSchedule string `mapstructure:"evidence_reconciliation_schedule" yaml:"evidence_reconciliation_schedule" json:"evidenceReconciliationSchedule"`

AutoReopenEnabled bool `mapstructure:"auto_reopen_enabled" yaml:"auto_reopen_enabled" json:"autoReopenEnabled"`
AutoReopenThresholdDays int `mapstructure:"auto_reopen_threshold_days" yaml:"auto_reopen_threshold_days" json:"autoReopenThresholdDays"`
}

func DefaultRiskConfig() *RiskConfig {
return &RiskConfig{
ReviewDeadlineReminderEnabled: false,
ReviewDeadlineReminderSchedule: "0 0 8 * * *",
ReviewOverdueEscalationEnabled: false,
ReviewOverdueEscalationSchedule: "0 0 9 * * *",
StaleRiskScannerEnabled: false,
StaleRiskScannerSchedule: "0 0 10 * * 1",
EvidenceReconciliationEnabled: false,
EvidenceReconciliationSchedule: "0 30 10 * * *",
AutoReopenEnabled: false,
AutoReopenThresholdDays: 30,
}
}

func LoadRiskConfig(path string) (*RiskConfig, error) {
v := viper.NewWithOptions(viper.KeyDelimiter("::"))

def := DefaultRiskConfig()
v.SetDefault("review_deadline_reminder_enabled", def.ReviewDeadlineReminderEnabled)
v.SetDefault("review_deadline_reminder_schedule", def.ReviewDeadlineReminderSchedule)
v.SetDefault("review_overdue_escalation_enabled", def.ReviewOverdueEscalationEnabled)
v.SetDefault("review_overdue_escalation_schedule", def.ReviewOverdueEscalationSchedule)
v.SetDefault("stale_risk_scanner_enabled", def.StaleRiskScannerEnabled)
v.SetDefault("stale_risk_scanner_schedule", def.StaleRiskScannerSchedule)
v.SetDefault("evidence_reconciliation_enabled", def.EvidenceReconciliationEnabled)
v.SetDefault("evidence_reconciliation_schedule", def.EvidenceReconciliationSchedule)
v.SetDefault("auto_reopen_enabled", def.AutoReopenEnabled)
v.SetDefault("auto_reopen_threshold_days", def.AutoReopenThresholdDays)

v.SetEnvPrefix("CCF_RISK")
v.SetEnvKeyReplacer(strings.NewReplacer("::", "_", ".", "_", "-", "_"))
v.AutomaticEnv()

if path != "" {
v.SetConfigFile(path)
v.SetConfigType("yaml")
if err := v.ReadInConfig(); err != nil {
var notFound viper.ConfigFileNotFoundError
if !errors.As(err, &notFound) && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("failed to read risk config file: %w", err)
}
}
}

var cfg RiskConfig
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("failed to parse risk config: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, err
}

return &cfg, nil
}

func (c *RiskConfig) Validate() error {
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)

if c.ReviewDeadlineReminderEnabled {
if _, err := parser.Parse(c.ReviewDeadlineReminderSchedule); err != nil {
return fmt.Errorf("invalid review_deadline_reminder_schedule: %w", err)
}
}
if c.ReviewOverdueEscalationEnabled {
if _, err := parser.Parse(c.ReviewOverdueEscalationSchedule); err != nil {
return fmt.Errorf("invalid review_overdue_escalation_schedule: %w", err)
}
}
if c.StaleRiskScannerEnabled {
if _, err := parser.Parse(c.StaleRiskScannerSchedule); err != nil {
return fmt.Errorf("invalid stale_risk_scanner_schedule: %w", err)
}
}
if c.EvidenceReconciliationEnabled {
if _, err := parser.Parse(c.EvidenceReconciliationSchedule); err != nil {
return fmt.Errorf("invalid evidence_reconciliation_schedule: %w", err)
}
}
if c.AutoReopenThresholdDays < 0 {
return fmt.Errorf("risk auto reopen threshold days must be non-negative")
}
if c.AutoReopenEnabled && c.AutoReopenThresholdDays <= 0 {
return fmt.Errorf("risk auto reopen threshold days must be greater than zero when auto reopen is enabled")
}

return nil
}
Loading