Conversation
…es in BuildByProps
…ns after create and reload full profile
…ssociation explicitly
…atisfy check-diff
…wagger to reflect correct payload schema
…esign
Corrects the initial Phase 1 implementation (BCH-1175) to match the
Confluence v15 design document authored by Gustavo Carvalho.
## Model changes (poam_cf.go)
- Rename deadline -> planned_completion_date (OSCAL-aligned)
- Rename due_date -> scheduled_completion_date on milestones
- Rename completed_at -> completion_date on milestones
- Add lifecycle fields: source_type (enum: risk-promotion|manual|import),
primary_owner_user_id, created_from_risk_id, acceptance_rationale,
last_status_change_at, completed_at on PoamItem
- Add order_index on CcfPoamItemMilestone
- Add CcfPoamItemEvidenceLink, CcfPoamItemFindingLink, CcfPoamItemControlLink
link tables (in addition to the existing risk link table)
- Remove Jira-only fields: poc_name, poc_email, poc_phone, resource_required,
remarks (these were ticket simplifications not in Confluence design)
## Migrator changes (migrator.go, tests/migrate.go)
- Register all four link tables in both production and test migrators:
CcfPoamItemRiskLink, CcfPoamItemEvidenceLink, CcfPoamItemFindingLink,
CcfPoamItemControlLink
## Handler changes (poam_items.go)
- Update createPoamRequest / updatePoamRequest to use Confluence field names
- Add EvidenceIDs, FindingIDs, ControlRefs to create payload
- Add overdueOnly and ownerRef query filters to GET /poam-items
- Add link sub-resource endpoints: GET /:id/risks, /evidence, /controls, /findings
- Set last_status_change_at on every status transition
- Set completed_at on PoamItem when status -> completed
- Rename controlRef -> poamControlRef to avoid collision with filter_import.go
- Update milestone create/update to use scheduled_completion_date and
completion_date field names
## Test changes (poam_items_integration_test.go)
- Complete rewrite: 35 integration test cases covering
- POST with minimal payload, milestones, risk links, all link types, invalid input
- GET list with all filters: status, sspId, riskId, dueBefore, overdueOnly, ownerRef
- GET /:id with milestone ordering and all link sets
- PUT scalar fields, status->completed sets completed_at, status change sets
last_status_change_at, not-found
- DELETE with cascade verification across all link tables
- GET/POST/PUT/DELETE milestones including completion_date auto-set
- GET sub-resource link endpoints (risks, evidence, controls, findings)
- Uniqueness constraint enforcement on duplicate risk links
Resolved all six merge conflicts introduced when main received new risk/template handler registrations and profile endpoint updates: - internal/api/handler/api.go: kept both POAM handler registration (from feat/poam-phase1) and new risk/template handler registrations (from main) — both sets of handlers are now present - internal/api/handler/oscal/profiles.go: accepted main's version (our branch did not touch this file) - internal/api/handler/oscal/profiles_integration_test.go: accepted main's version (our branch did not touch this file) - docs/docs.go, docs/swagger.json, docs/swagger.yaml: regenerated via 'make swag' to include both main's new endpoints and the new POAM Items endpoints from this branch Also fixed malformed Swagger annotation formatting in poam_items.go (missing spaces between annotation keywords and their arguments) so that 'swag init' parses them correctly. All 13 POAM endpoints now appear in the generated Swagger spec under /poam-items. Build: go build ./... ✅ | go vet ./... ✅
The BeforeUpdate GORM hook on CcfPoamItem already calls
tx.Statement.SetColumn('LastStatusChangeAt', ...) whenever the
Status column changes. The Update handler was also setting
updates['last_status_change_at'] in the same map, causing Postgres
to receive a duplicate column assignment in the generated UPDATE
statement (SQLSTATE 42601).
Fix: remove the explicit map entry from the handler and rely
exclusively on the BeforeUpdate hook.
Also fixed integration test compile errors introduced by the merge
with main:
- RegisterHandlers signature changed from 6 to 5 args (removed
extra nil)
- controlRef renamed to poamControlRef to avoid package collision
with filter_import.go
…ailure in all test suites GORM's check: tag generates CHECK (tablename_columnname IN (...)) which is invalid Postgres syntax - Postgres expects just the column name. This caused every integration test suite's MigrateUp to fail with: ERROR: column "ccf_poam_items_status" does not exist (SQLSTATE 42703) Changes: - Remove check: tags from CcfPoamItem.Status, CcfPoamItem.SourceType, and CcfPoamItemMilestone.Status (no other model in the codebase uses check:) - Remove now-unused gorm.io/gorm import from poam_cf.go - Remove BeforeUpdate hook (map-based Updates() bypasses GORM hooks entirely; last_status_change_at is set directly in the handler's updates map instead) - Re-add last_status_change_at to the Update handler's updates map (was removed in previous commit to fix a duplicate-column error, but the BeforeUpdate hook cannot fire on map-based updates so it must be explicit)
docs/POAM-Design.md
Outdated
| @@ -0,0 +1,69 @@ | |||
| Title: POAM Phase 1 – API Design | |||
There was a problem hiding this comment.
this file can be removed. If this design is relevant to be kept, we should store in confluence!
There was a problem hiding this comment.
Pull request overview
Adds Phase 1 “CCF POAM” foundations to support POAM item CRUD and milestone management for the Risk Register, including persistence, HTTP endpoints, and generated OpenAPI documentation.
Changes:
- Introduces new relational models for
CcfPoamItem, milestones, and link/join tables (risk/evidence/control/finding). - Adds
/api/poam-itemsCRUD endpoints plus milestone and link sub-resource endpoints. - Adds integration test suite for the new POAM endpoints and updates DB migration wiring + Swagger artifacts.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/tests/migrate.go | Ensures test DB migrations include new POAM tables. |
| internal/service/relational/poam_cf.go | New GORM models for POAM items, milestones, and link tables. |
| internal/service/migrator.go | Adds new POAM models to application migrations up/down. |
| internal/api/handler/poam_items.go | New POAM REST handler (CRUD, milestones, link list endpoints) + swag annotations. |
| internal/api/handler/poam_items_integration_test.go | Integration coverage for POAM item/milestone flows and filters. |
| internal/api/handler/api.go | Registers POAM handler into the main API router. |
| docs/swagger.yaml | Generated OpenAPI updates for POAM endpoints/models. |
| docs/swagger.json | Generated OpenAPI updates for POAM endpoints/models. |
| docs/docs.go | Generated Swagger doc template updates. |
| docs/POAM-Design.md | New design doc describing POAM Phase 1. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Extract all DB logic from handler into internal/service/relational/poam/
- models.go: PoamItem, PoamItemMilestone, four link types, status/source
constants, BeforeCreate UUID hook, CreateParams, UpdateParams,
CreateMilestoneParams, UpdateMilestoneParams, ListFilters, ControlRef
- queries.go: ApplyFilters (status, sspId, riskId, deadlineBefore,
overdueOnly, ownerRef)
- service.go: PoamService with List, Create, GetByID, Update, Delete,
EnsureExists, EnsureSSPExists, ListMilestones, AddMilestone,
UpdateMilestone, DeleteMilestone, ListRiskLinks, AddRiskLink,
DeleteRiskLink, ListEvidenceLinks, AddEvidenceLink, DeleteEvidenceLink,
ListControlLinks, AddControlLink, DeleteControlLink, ListFindings,
AddFindingLink, DeleteFindingLink
- Rewrite handler (poam_items.go) to use service only — zero gorm imports;
typed response structs (poamItemResponse, milestoneResponse, link types);
@Security OAuth2Password on all Swagger annotations
- Add all missing link CRUD endpoints:
POST/DELETE /:id/risks/:riskId
POST/DELETE /:id/evidence/:evidenceId
POST/DELETE /:id/controls (catalogId+controlId path)
POST/DELETE /:id/findings/:findingId
- Fix check: GORM tag bug (generated invalid Postgres CHECK constraint
with tablename_column prefix); remove BeforeUpdate hook (bypassed by
map-based Updates()); set last_status_change_at directly in update map
- Update migrator.go and tests/migrate.go to use new poam package types
- Remove old poam_cf.go from relational package
- Rewrite integration tests: use poamsvc types for seeding, correct
response struct unmarshalling, idempotent duplicate-link test
Sandbox validated: 35/35 tests pass against live Postgres instance
- Remove docs/POAM-Design.md (design belongs in Confluence, not repo)
- Add has-many associations for all 4 link tables on PoamItem model
- GetByID now preloads RiskLinks, EvidenceLinks, ControlLinks, FindingLinks
- toPoamItemResponse populates all link arrays in the response body
- Update handler uses typed struct Save() not raw map[string]interface{}
- UpdatePoamItemParams includes AddRiskIDs/RemoveRiskIDs and equivalents
for evidence, controls, findings (Gus: link management in Update)
- validate:required tags on createPoamItemRequest.SspID and Title;
ctx.Validate() called before processing (Copilot: missing validation)
- parsePoamListFilters returns 400 on malformed UUID/RFC3339 params
(Copilot: invalid query params silently ignored)
- last_status_change_at stamped only on actual status transition
(Copilot: was stamped even when status unchanged)
- completedAt is server-controlled only; not settable via payload
(Copilot: completedAt could be set via request body)
- All First() errors discriminated: gorm.ErrRecordNotFound -> 404,
other errors -> 500 (Copilot: all DB errors mapped to 404)
- Link tables use composite primaryKey + constraint:OnDelete:CASCADE
matching the Risk service pattern (Copilot item 13)
- Integration tests updated to use poamsvc types and new response shapes
- Sandbox validation: 35/35 tests pass against live Postgres instance
…est updates, swagger regen)
Rename references to match the DDD-refactored handler types: - createPoamRequest -> createPoamItemRequest - updatePoamRequest -> updatePoamItemRequest - poamControlRef -> poamControlRefRequest - poamMilestoneResponse -> milestoneResponse (unexported, same package) All 35 integration test cases now compile and reference the correct types from the service-backed handler.
Run 'make swag' (go tool swag init + swag fmt) to bring the committed
docs/swagger.{json,yaml,docs.go} in sync with the current POAM handler
annotations, and apply whitespace-only alignment fixes produced by
swag fmt to poam_items.go, models.go, and service.go.
This fixes the check-diff CI job which regenerates swagger and asserts
a clean working tree.
…in test The AddControlLink test at line 910 still referenced the old type name poamAddControlLinkRequest. The correct type is poamControlRefRequest (the same struct used for both create-time and standalone link endpoints). go vet -tags integration now passes cleanly.
The POAM route group uses JWTMiddleware, so every test request must carry a valid Bearer token. Introduce an authedReq() suite helper that calls GetAuthToken() and sets the Authorization header, then update all 47 HTTP calls in the test file to use it. Also remove the now-unused 'strings' import and the blank-identifier workaround that was keeping it alive.
The Create handler calls EnsureSSPExists which queries system_security_plans. Tests that generate a random sspID without inserting the corresponding row were getting HTTP 404. Add an ensureSSP() helper and call it at the start of each TestCreate_* test that submits a valid sspId to the API.
The List handler reads 'deadlineBefore' (not 'dueBefore') from the query string. The test was sending the wrong param name so the filter was never applied, causing all items to be returned instead of just the past-due one.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 11 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| for i, mp := range params.Milestones { | ||
| orderIdx := mp.OrderIndex | ||
| if orderIdx == 0 { |
There was a problem hiding this comment.
Milestone ordering: treating OrderIndex==0 as "unset" means callers cannot intentionally set a milestone's order_index to 0 for any milestone other than the first. This will silently rewrite client-provided ordering (e.g., a second milestone with orderIndex=0 becomes 1). Consider making OrderIndex a *int in the request/params (so omitted vs 0 is distinguishable) or using a sentinel (e.g., -1) for "auto" ordering.
| if orderIdx == 0 { | |
| // Use -1 as the sentinel for "auto" ordering so that 0 can be a valid explicit index. | |
| if orderIdx == -1 { |
internal/api/handler/poam_items.go
Outdated
| Description string `json:"description"` | ||
| Status string `json:"status"` | ||
| ScheduledCompletionDate *time.Time `json:"scheduledCompletionDate"` | ||
| OrderIndex int `json:"orderIndex"` |
There was a problem hiding this comment.
The API request type uses orderIndex int, which makes it impossible to distinguish an omitted field from an explicit 0. This feeds into service-side logic that treats 0 as "unset". Consider changing this to *int (and updating validation/defaulting) so clients can intentionally set 0 when needed.
| OrderIndex int `json:"orderIndex"` | |
| OrderIndex *int `json:"orderIndex"` |
| if filters.OverdueOnly { | ||
| now := time.Now().UTC() | ||
| q = q.Where( | ||
| "ccf_poam_items.status IN ('open','in-progress') AND ccf_poam_items.planned_completion_date IS NOT NULL AND ccf_poam_items.planned_completion_date < ?", |
There was a problem hiding this comment.
PoamItemStatusOverdue is defined as a valid status, but the overdueOnly filter explicitly limits results to status IN ('open','in-progress'). This makes "overdue" behave inconsistently depending on whether it's treated as a computed state or a persisted status. Either include 'overdue' in this filter, or remove/avoid exposing 'overdue' as a persisted status value and compute overdue purely from planned_completion_date.
| "ccf_poam_items.status IN ('open','in-progress') AND ccf_poam_items.planned_completion_date IS NOT NULL AND ccf_poam_items.planned_completion_date < ?", | |
| "ccf_poam_items.status IN ('open','in-progress','overdue') AND ccf_poam_items.planned_completion_date IS NOT NULL AND ccf_poam_items.planned_completion_date < ?", |
| // PoamItemRiskLink is the join table linking PoamItems to Risks. | ||
| // Uses a composite primary key and OnDelete:CASCADE to match the Risk service | ||
| // link table pattern (e.g., risk_evidence_links). | ||
| type PoamItemRiskLink struct { | ||
| PoamItemID uuid.UUID `gorm:"type:uuid;primaryKey" json:"poamItemId"` | ||
| RiskID uuid.UUID `gorm:"type:uuid;primaryKey;index" json:"riskId"` | ||
| CreatedAt time.Time ` json:"createdAt"` | ||
| PoamItem *PoamItem `json:"-" gorm:"foreignKey:PoamItemID;references:ID;constraint:OnDelete:CASCADE"` | ||
| } |
There was a problem hiding this comment.
The join table only declares a foreign-key constraint back to PoamItem (via PoamItem pointer). There is no FK constraint for RiskID (and similarly for Evidence/Finding/Control links), so deleting a Risk/Evidence/Finding/Control can leave orphaned link rows and the DB won't enforce referential integrity. Consider adding the corresponding association fields + constraint:OnDelete:CASCADE (or at least FK constraints) for the other side of each link table.
| // Ensure the relational package is imported (used for SSP existence checks in the service). | ||
| var _ = relational.SystemSecurityPlan{} |
There was a problem hiding this comment.
var _ = relational.SystemSecurityPlan{} is only present to force the relational import, but this file otherwise doesn't use relational. This adds noise and can be removed by dropping the relational import from this file (the service already imports relational where it’s actually needed).
|
|
||
| poamService := poamsvc.NewPoamService(db) | ||
| poamHandler := NewPoamItemsHandler(poamService, logger) | ||
| poamGroup := server.API().Group("/poam-items") |
There was a problem hiding this comment.
I think this route should be under spp/<id>/poam-items right? As in - the plan of actions depends on the risk, which belongs to a given ssp. This was one of the things I fixed under risks this morning (supporting that other route)
| // Field names follow the Confluence design doc (v15). | ||
| type PoamItem struct { | ||
| ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` | ||
| SspID uuid.UUID `gorm:"type:uuid;not null;index" json:"sspId"` |
There was a problem hiding this comment.
yeah, we already have this on the model. Probably better to update endpoints to get the SSP id from the URL. You can leave the existing ones like that and implement additional ones that are SSP-bound (check the new risks code that was just merged)
gusfcarvalho
left a comment
There was a problem hiding this comment.
Other the two comments above, looks good! I asked copilot to do a final review
- orderIndex in createMilestoneRequest changed to *int so explicit 0
is distinguishable from an omitted field; inline milestone loop in
Create handler falls back to slice position when nil
- overdueOnly filter now includes status='overdue' in the IN clause so
items already persisted with that status are not silently excluded
- removed blank identifier var _ = relational.SystemSecurityPlan{} and
the relational import from models.go (import only needed in service.go)
- added DB-level FK absence comments on all four link tables explaining
the intentional cross-bounded-context design decision
- added SSP-scoped routes /system-security-plans/:sspId/poam-items via
RegisterSSPScoped; List and Create handlers inject the path param into
filters/body automatically so clients don't repeat the SSP ID
- regenerated Swagger docs
PR #338 Summary: POAM Phase 1 — Foundation
Technical Review Summary
This pull request establishes the foundational Phase 1 implementation for the Plan of Action and Milestones (POAM) feature within the Risk Register. It introduces the core data models, service layer, and REST API endpoints required to manage POAM items, their milestones, and their relationships with other entities.
Key Architectural Changes
The implementation follows a Domain-Driven Design (DDD) approach, introducing a dedicated service layer (
internal/service/relational/poam/service.go) to encapsulate business logic and database interactions, separating them from the HTTP handlers.Data Model Updates
The data models have been significantly refactored to align with the authoritative Confluence design document and OSCAL standards.
deadlineplanned_completion_datedue_date(milestone)scheduled_completion_datecompleted_at(milestone)completion_datesource_typeprimary_owner_user_idcreated_from_risk_idacceptance_rationalelast_status_change_atorder_index(milestone)Additionally, new join tables were introduced to support many-to-many relationships with cascading deletes:
CcfPoamItemRiskLinkCcfPoamItemEvidenceLinkCcfPoamItemFindingLinkCcfPoamItemControlLinkAPI Enhancements
The HTTP handlers (
internal/api/handler/poam_items.go) have been updated to reflect the new data models and provide comprehensive CRUD operations.GET /poam-itemsendpoint now supports filtering byoverdueOnly,ownerRef,status,sspId,riskId, anddeadlineBefore.GET /:id/risksGET /:id/evidenceGET /:id/controlsGET /:id/findingslast_status_change_atupon any status transition and setscompleted_atwhen an item's status changes tocompleted.Testing and Quality Assurance
The PR includes a robust suite of 35 integration tests (
internal/api/handler/poam_items_integration_test.go). These tests provide comprehensive coverage for: