Skip to content

Commit 67f6327

Browse files
feat: new risk jobs (#344)
* feat: risk notification jobs * fix: copilot issues * fix: copilot issues * fix: copilot issues * fix: copilot issues * Reject zero auto reopen threshold * Fix resolveSSP display name fallback * Limit risk evidence orphan query * Clear acceptance justification * fix: remove uneeded self-heal behavior * Use risk service for overdue reopen * fix: use context on db Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update risk scanner batching * Apply copilot comments and defaultv2 * Lock risk when reopening review * Address risk worker comments --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 15be6ab commit 67f6327

29 files changed

+1974
-5
lines changed

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ CCF_JWT_PRIVATE_KEY=private.pem
88
CCF_JWT_PUBLIC_KEY=public.pem
99

1010
CCF_API_ALLOWED_ORIGINS="http://localhost:3000,http://localhost:8000"
11+
CCF_RISK_CONFIG="risk.yaml"
1112

1213
CCF_ENVIRONMENT="" # Defaults to production.
1314
## This configuration disables cookie setting to allow testing on Safari
1415
## It is insecure so use it with caution
15-
#CCF_ENVIRONMENT="local"
16+
#CCF_ENVIRONMENT="local"

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ func bindEnvironmentVariables() {
4646
viper.MustBindEnv("sso_config")
4747
viper.MustBindEnv("email_config")
4848
viper.MustBindEnv("workflow_config")
49+
viper.MustBindEnv("risk_config")
4950
viper.MustBindEnv("metrics_enabled")
5051
viper.MustBindEnv("metrics_port")
5152
viper.MustBindEnv("use_dev_logger")

docs/docs.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24309,6 +24309,9 @@ const docTemplate = `{
2430924309
"handler.SubscriptionsResponse": {
2431024310
"type": "object",
2431124311
"properties": {
24312+
"riskNotificationsSubscribed": {
24313+
"type": "boolean"
24314+
},
2431224315
"subscribed": {
2431324316
"type": "boolean"
2431424317
},
@@ -24323,6 +24326,9 @@ const docTemplate = `{
2432324326
"handler.UpdateSubscriptionsRequest": {
2432424327
"type": "object",
2432524328
"properties": {
24329+
"riskNotificationsSubscribed": {
24330+
"type": "boolean"
24331+
},
2432624332
"subscribed": {
2432724333
"type": "boolean"
2432824334
},
@@ -32321,6 +32327,10 @@ const docTemplate = `{
3232132327
"lastName": {
3232232328
"type": "string"
3232332329
},
32330+
"riskNotificationsSubscribed": {
32331+
"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.",
32332+
"type": "boolean"
32333+
},
3232432334
"taskAvailableEmailSubscribed": {
3232532335
"description": "TaskAvailableEmailSubscribed indicates if the user wants an email when tasks become available",
3232632336
"type": "boolean"

docs/swagger.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24303,6 +24303,9 @@
2430324303
"handler.SubscriptionsResponse": {
2430424304
"type": "object",
2430524305
"properties": {
24306+
"riskNotificationsSubscribed": {
24307+
"type": "boolean"
24308+
},
2430624309
"subscribed": {
2430724310
"type": "boolean"
2430824311
},
@@ -24317,6 +24320,9 @@
2431724320
"handler.UpdateSubscriptionsRequest": {
2431824321
"type": "object",
2431924322
"properties": {
24323+
"riskNotificationsSubscribed": {
24324+
"type": "boolean"
24325+
},
2432024326
"subscribed": {
2432124327
"type": "boolean"
2432224328
},
@@ -32315,6 +32321,10 @@
3231532321
"lastName": {
3231632322
"type": "string"
3231732323
},
32324+
"riskNotificationsSubscribed": {
32325+
"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.",
32326+
"type": "boolean"
32327+
},
3231832328
"taskAvailableEmailSubscribed": {
3231932329
"description": "TaskAvailableEmailSubscribed indicates if the user wants an email when tasks become available",
3232032330
"type": "boolean"

docs/swagger.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1344,6 +1344,8 @@ definitions:
13441344
type: object
13451345
handler.SubscriptionsResponse:
13461346
properties:
1347+
riskNotificationsSubscribed:
1348+
type: boolean
13471349
subscribed:
13481350
type: boolean
13491351
taskAvailableEmailSubscribed:
@@ -1353,6 +1355,8 @@ definitions:
13531355
type: object
13541356
handler.UpdateSubscriptionsRequest:
13551357
properties:
1358+
riskNotificationsSubscribed:
1359+
type: boolean
13561360
subscribed:
13571361
type: boolean
13581362
taskAvailableEmailSubscribed:
@@ -6642,6 +6646,11 @@ definitions:
66426646
type: string
66436647
lastName:
66446648
type: string
6649+
riskNotificationsSubscribed:
6650+
description: |-
6651+
RiskNotificationsSubscribed indicates if the user wants to receive risk lifecycle notifications.
6652+
The DB default is intentionally true so existing users are opted in when the column is introduced.
6653+
type: boolean
66456654
taskAvailableEmailSubscribed:
66466655
description: TaskAvailableEmailSubscribed indicates if the user wants an email
66476656
when tasks become available

internal/api/handler/users.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,14 @@ type SubscriptionsResponse struct {
2727
Subscribed bool `json:"subscribed"`
2828
TaskAvailableEmailSubscribed bool `json:"taskAvailableEmailSubscribed"`
2929
TaskDailyDigestSubscribed bool `json:"taskDailyDigestSubscribed"`
30+
RiskNotificationsSubscribed bool `json:"riskNotificationsSubscribed"`
3031
}
3132

3233
type UpdateSubscriptionsRequest struct {
3334
Subscribed *bool `json:"subscribed"`
3435
TaskAvailableEmailSubscribed *bool `json:"taskAvailableEmailSubscribed"`
3536
TaskDailyDigestSubscribed *bool `json:"taskDailyDigestSubscribed"`
37+
RiskNotificationsSubscribed *bool `json:"riskNotificationsSubscribed"`
3638
}
3739

3840
func NewUserHandler(sugar *zap.SugaredLogger, db *gorm.DB) *UserHandler {
@@ -437,6 +439,7 @@ func (h *UserHandler) GetSubscriptions(ctx echo.Context) error {
437439
Subscribed: user.DigestSubscribed,
438440
TaskAvailableEmailSubscribed: user.TaskAvailableEmailSubscribed,
439441
TaskDailyDigestSubscribed: user.TaskDailyDigestSubscribed,
442+
RiskNotificationsSubscribed: user.RiskNotificationsSubscribed,
440443
},
441444
})
442445
}
@@ -484,6 +487,9 @@ func (h *UserHandler) UpdateSubscriptions(ctx echo.Context) error {
484487
if req.TaskDailyDigestSubscribed != nil {
485488
user.TaskDailyDigestSubscribed = *req.TaskDailyDigestSubscribed
486489
}
490+
if req.RiskNotificationsSubscribed != nil {
491+
user.RiskNotificationsSubscribed = *req.RiskNotificationsSubscribed
492+
}
487493

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

501508
return ctx.JSON(200, GenericDataResponse[SubscriptionsResponse]{
502509
Data: SubscriptionsResponse{
503510
Subscribed: user.DigestSubscribed,
504511
TaskAvailableEmailSubscribed: user.TaskAvailableEmailSubscribed,
505512
TaskDailyDigestSubscribed: user.TaskDailyDigestSubscribed,
513+
RiskNotificationsSubscribed: user.RiskNotificationsSubscribed,
506514
},
507515
})
508516
}

internal/api/handler/users_integration_test.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,7 @@ func (suite *UserApiIntegrationSuite) TestSubscriptions() {
403403
Subscribed bool `json:"subscribed"`
404404
TaskAvailableEmailSubscribed bool `json:"taskAvailableEmailSubscribed"`
405405
TaskDailyDigestSubscribed bool `json:"taskDailyDigestSubscribed"`
406+
RiskNotificationsSubscribed bool `json:"riskNotificationsSubscribed"`
406407
} `json:"data"`
407408
}
408409
err = json.Unmarshal(rec.Body.Bytes(), &response)
@@ -412,6 +413,7 @@ func (suite *UserApiIntegrationSuite) TestSubscriptions() {
412413
suite.False(response.Data.Subscribed, "Expected default digest subscription to be false")
413414
suite.False(response.Data.TaskAvailableEmailSubscribed, "Expected task available email subscription to default to false")
414415
suite.False(response.Data.TaskDailyDigestSubscribed, "Expected task daily digest subscription to default to false")
416+
suite.True(response.Data.RiskNotificationsSubscribed, "Expected risk notifications subscription to default to true")
415417
})
416418

417419
suite.Run("UpdateSubscriptions", func() {
@@ -420,6 +422,7 @@ func (suite *UserApiIntegrationSuite) TestSubscriptions() {
420422
"subscribed": true,
421423
"taskAvailableEmailSubscribed": true,
422424
"taskDailyDigestSubscribed": true,
425+
"riskNotificationsSubscribed": false,
423426
}
424427
payloadJSON, err := json.Marshal(payload)
425428
suite.Require().NoError(err, "Failed to marshal update subscriptions request")
@@ -437,6 +440,7 @@ func (suite *UserApiIntegrationSuite) TestSubscriptions() {
437440
Subscribed bool `json:"subscribed"`
438441
TaskAvailableEmailSubscribed bool `json:"taskAvailableEmailSubscribed"`
439442
TaskDailyDigestSubscribed bool `json:"taskDailyDigestSubscribed"`
443+
RiskNotificationsSubscribed bool `json:"riskNotificationsSubscribed"`
440444
} `json:"data"`
441445
}
442446
err = json.Unmarshal(rec.Body.Bytes(), &response)
@@ -445,11 +449,13 @@ func (suite *UserApiIntegrationSuite) TestSubscriptions() {
445449
suite.True(response.Data.Subscribed, "Expected digest subscription to be updated to true")
446450
suite.True(response.Data.TaskAvailableEmailSubscribed, "Expected task available email subscription to be updated to true")
447451
suite.True(response.Data.TaskDailyDigestSubscribed, "Expected task daily digest subscription to be updated to true")
452+
suite.False(response.Data.RiskNotificationsSubscribed, "Expected risk notifications subscription to be updated to false")
448453

449454
// Test unsubscribing from digest
450455
payload = map[string]interface{}{
451-
"subscribed": false,
452-
"taskDailyDigestSubscribed": false,
456+
"subscribed": false,
457+
"taskDailyDigestSubscribed": false,
458+
"riskNotificationsSubscribed": true,
453459
}
454460
payloadJSON, err = json.Marshal(payload)
455461
suite.Require().NoError(err, "Failed to marshal unsubscribe request")
@@ -468,6 +474,7 @@ func (suite *UserApiIntegrationSuite) TestSubscriptions() {
468474
suite.False(response.Data.Subscribed, "Expected digest subscription to be updated to false")
469475
suite.True(response.Data.TaskAvailableEmailSubscribed, "Expected task available email subscription to remain unchanged when omitted")
470476
suite.False(response.Data.TaskDailyDigestSubscribed, "Expected task daily digest subscription to be updated to false")
477+
suite.True(response.Data.RiskNotificationsSubscribed, "Expected risk notifications subscription to be updated to true")
471478
})
472479

473480
suite.Run("UpdateSubscriptionsInvalidPayload", func() {

internal/config/config.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type Config struct {
3838
DigestEnabled bool // Enable or disable the digest scheduler
3939
DigestSchedule string // Cron schedule for digest emails
4040
Workflow *WorkflowConfig
41+
Risk *RiskConfig
4142
}
4243

4344
func NewConfig(logger *zap.SugaredLogger) *Config {
@@ -174,6 +175,16 @@ func NewConfig(logger *zap.SugaredLogger) *Config {
174175
workflowConfig = &WorkflowConfig{SchedulerEnabled: false}
175176
}
176177

178+
riskConfigPath := viper.GetString("risk_config")
179+
if riskConfigPath == "" {
180+
riskConfigPath = "risk.yaml"
181+
}
182+
riskConfig, err := LoadRiskConfig(riskConfigPath)
183+
if err != nil {
184+
logger.Warnw("Failed to load risk config, risk jobs will be disabled", "error", err, "path", riskConfigPath)
185+
riskConfig = DefaultRiskConfig()
186+
}
187+
177188
// Worker configuration
178189
workerConfig := DefaultWorkerConfig()
179190
if viper.IsSet("worker_enabled") {
@@ -206,6 +217,7 @@ func NewConfig(logger *zap.SugaredLogger) *Config {
206217
DigestEnabled: digestEnabled,
207218
DigestSchedule: digestSchedule,
208219
Workflow: workflowConfig,
220+
Risk: riskConfig,
209221
}
210222

211223
}

internal/config/risk.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package config
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
"github.com/robfig/cron/v3"
10+
"github.com/spf13/viper"
11+
)
12+
13+
// RiskConfig contains configuration for risk-related periodic workers.
14+
type RiskConfig struct {
15+
ReviewDeadlineReminderEnabled bool `mapstructure:"review_deadline_reminder_enabled" yaml:"review_deadline_reminder_enabled" json:"reviewDeadlineReminderEnabled"`
16+
ReviewDeadlineReminderSchedule string `mapstructure:"review_deadline_reminder_schedule" yaml:"review_deadline_reminder_schedule" json:"reviewDeadlineReminderSchedule"`
17+
18+
ReviewOverdueEscalationEnabled bool `mapstructure:"review_overdue_escalation_enabled" yaml:"review_overdue_escalation_enabled" json:"reviewOverdueEscalationEnabled"`
19+
ReviewOverdueEscalationSchedule string `mapstructure:"review_overdue_escalation_schedule" yaml:"review_overdue_escalation_schedule" json:"reviewOverdueEscalationSchedule"`
20+
21+
StaleRiskScannerEnabled bool `mapstructure:"stale_risk_scanner_enabled" yaml:"stale_risk_scanner_enabled" json:"staleRiskScannerEnabled"`
22+
StaleRiskScannerSchedule string `mapstructure:"stale_risk_scanner_schedule" yaml:"stale_risk_scanner_schedule" json:"staleRiskScannerSchedule"`
23+
24+
EvidenceReconciliationEnabled bool `mapstructure:"evidence_reconciliation_enabled" yaml:"evidence_reconciliation_enabled" json:"evidenceReconciliationEnabled"`
25+
EvidenceReconciliationSchedule string `mapstructure:"evidence_reconciliation_schedule" yaml:"evidence_reconciliation_schedule" json:"evidenceReconciliationSchedule"`
26+
27+
AutoReopenEnabled bool `mapstructure:"auto_reopen_enabled" yaml:"auto_reopen_enabled" json:"autoReopenEnabled"`
28+
AutoReopenThresholdDays int `mapstructure:"auto_reopen_threshold_days" yaml:"auto_reopen_threshold_days" json:"autoReopenThresholdDays"`
29+
}
30+
31+
func DefaultRiskConfig() *RiskConfig {
32+
return &RiskConfig{
33+
ReviewDeadlineReminderEnabled: false,
34+
ReviewDeadlineReminderSchedule: "0 0 8 * * *",
35+
ReviewOverdueEscalationEnabled: false,
36+
ReviewOverdueEscalationSchedule: "0 0 9 * * *",
37+
StaleRiskScannerEnabled: false,
38+
StaleRiskScannerSchedule: "0 0 10 * * 1",
39+
EvidenceReconciliationEnabled: false,
40+
EvidenceReconciliationSchedule: "0 30 10 * * *",
41+
AutoReopenEnabled: false,
42+
AutoReopenThresholdDays: 30,
43+
}
44+
}
45+
46+
func LoadRiskConfig(path string) (*RiskConfig, error) {
47+
v := viper.NewWithOptions(viper.KeyDelimiter("::"))
48+
49+
def := DefaultRiskConfig()
50+
v.SetDefault("review_deadline_reminder_enabled", def.ReviewDeadlineReminderEnabled)
51+
v.SetDefault("review_deadline_reminder_schedule", def.ReviewDeadlineReminderSchedule)
52+
v.SetDefault("review_overdue_escalation_enabled", def.ReviewOverdueEscalationEnabled)
53+
v.SetDefault("review_overdue_escalation_schedule", def.ReviewOverdueEscalationSchedule)
54+
v.SetDefault("stale_risk_scanner_enabled", def.StaleRiskScannerEnabled)
55+
v.SetDefault("stale_risk_scanner_schedule", def.StaleRiskScannerSchedule)
56+
v.SetDefault("evidence_reconciliation_enabled", def.EvidenceReconciliationEnabled)
57+
v.SetDefault("evidence_reconciliation_schedule", def.EvidenceReconciliationSchedule)
58+
v.SetDefault("auto_reopen_enabled", def.AutoReopenEnabled)
59+
v.SetDefault("auto_reopen_threshold_days", def.AutoReopenThresholdDays)
60+
61+
v.SetEnvPrefix("CCF_RISK")
62+
v.SetEnvKeyReplacer(strings.NewReplacer("::", "_", ".", "_", "-", "_"))
63+
v.AutomaticEnv()
64+
65+
if path != "" {
66+
v.SetConfigFile(path)
67+
v.SetConfigType("yaml")
68+
if err := v.ReadInConfig(); err != nil {
69+
var notFound viper.ConfigFileNotFoundError
70+
if !errors.As(err, &notFound) && !errors.Is(err, os.ErrNotExist) {
71+
return nil, fmt.Errorf("failed to read risk config file: %w", err)
72+
}
73+
}
74+
}
75+
76+
var cfg RiskConfig
77+
if err := v.Unmarshal(&cfg); err != nil {
78+
return nil, fmt.Errorf("failed to parse risk config: %w", err)
79+
}
80+
if err := cfg.Validate(); err != nil {
81+
return nil, err
82+
}
83+
84+
return &cfg, nil
85+
}
86+
87+
func (c *RiskConfig) Validate() error {
88+
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
89+
90+
if c.ReviewDeadlineReminderEnabled {
91+
if _, err := parser.Parse(c.ReviewDeadlineReminderSchedule); err != nil {
92+
return fmt.Errorf("invalid review_deadline_reminder_schedule: %w", err)
93+
}
94+
}
95+
if c.ReviewOverdueEscalationEnabled {
96+
if _, err := parser.Parse(c.ReviewOverdueEscalationSchedule); err != nil {
97+
return fmt.Errorf("invalid review_overdue_escalation_schedule: %w", err)
98+
}
99+
}
100+
if c.StaleRiskScannerEnabled {
101+
if _, err := parser.Parse(c.StaleRiskScannerSchedule); err != nil {
102+
return fmt.Errorf("invalid stale_risk_scanner_schedule: %w", err)
103+
}
104+
}
105+
if c.EvidenceReconciliationEnabled {
106+
if _, err := parser.Parse(c.EvidenceReconciliationSchedule); err != nil {
107+
return fmt.Errorf("invalid evidence_reconciliation_schedule: %w", err)
108+
}
109+
}
110+
if c.AutoReopenThresholdDays < 0 {
111+
return fmt.Errorf("risk auto reopen threshold days must be non-negative")
112+
}
113+
if c.AutoReopenEnabled && c.AutoReopenThresholdDays <= 0 {
114+
return fmt.Errorf("risk auto reopen threshold days must be greater than zero when auto reopen is enabled")
115+
}
116+
117+
return nil
118+
}

0 commit comments

Comments
 (0)