Skip to content

Feat/poam phase1#338

Open
AKAbdulHanif wants to merge 30 commits intocompliance-framework:mainfrom
AKAbdulHanif:feat/poam-phase1
Open

Feat/poam phase1#338
AKAbdulHanif wants to merge 30 commits intocompliance-framework:mainfrom
AKAbdulHanif:feat/poam-phase1

Conversation

@AKAbdulHanif
Copy link
Contributor

@AKAbdulHanif AKAbdulHanif commented Mar 4, 2026

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.

Previous Field New Field Rationale
deadline planned_completion_date Alignment with OSCAL terminology
due_date (milestone) scheduled_completion_date Alignment with OSCAL terminology
completed_at (milestone) completion_date Alignment with OSCAL terminology
None source_type Tracks origin (risk-promotion, manual, import)
None primary_owner_user_id Establishes ownership
None created_from_risk_id Provides traceability back to source risk
None acceptance_rationale Enhances audit trail capabilities
None last_status_change_at Enhances audit trail capabilities
None order_index (milestone) Enables explicit ordering of milestones

Additionally, new join tables were introduced to support many-to-many relationships with cascading deletes:

  • CcfPoamItemRiskLink
  • CcfPoamItemEvidenceLink
  • CcfPoamItemFindingLink
  • CcfPoamItemControlLink

API Enhancements

The HTTP handlers (internal/api/handler/poam_items.go) have been updated to reflect the new data models and provide comprehensive CRUD operations.

  • Payload Alignment: Request and response payloads now match the Confluence field definitions.
  • Advanced Filtering: The GET /poam-items endpoint now supports filtering by overdueOnly, ownerRef, status, sspId, riskId, and deadlineBefore.
  • Sub-resource Endpoints: New endpoints were added to manage relationships:
    • GET /:id/risks
    • GET /:id/evidence
    • GET /:id/controls
    • GET /:id/findings
  • Automated State Management: The system automatically updates last_status_change_at upon any status transition and sets completed_at when an item's status changes to completed.
  • Swagger Documentation: API documentation has been fully regenerated and formatted to reflect all changes.

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:

  • All CRUD operations for POAM items and milestones.
  • Filtering logic.
  • Cascading deletes for linked entities.
  • Lifecycle timestamp automation.
  • Uniqueness constraints.

AKAbdulHanif and others added 5 commits March 4, 2026 10:38
…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)
@@ -0,0 +1,69 @@
Title: POAM Phase 1 – API Design
Copy link
Contributor

Choose a reason for hiding this comment

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

this file can be removed. If this design is relevant to be kept, we should store in confluence!

Copy link
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

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-items CRUD 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
AKAbdulHanif and others added 7 commits March 9, 2026 08:16
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.
Copy link
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

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 {
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
if orderIdx == 0 {
// Use -1 as the sentinel for "auto" ordering so that 0 can be a valid explicit index.
if orderIdx == -1 {

Copilot uses AI. Check for mistakes.
Description string `json:"description"`
Status string `json:"status"`
ScheduledCompletionDate *time.Time `json:"scheduledCompletionDate"`
OrderIndex int `json:"orderIndex"`
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
OrderIndex int `json:"orderIndex"`
OrderIndex *int `json:"orderIndex"`

Copilot uses AI. Check for mistakes.
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 < ?",
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
"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 < ?",

Copilot uses AI. Check for mistakes.
Comment on lines +150 to +158
// 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"`
}
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +203 to +204
// Ensure the relational package is imported (used for SSP existence checks in the service).
var _ = relational.SystemSecurityPlan{}
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.

poamService := poamsvc.NewPoamService(db)
poamHandler := NewPoamItemsHandler(poamService, logger)
poamGroup := server.API().Group("/poam-items")
Copy link
Contributor

Choose a reason for hiding this comment

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

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"`
Copy link
Contributor

Choose a reason for hiding this comment

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

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)

Copy link
Contributor

@gusfcarvalho gusfcarvalho left a comment

Choose a reason for hiding this comment

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

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
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