Skip to content

feat(poam): BCH-1186 — promote risk to POAM item#362

Merged
gusfcarvalho merged 8 commits into
mainfrom
feat/poam-promote-to-poam
Mar 27, 2026
Merged

feat(poam): BCH-1186 — promote risk to POAM item#362
gusfcarvalho merged 8 commits into
mainfrom
feat/poam-promote-to-poam

Conversation

@AKAbdulHanif
Copy link
Copy Markdown
Contributor

BCH-1186: POAM Phase 2 — Risk Integration (Promote to POAM)

Summary

Implements POST /risks/:id/promote-to-poam and the SSP-scoped variant POST /oscal/system-security-plans/:sspId/risks/:id/promote-to-poam.

When a risk is in risk-accepted status, the risk owner can promote it to a structured POAM item. The entire operation is atomic — POAM item creation, milestone creation, risk link creation, and risk event emission all commit or roll back together.

Changes

File Change
poam/models.go Add PocName, PocEmail, ResourceRequired fields to PoamItem (GORM AutoMigrate adds columns)
poam/service.go Extend CreatePoamItemParams with poc/resource fields; add CreateWithTx for shared-transaction use
risks/events.go Add RiskEventTypePoamPromoted constant + BuildRiskEventDetails case
risks/promote_to_poam.go New PromoteToPoam service method — validates risk-accepted status, guards re-promotion of active POAM, copies RemediationTemplate tasks as milestones, merges extra milestones from request body, atomic transaction
handler/poam_items.go Expose PocName, PocEmail, ResourceRequired in createPoamItemRequest and poamItemResponse
handler/risks.go Add promoteToPoamRequest, PromoteToPoam and PromoteToPoamForSSP handlers; inject PoamService into RiskHandler
handler/api.go Pass poamService to NewRiskHandler
risks_promote_to_poam_integration_test.go 7 integration tests covering happy path, defaults, 422 rejection, SSP-scoped, and remediation template milestone merging

Definition of Done

  • POST /risks/:id/promote-to-poam creates PoamItem linked to Risk
  • Rejects if risk is NOT in risk-accepted status (422 with clear error message)
  • Title/description defaults from risk if not provided in body
  • Milestones from RemediationTemplate are copied in correct order
  • poam_item_risk_links entry created; risk_event(poam_promoted) row emitted
  • Whole operation is transactional: no partial PoamItem if milestone insert fails
  • 7 integration tests covering happy path and rejection cases

Depends on

BCH-1185 (POAM Item CRUD), BCH-1182 (Risk Accept + Review)

Implements POST /risks/:id/promote-to-poam and the SSP-scoped variant
POST /oscal/system-security-plans/:sspId/risks/:id/promote-to-poam.

Changes:
- poam/models.go: add PocName, PocEmail, ResourceRequired fields to PoamItem
- poam/service.go: extend CreatePoamItemParams with poc/resource fields;
  add CreateWithTx for use within an external transaction
- risks/events.go: add RiskEventTypePoamPromoted constant + BuildRiskEventDetails case
- risks/promote_to_poam.go: new PromoteToPoam service method — validates
  risk-accepted status, guards against re-promotion of active POAM,
  copies RemediationTemplate tasks as milestones, merges extra milestones
  from request, creates POAM item + risk link + risk event atomically
- handler/poam_items.go: expose PocName, PocEmail, ResourceRequired in
  createPoamItemRequest and poamItemResponse
- handler/risks.go: add promoteToPoamRequest, PromoteToPoam and
  PromoteToPoamForSSP handlers; inject PoamService into RiskHandler
- handler/api.go: pass poamService to NewRiskHandler
- risks_promote_to_poam_integration_test.go: 7 integration tests covering
  happy path, defaults, 422 rejection cases, SSP-scoped, and remediation
  template milestone merging

Depends-on: BCH-1185 (POAM Item CRUD), BCH-1182 (Risk Accept + Review)
…rder-index

- docs/: regenerate swagger docs to include new promote-to-poam endpoints
  (fixes check-diff CI failure — docs were stale after adding handler annotations)
- risks_promote_to_poam_integration_test.go: fix remediation template task
  order-index values from 0-based to 1-based (validation requires > 0)
  (fixes TestPromoteToPoam_WithRemediationTemplate integration test failure)
- poam/models.go: include GORM column tags for PocName, PocEmail,
  ResourceRequired to ensure AutoMigrate generates correct column names
Comment thread internal/service/relational/risks/promote_to_poam.go Outdated
…lifecycle transitions

- Change PromoteToPoam guard: risk must be in 'investigating' status (not
  'risk-accepted'). A formally accepted risk has been deemed tolerable and
  should not receive a remediation plan; POAM promotion is only valid while
  the risk is actively being investigated.

- Transition risk to 'mitigating-planned' on promotion: when a POAM item is
  created, the risk advances from investigating → mitigating-planned inside
  the same atomic transaction, emitting status_changed + poam_promoted events.

- Add POAM completion lifecycle callback (poam_completion.go): when a POAM
  item transitions to 'completed', OnPoamItemCompleted advances all linked
  risks from mitigating-planned → mitigating-implemented, emitting
  status_changed + poam_completed events per risk.

- Add RiskEventTypePoamCompleted constant and event details case.

- Update validateStatusTransition: allow risk-accepted → mitigating-planned
  for manual transitions.

- Wire riskService into PoamItemsHandler for the completion callback; share
  a single RiskService instance across both handlers via api.go.

- Update all integration tests: remove accept step (risks are promoted from
  investigating, not risk-accepted); add assertions for mitigating-planned
  after promotion; add TestPromoteToPoam_CompletionAdvancesRiskStatus for
  the full investigating → mitigating-planned → mitigating-implemented path.

- Regenerate Swagger docs.
Comment thread internal/service/relational/poam/models.go Outdated
…Item

Remove free-text PocName and PocEmail fields from the PoamItem model,
CreatePoamItemParams, and all handler request/response structs.

Replace with the existing PrimaryOwnerUserID FK pattern already used on
the Risk model, which links to the users table for structured ownership.
ResourceRequired is retained as a free-text operational field.

On promotion, the POAM item automatically inherits the risk's
PrimaryOwnerUserID. Callers may override it via primaryOwnerUserId in
the request body.

Addresses review comment from gusfcarvalho on models.go line 85.

- Remove PocName, PocEmail from PoamItem model (GORM + JSON tags)
- Remove from CreatePoamItemParams and service Create/CreateWithTx
- Remove from PromoteToPoamParams; inherit risk.PrimaryOwnerUserID
- Remove from promoteToPoamRequest handler struct
- Remove from createPoamItemRequest, poamItemResponse, toPoamItemResponse
- Update integration tests: assert PrimaryOwnerUserID instead of PocName/PocEmail
- Regenerate Swagger docs
…eritance

- Run swag fmt to fix check-diff: poam_items.go, poam/models.go, poam/service.go
  annotations were not formatted to match swag fmt output on CI
- Fix TestPromoteToPoam_HappyPath and TestPromoteToPoam_SSPScoped_HappyPath:
  create risk with explicit primary-owner-user-id so PrimaryOwnerUserID
  inheritance assertion is testable (dummy@example.com is not persisted to DB
  so actor-based resolution returns nil)
Comment thread internal/api/handler/risks.go Outdated
)

func NewRiskHandler(sugar *zap.SugaredLogger, db *gorm.DB) *RiskHandler {
func NewRiskHandler(sugar *zap.SugaredLogger, db *gorm.DB, poamSvc *poamsvc.PoamService, riskSvc ...*riskrel.RiskService) *RiskHandler {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we passing an optional riskSvc here? We should enforce this to be one entry, and change the signature where used.

Comment thread internal/api/handler/risks.go Outdated
Comment on lines +38 to +43
var rs *riskrel.RiskService
if len(riskSvc) > 0 && riskSvc[0] != nil {
rs = riskSvc[0]
} else {
rs = riskrel.NewRiskService(db)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same - if we are injecting the risk service now, we should probably remove all of these lines and just enforce the risk service to be used. It's the best approach since we are now creating the risk service under api.go

Comment thread internal/api/handler/risks.go
Comment thread internal/api/handler/risks.go
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements “Risk → POAM promotion” and ties POAM completion back into the Risk lifecycle, enabling atomic creation of a POAM item (plus milestones/links) from a risk and emitting new risk event types to reflect these lifecycle transitions.

Changes:

  • Add POST /risks/:id/promote-to-poam (and SSP-scoped variant) to create a POAM item from a risk and transition the risk to mitigating-planned.
  • Add POAM completion callback logic to advance linked risks to mitigating-implemented and emit poam_completed events.
  • Extend POAM item model/API with resourceRequired, add POAM service CreateWithTx, and update Swagger docs.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
internal/service/relational/risks/promote_to_poam.go New transactional promotion flow from risk to POAM item + event emission
internal/service/relational/risks/poam_completion.go New hook to advance linked risks when a POAM item is completed
internal/service/relational/risks/events.go Add poam_promoted / poam_completed event types and detail strings
internal/service/relational/poam/service.go Add ResourceRequired to create params and new CreateWithTx helper
internal/service/relational/poam/models.go Add ResourceRequired field to PoamItem
internal/api/handler/risks.go Add promote-to-POAM handlers + inject PoamService into RiskHandler
internal/api/handler/poam_items.go Expose resourceRequired; trigger risk advancement on POAM completion
internal/api/handler/api.go Wire shared PoamService/RiskService into handlers
internal/api/handler/risks_promote_to_poam_integration_test.go New integration coverage for promote-to-POAM + completion lifecycle
docs/swagger.yaml Document new endpoints + resourceRequired
docs/swagger.json Generated Swagger JSON updates for the same
docs/docs.go Generated docs template updates for the same

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// 1. Load and lock the risk row.
var risk Risk
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Preload("OwnerAssignments").
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OwnerAssignments is preloaded when loading the risk, but this method never reads it (and getRiskSnapshot() preloads it again later). This adds an unnecessary query and extra row data under the risk row lock; consider dropping this Preload here.

Suggested change
Preload("OwnerAssignments").

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we might need to use this for the primaryOwner of the POAM... if not, we should indeed just remove it.

Comment on lines +120 to +126
offset := len(templateMilestones)
for i, extra := range params.ExtraMilestones {
if extra.OrderIndex == 0 {
extra.OrderIndex = offset + i
}
templateMilestones = append(templateMilestones, extra)
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extra.OrderIndex == 0 is being used as a sentinel for “unset”, but OrderIndex is an int where 0 can be a legitimate value (e.g., 0-based ordering). As written, a client cannot explicitly request orderIndex: 0 because it will be rewritten to offset+i. Consider changing CreateMilestoneParams.OrderIndex to *int (or adding an explicit boolean) so you can distinguish omitted vs provided values.

Copilot uses AI. Check for mistakes.
Comment thread internal/service/relational/poam/service.go
Comment thread internal/service/relational/poam/service.go
Comment thread internal/api/handler/risks.go Outdated
Comment on lines +2005 to +2006
string(riskrel.RiskStatusClosed): {},
string(riskrel.RiskStatusMitigatingPlanned): {},
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateStatusTransition now allows risk-accepted -> mitigating-planned, but RiskService.PromoteToPoam currently rejects risk-accepted and only allows investigating. This creates an inconsistent state machine (clients could transition accepted risks to mitigating-planned via update even though promotion disallows it). Please align the allowed transitions with the promotion rules/intended spec.

Suggested change
string(riskrel.RiskStatusClosed): {},
string(riskrel.RiskStatusMitigatingPlanned): {},
string(riskrel.RiskStatusClosed): {},

Copilot uses AI. Check for mistakes.
Comment thread internal/service/relational/poam/models.go
Comment on lines +349 to +350
// Ensure the testing import is used.
var _ = (*testing.T)(nil)
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test file imports testing only to satisfy the compiler via var _ = (*testing.T)(nil). Consider removing the testing import and the dummy assignment (it’s not needed since the suite already provides suite.T()), which will keep the test file cleaner.

Copilot uses AI. Check for mistakes.
Comment thread internal/service/relational/risks/promote_to_poam.go
- fix(handler): make riskSvc a required parameter in NewRiskHandler,
  removing the variadic fallback pattern (gusfcarvalho review)

- fix(workflow): add mitigating-planned → investigating transition so a
  failed mitigation can return to investigation (gusfcarvalho review)

- fix(workflow): add mitigating-implemented → remediated transition so
  fully-evidenced risks can be marked remediated before close
  (gusfcarvalho review)

- fix(workflow): remove risk-accepted → mitigating-planned from
  validateStatusTransition to align with PromoteToPoam which only
  allows promotion from investigating (Copilot review)

- fix(workflow): replace risk-accepted → mitigating-planned with
  risk-accepted → investigating to allow re-opening accepted risks

- fix(service): remove unnecessary Preload(OwnerAssignments) from the
  SELECT FOR UPDATE lock query in PromoteToPoam; PrimaryOwnerUserID is
  read directly from the Risk struct (Copilot + gusfcarvalho review)

- fix(service): change CreateMilestoneParams.OrderIndex from int to
  *int so callers can explicitly set 0 without it being treated as
  unset; update all call sites in Create, CreateWithTx, AddMilestone,
  and the PromoteToPoam handler (Copilot review)

- fix(service): add NOTE godoc to CreateWithTx documenting that
  EvidenceIDs, ControlRefs, and FindingIDs are intentionally not
  processed (gusfcarvalho: documentation only is sufficient)

- fix(test): remove dummy testing import and var _ = (*testing.T)(nil)
  sentinel from risks_promote_to_poam_integration_test.go (Copilot review)
@gusfcarvalho gusfcarvalho merged commit abbd822 into main Mar 27, 2026
4 checks passed
@gusfcarvalho gusfcarvalho deleted the feat/poam-promote-to-poam branch March 27, 2026 10:06
gusfcarvalho added a commit that referenced this pull request Apr 9, 2026
* feat(poam): BCH-1186 — promote risk to POAM item

Implements POST /risks/:id/promote-to-poam and the SSP-scoped variant
POST /oscal/system-security-plans/:sspId/risks/:id/promote-to-poam.

Changes:
- poam/models.go: add PocName, PocEmail, ResourceRequired fields to PoamItem
- poam/service.go: extend CreatePoamItemParams with poc/resource fields;
  add CreateWithTx for use within an external transaction
- risks/events.go: add RiskEventTypePoamPromoted constant + BuildRiskEventDetails case
- risks/promote_to_poam.go: new PromoteToPoam service method — validates
  risk-accepted status, guards against re-promotion of active POAM,
  copies RemediationTemplate tasks as milestones, merges extra milestones
  from request, creates POAM item + risk link + risk event atomically
- handler/poam_items.go: expose PocName, PocEmail, ResourceRequired in
  createPoamItemRequest and poamItemResponse
- handler/risks.go: add promoteToPoamRequest, PromoteToPoam and
  PromoteToPoamForSSP handlers; inject PoamService into RiskHandler
- handler/api.go: pass poamService to NewRiskHandler
- risks_promote_to_poam_integration_test.go: 7 integration tests covering
  happy path, defaults, 422 rejection cases, SSP-scoped, and remediation
  template milestone merging

Depends-on: BCH-1185 (POAM Item CRUD), BCH-1182 (Risk Accept + Review)

* fix(poam): BCH-1186 CI fixes — regenerate swagger docs and fix test order-index

- docs/: regenerate swagger docs to include new promote-to-poam endpoints
  (fixes check-diff CI failure — docs were stale after adding handler annotations)
- risks_promote_to_poam_integration_test.go: fix remediation template task
  order-index values from 0-based to 1-based (validation requires > 0)
  (fixes TestPromoteToPoam_WithRemediationTemplate integration test failure)
- poam/models.go: include GORM column tags for PocName, PocEmail,
  ResourceRequired to ensure AutoMigrate generates correct column names

* fix(poam): BCH-1186 address review comments — investigating status + lifecycle transitions

- Change PromoteToPoam guard: risk must be in 'investigating' status (not
  'risk-accepted'). A formally accepted risk has been deemed tolerable and
  should not receive a remediation plan; POAM promotion is only valid while
  the risk is actively being investigated.

- Transition risk to 'mitigating-planned' on promotion: when a POAM item is
  created, the risk advances from investigating → mitigating-planned inside
  the same atomic transaction, emitting status_changed + poam_promoted events.

- Add POAM completion lifecycle callback (poam_completion.go): when a POAM
  item transitions to 'completed', OnPoamItemCompleted advances all linked
  risks from mitigating-planned → mitigating-implemented, emitting
  status_changed + poam_completed events per risk.

- Add RiskEventTypePoamCompleted constant and event details case.

- Update validateStatusTransition: allow risk-accepted → mitigating-planned
  for manual transitions.

- Wire riskService into PoamItemsHandler for the completion callback; share
  a single RiskService instance across both handlers via api.go.

- Update all integration tests: remove accept step (risks are promoted from
  investigating, not risk-accepted); add assertions for mitigating-planned
  after promotion; add TestPromoteToPoam_CompletionAdvancesRiskStatus for
  the full investigating → mitigating-planned → mitigating-implemented path.

- Regenerate Swagger docs.

* fix(poam): replace poc_name/poc_email with primaryOwnerUserId on PoamItem

Remove free-text PocName and PocEmail fields from the PoamItem model,
CreatePoamItemParams, and all handler request/response structs.

Replace with the existing PrimaryOwnerUserID FK pattern already used on
the Risk model, which links to the users table for structured ownership.
ResourceRequired is retained as a free-text operational field.

On promotion, the POAM item automatically inherits the risk's
PrimaryOwnerUserID. Callers may override it via primaryOwnerUserId in
the request body.

Addresses review comment from gusfcarvalho on models.go line 85.

- Remove PocName, PocEmail from PoamItem model (GORM + JSON tags)
- Remove from CreatePoamItemParams and service Create/CreateWithTx
- Remove from PromoteToPoamParams; inherit risk.PrimaryOwnerUserID
- Remove from promoteToPoamRequest handler struct
- Remove from createPoamItemRequest, poamItemResponse, toPoamItemResponse
- Update integration tests: assert PrimaryOwnerUserID instead of PocName/PocEmail
- Regenerate Swagger docs

* fix(poam): BCH-1186 CI fixes — swag fmt formatting and test owner inheritance

- Run swag fmt to fix check-diff: poam_items.go, poam/models.go, poam/service.go
  annotations were not formatted to match swag fmt output on CI
- Fix TestPromoteToPoam_HappyPath and TestPromoteToPoam_SSPScoped_HappyPath:
  create risk with explicit primary-owner-user-id so PrimaryOwnerUserID
  inheritance assertion is testable (dummy@example.com is not persisted to DB
  so actor-based resolution returns nil)

* fix(poam): BCH-1186 address PR #362 review comments

- fix(handler): make riskSvc a required parameter in NewRiskHandler,
  removing the variadic fallback pattern (gusfcarvalho review)

- fix(workflow): add mitigating-planned → investigating transition so a
  failed mitigation can return to investigation (gusfcarvalho review)

- fix(workflow): add mitigating-implemented → remediated transition so
  fully-evidenced risks can be marked remediated before close
  (gusfcarvalho review)

- fix(workflow): remove risk-accepted → mitigating-planned from
  validateStatusTransition to align with PromoteToPoam which only
  allows promotion from investigating (Copilot review)

- fix(workflow): replace risk-accepted → mitigating-planned with
  risk-accepted → investigating to allow re-opening accepted risks

- fix(service): remove unnecessary Preload(OwnerAssignments) from the
  SELECT FOR UPDATE lock query in PromoteToPoam; PrimaryOwnerUserID is
  read directly from the Risk struct (Copilot + gusfcarvalho review)

- fix(service): change CreateMilestoneParams.OrderIndex from int to
  *int so callers can explicitly set 0 without it being treated as
  unset; update all call sites in Create, CreateWithTx, AddMilestone,
  and the PromoteToPoam handler (Copilot review)

- fix(service): add NOTE godoc to CreateWithTx documenting that
  EvidenceIDs, ControlRefs, and FindingIDs are intentionally not
  processed (gusfcarvalho: documentation only is sufficient)

- fix(test): remove dummy testing import and var _ = (*testing.T)(nil)
  sentinel from risks_promote_to_poam_integration_test.go (Copilot review)

* fix(poam): normalise swag fmt whitespace in validateStatusTransition map

* feat(worker): BCH-1186 Phase 3 — POAM background jobs

Add three scheduled River background jobs that mirror the Risk Review
Deadline Scanner pattern for POAM lifecycle management:

1. POAM Deadline Reminder (daily, 0 8 * * *)
   - Scans open|in-progress items where deadline - now <= 30 days AND deadline > now
   - Enqueues poam_deadline_reminder per item per owner
   - Idempotency key: PoamItemID + ReminderWindowBucket (calendar day)

2. POAM Overdue Transition (daily, 0 9 * * *)
   - Scans open|in-progress items where deadline < now
   - Transitions status to overdue in DB (with updated_at)
   - Enqueues poam_overdue_notification per item per owner
   - Idempotency key: PoamItemID + OverdueWindow (calendar day)
   - Already-overdue items are excluded (idempotent)

3. Incomplete Milestone Scanner (weekly, 0 8 * * 1)
   - Scans planned milestones where planned_completion_date < now
     AND parent poam_item.status != completed
   - Enqueues poam_milestone_overdue_reminder per milestone per owner
   - Idempotency key: MilestoneID + WeeklyBucket (ISO week)

New files:
  - poam_job_types.go        — args structs, Kind/Timeout/InsertOpts helpers
  - poam_helpers.go          — shared resolvePoamRecipients, resolvePoamSSPDisplayName,
                               resolvePoamURL, GORMUserRepository helpers
  - poam_deadline_reminder_worker.go  — scanner + notification worker
  - poam_overdue_transition_worker.go — scanner + DB transition + notification worker
  - poam_milestone_overdue_worker.go  — scanner + notification worker
  - poam_periodic_jobs.go    — PeriodicJob constructors for River scheduler
  - internal/config/poam.go  — PoamConfig (DeadlineReminderSchedule,
                               OverdueTransitionSchedule, MilestoneOverdueSchedule)

Updated files:
  - jobs.go     — register 3 notification workers in Workers()
  - service.go  — register 3 scanner workers + poam queue in buildRiverConfig,
                  add 3 PeriodicJobs in periodicJobsFromConfig
  - config.go   — add Poam *PoamConfig to Config struct

Tests:
  - 12 unit tests (SQLite in-memory) covering all query boundary conditions:
    window inclusion/exclusion, status filtering, idempotency
  - 7 integration tests (Postgres via IntegrationTestSuite) covering:
    full scanner→DB mutation→notification worker pipeline,
    idempotency bucket key consistency, completed-parent exclusion

All unit tests pass. Integration tests compile cleanly (require Postgres).

* feat(worker): BCH-1186 Phase 3 — POAM open digest background jobs

Implements the periodic POAM open digest as specified in BCH-1186 Phase 3.

Workers added:
- PoamOpenDigestSchedulerWorker: periodic River job (daily/weekly, configurable)
  that resolves recipients from active POAM item owners and enqueues one
  PoamOpenDigestArgs job per recipient.
- PoamOpenDigestWorker: per-recipient worker that loads POAM items, classifies
  them into five buckets, and sends the grouped HTML+text digest email.

Digest buckets:
- NewSinceLastDigest  — items created within the current digest window
- Overdue             — items with status: overdue
- ApproachingDeadline — items with deadline within 30 days (open/in-progress)
- MilestonesDueSoon   — planned milestones with due_date within 14 days
- Stale               — open items with no update in >30 days

River config:
- Queue: poam | MaxAttempts: 3
- UniqueOpts.ByArgs: true | UniqueOpts.ByPeriod: 24h (daily) or 7d (weekly)
- Timeout: 30s per recipient digest

Bug fixes:
- jobs.go: removed incorrect NewPoamDigestSchedulerWorker call (scheduler is
  registered in service.go via clientProxy, not in jobs.go Workers())
- poam_digest_worker.go: replaced PostgreSQL-specific ::text cast with
  CAST(... AS TEXT) for SQLite compatibility in unit tests
- poam_digest_worker.go: corrected table name to ccf_poam_items

Files added:
- internal/service/worker/poam_digest_worker.go
- internal/service/worker/poam_digest_worker_test.go (35 unit tests, all pass)
- internal/service/email/templates/templates/poam-open-digest.html
- internal/service/email/templates/templates/poam-open-digest.txt

Files modified:
- internal/config/poam.go — OpenDigestWindow field + validation
- internal/service/worker/poam_job_types.go — PoamOpenDigestSchedulerArgs/PoamOpenDigestArgs
- internal/service/worker/poam_periodic_jobs.go — NewPoamOpenDigestPeriodicJob
- internal/service/worker/service.go — scheduler worker registration
- internal/service/worker/jobs.go — per-recipient digest worker registration
- internal/service/worker/poam_workers_integration_test.go — 3 digest integration tests

* fix(worker): align PoamOpenDigestWorker structure with RiskOpenDigestWorker

Structural consistency fixes following audit against Risk digest (BCH-1184)
canonical pattern:

1. Add PoamItemID field to PoamDigestEmailItem for test assertions
   - Mirrors how RiskDigestEmailItem carries RiskID for integration test
     assertions; field is not rendered in the email template

2. Add RiskNotificationsSubscribed subscription check to PoamOpenDigestWorker
   - Risk digest skips unsubscribed users; POAM must do the same
   - Reuses existing RiskNotificationsSubscribed field until a dedicated
     PoamNotificationsSubscribed column is added

3. Add nil-db guard in PoamOpenDigestWorker.Work
   - Mirrors the equivalent guard in RiskOpenDigestWorker

4. Align templateData keys: use user.FullName() instead of manual
   FirstName+LastName concatenation; add Has* boolean flags
   (HasNewSinceLast, HasOverdue, HasApproachingDeadline, HasMilestonesDueSoon,
   HasStale) to match the Risk digest template data contract

5. Check result.Success and log result.MessageID after emailService.Send
     POAM digest now does the same

6. Fix integration test: replace undefined poamDigestEmailItem (lowercase)
   with exported PoamDigestEmailItem; add risk_notifications_subscribed=true
   to seedUser so the digest worker does not skip the test recipient

All 35 unit tests and go vet pass.

* fix(ci): resolve lint and check-diff failures

Lint (golangci-lint unused):
- Remove unused resolveUserEmail helper from poam_helpers.go
  (function was added speculatively but never called; also removes
  the now-orphaned 'errors' import)

check-diff (swag fmt / swag init):
- Apply swag fmt alignment fixes across four files that CI regenerates
  and checks for dirtiness:
  * internal/config/poam.go — align struct tag spacing
  * internal/service/worker/poam_deadline_reminder_worker.go — sort imports
  * internal/service/worker/poam_job_types.go — align const/field spacing
  * internal/service/worker/poam_periodic_jobs.go — remove blank line
- Regenerate docs/ from swag init so check-diff sees no diff

* fix(worker): clean up stale risks on SSP profile change (BCH-1186)

Root cause: RiskEvidenceReconciliationScannerWorker only reconciled
duplicate dedupe keys. Open auto-generated risks whose SSP profile was
swapped or unbound were never cleaned up because handleEvidenceResolution
only runs on evidence arrival.

Fix: Extend the reconciliation scanner with a second pass (Pass 2) that
queries all open auto-generated risks with at least one control link and
enqueues a RiskOrphanedControlsArgs job per risk.

New RiskOrphanedControlsWorker:
- Loads the risk's control links (risk_control_links.catalog_id + control_id)
- Loads the SSP's current profile controls via profile_controls JOIN ssp
- If zero intersection (or SSP has no profile): transitions risk to
  remediated with an 'orphaned_profile_change' audit event
- Skips manually created risks (risk_template_id IS NULL)
- Skips already terminal risks (closed, remediated)
- No-ops if risk was deleted between scan and execution

Handles both cases:
  - Single-profile swap: old profile's controls absent from new profile
  - Multi-profile detach: SSP profile_id set to NULL

Tests added:
  - TestRiskOrphanedControlsWorker_ProfileUnbound
  - TestRiskOrphanedControlsWorker_ControlStillInProfile
  - TestRiskOrphanedControlsWorker_ControlRemovedFromProfile
  - TestRiskOrphanedControlsWorker_SkipsManualRisk
  - TestRiskOrphanedControlsWorker_SkipsAlreadyTerminal
  - TestRiskOrphanedControlsWorker_DeletedRiskIsNoop
  - TestReconciliationScannerPass2_EnqueuesOrphanJobs
  - TestReconciliationScannerPass2_NoOrphans

Also: added RiskControlLink to newRiskWorkersTestDB migration list so
the existing TestRiskEvidenceReconciliationScannerWorker test continues
to pass with the new Pass 2 query.

* fix(poam): address PR review — add TODO for milestone responsible_party_user_id FK

Reviewer (gusfcarvalho) noted that milestone overdue notifications are sent
to the POAM item owner rather than the milestone's responsible party.

The current ccf_poam_item_milestones.responsible_party column is a free-text
string with no FK to the users table, so we cannot resolve an email address
from it. Added a TODO comment at the notification dispatch site explaining
the constraint and the required follow-on schema migration
(add responsible_party_user_id uuid FK to ccf_poam_item_milestones).

Closes reviewer comment on PR #366.

* fix(email): add missing POAM notification email templates

Three workers referenced email templates that did not exist as embedded
files, causing the email service to fail at runtime with a template-not-
found error:

  - poam-overdue-notification  (PoamOverdueNotificationWorker)
  - poam-deadline-reminder     (PoamDeadlineReminderWorker)
  - poam-milestone-overdue-reminder (MilestoneOverdueReminderWorker)

Added HTML and plain-text variants for all three templates, following
the style and variable conventions established by the existing
poam-open-digest and risk-* templates.

Also added TestTemplateService_Poam* unit tests to the template service
test suite so that missing templates are caught at test time rather than
at runtime.

* Revert "fix(worker): clean up stale risks on SSP profile change (BCH-1186)"

This reverts commit b1ea739.

* fix(poam): address PR #366 review comments

- Remove WebBaseURL from PoamConfig; workers use top-level Config.WebBaseURL
- Wire ReminderWindowDays from PoamConfig into PoamDeadlineReminderScannerWorker
  replacing the hard-coded 30-day constant; constructor now accepts a
  reminderWindow time.Duration parameter with a safe positive-value guard
- Simplify service.go POAM block to resolve poamCfg once and reuse it for
  both the scanner window and the digest window kind
- Call cfg.Validate() in LoadPoamConfig after unmarshal so invalid cron
  schedules and misconfigured fields fail fast at startup, consistent with
  RiskConfig and WorkflowConfig loading patterns
- Extend PoamConfig.Validate() to check open_digest_window is 'daily' or
  'weekly' and that ReminderWindowDays is a positive integer
- Fix 5-field cron comment examples in poam_job_types.go to 6-field format
  (0 0 8 * * * / 0 0 9 * * *) consistent with the rest of the codebase
- Update unit and integration test call sites for the new constructor signature

* chore: apply swag fmt struct field alignment in deadline reminder worker

---------

Co-authored-by: AKAbdulHanif <AKAbdulHanif@users.noreply.github.com>
Co-authored-by: Gustavo Fernandes de Carvalho <17139678+gusfcarvalho@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants