Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 @@ -23548,6 +23548,9 @@ const docTemplate = `{
"handler.SubscriptionsResponse": {
"type": "object",
"properties": {
"riskNotificationsSubscribed": {
"type": "boolean"
},
"subscribed": {
"type": "boolean"
},
Expand All @@ -23562,6 +23565,9 @@ const docTemplate = `{
"handler.UpdateSubscriptionsRequest": {
"type": "object",
"properties": {
"riskNotificationsSubscribed": {
"type": "boolean"
},
"subscribed": {
"type": "boolean"
},
Expand Down Expand Up @@ -31532,6 +31538,10 @@ const docTemplate = `{
"lastName": {
"type": "string"
},
"riskNotificationsSubscribed": {
"description": "RiskNotificationsSubscribed indicates if the user wants to receive risk lifecycle notifications",
"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 @@ -23542,6 +23542,9 @@
"handler.SubscriptionsResponse": {
"type": "object",
"properties": {
"riskNotificationsSubscribed": {
"type": "boolean"
},
"subscribed": {
"type": "boolean"
},
Expand All @@ -23556,6 +23559,9 @@
"handler.UpdateSubscriptionsRequest": {
"type": "object",
"properties": {
"riskNotificationsSubscribed": {
"type": "boolean"
},
"subscribed": {
"type": "boolean"
},
Expand Down Expand Up @@ -31526,6 +31532,10 @@
"lastName": {
"type": "string"
},
"riskNotificationsSubscribed": {
"description": "RiskNotificationsSubscribed indicates if the user wants to receive risk lifecycle notifications",
"type": "boolean"
},
"taskAvailableEmailSubscribed": {
"description": "TaskAvailableEmailSubscribed indicates if the user wants an email when tasks become available",
"type": "boolean"
Expand Down
8 changes: 8 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 @@ -6624,6 +6628,10 @@ definitions:
type: string
lastName:
type: string
riskNotificationsSubscribed:
description: RiskNotificationsSubscribed indicates if the user wants to receive
risk lifecycle notifications
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
114 changes: 114 additions & 0 deletions internal/config/risk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package config

import (
"errors"
"fmt"
"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) {
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")
}

return nil
}
Loading