diff --git a/docs/docs.go b/docs/docs.go index e018a0a6..313354be 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -18666,6 +18666,82 @@ const docTemplate = `{ ] } }, + "/oscal/system-security-plans/{sspId}/risks/{id}/promote-to-poam": { + "post": { + "description": "Promotes an investigating risk to a POAM item, scoped to a specific SSP. The risk must belong to the given SSP and be in investigating status. On success, the risk transitions to mitigating-planned.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Promote risk to POAM item (SSP-scoped)", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "sspId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Promotion payload", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/handler.promoteToPoamRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_poamItemResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/oscal/system-security-plans/{sspId}/risks/{id}/remediation-template": { "get": { "description": "Gets the remediation template linked to a risk scoped to an SSP.", @@ -21594,6 +21670,75 @@ const docTemplate = `{ ] } }, + "/risks/{id}/promote-to-poam": { + "post": { + "description": "Promotes an investigating risk to a POAM item and transitions the risk to mitigating-planned. The risk must be in investigating status (risk-accepted risks cannot be promoted — they have been formally accepted as tolerable). The POAM item is pre-populated from the risk's data and any RemediationTemplate tasks. The entire operation is transactional.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Promote risk to POAM item", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Promotion payload", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/handler.promoteToPoamRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_poamItemResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/risks/{id}/remediation-template": { "get": { "description": "Gets the remediation template linked to a risk.", @@ -28177,6 +28322,9 @@ const docTemplate = `{ "primaryOwnerUserId": { "type": "string" }, + "resourceRequired": { + "type": "string" + }, "riskIds": { "type": "array", "items": { @@ -28388,6 +28536,9 @@ const docTemplate = `{ "primaryOwnerUserId": { "type": "string" }, + "resourceRequired": { + "type": "string" + }, "riskLinks": { "type": "array", "items": { @@ -28411,6 +28562,34 @@ const docTemplate = `{ } } }, + "handler.promoteToPoamRequest": { + "type": "object", + "properties": { + "deadline": { + "description": "Deadline maps to PoamItem.PlannedCompletionDate.", + "type": "string" + }, + "milestones": { + "description": "Milestones are additional milestones to append after any copied from the\nrisk's RemediationTemplate.", + "type": "array", + "items": { + "$ref": "#/definitions/handler.createMilestoneRequest" + } + }, + "primaryOwnerUserId": { + "description": "PrimaryOwnerUserID optionally overrides the POAM item owner.\nIf omitted, the risk's own PrimaryOwnerUserID is inherited automatically.", + "type": "string" + }, + "resourceRequired": { + "description": "ResourceRequired is a free-text planning field describing effort or budget needed.", + "type": "string" + }, + "title": { + "description": "Title overrides the risk's title as the POAM item title.\nIf omitted, the risk's own title is used.", + "type": "string" + } + } + }, "handler.publicUserResponse": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 5cc68c8e..8239d118 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -18660,6 +18660,82 @@ ] } }, + "/oscal/system-security-plans/{sspId}/risks/{id}/promote-to-poam": { + "post": { + "description": "Promotes an investigating risk to a POAM item, scoped to a specific SSP. The risk must belong to the given SSP and be in investigating status. On success, the risk transitions to mitigating-planned.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Promote risk to POAM item (SSP-scoped)", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "sspId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Promotion payload", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/handler.promoteToPoamRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_poamItemResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/oscal/system-security-plans/{sspId}/risks/{id}/remediation-template": { "get": { "description": "Gets the remediation template linked to a risk scoped to an SSP.", @@ -21588,6 +21664,75 @@ ] } }, + "/risks/{id}/promote-to-poam": { + "post": { + "description": "Promotes an investigating risk to a POAM item and transitions the risk to mitigating-planned. The risk must be in investigating status (risk-accepted risks cannot be promoted — they have been formally accepted as tolerable). The POAM item is pre-populated from the risk's data and any RemediationTemplate tasks. The entire operation is transactional.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Promote risk to POAM item", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Promotion payload", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/handler.promoteToPoamRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_poamItemResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/risks/{id}/remediation-template": { "get": { "description": "Gets the remediation template linked to a risk.", @@ -28171,6 +28316,9 @@ "primaryOwnerUserId": { "type": "string" }, + "resourceRequired": { + "type": "string" + }, "riskIds": { "type": "array", "items": { @@ -28382,6 +28530,9 @@ "primaryOwnerUserId": { "type": "string" }, + "resourceRequired": { + "type": "string" + }, "riskLinks": { "type": "array", "items": { @@ -28405,6 +28556,34 @@ } } }, + "handler.promoteToPoamRequest": { + "type": "object", + "properties": { + "deadline": { + "description": "Deadline maps to PoamItem.PlannedCompletionDate.", + "type": "string" + }, + "milestones": { + "description": "Milestones are additional milestones to append after any copied from the\nrisk's RemediationTemplate.", + "type": "array", + "items": { + "$ref": "#/definitions/handler.createMilestoneRequest" + } + }, + "primaryOwnerUserId": { + "description": "PrimaryOwnerUserID optionally overrides the POAM item owner.\nIf omitted, the risk's own PrimaryOwnerUserID is inherited automatically.", + "type": "string" + }, + "resourceRequired": { + "description": "ResourceRequired is a free-text planning field describing effort or budget needed.", + "type": "string" + }, + "title": { + "description": "Title overrides the risk's title as the POAM item title.\nIf omitted, the risk's own title is used.", + "type": "string" + } + } + }, "handler.publicUserResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 3cd6f764..f77c5164 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1611,6 +1611,8 @@ definitions: type: string primaryOwnerUserId: type: string + resourceRequired: + type: string riskIds: items: type: string @@ -1753,6 +1755,8 @@ definitions: type: string primaryOwnerUserId: type: string + resourceRequired: + type: string riskLinks: items: $ref: '#/definitions/handler.riskLinkResponse' @@ -1768,6 +1772,33 @@ definitions: updatedAt: type: string type: object + handler.promoteToPoamRequest: + properties: + deadline: + description: Deadline maps to PoamItem.PlannedCompletionDate. + type: string + milestones: + description: |- + Milestones are additional milestones to append after any copied from the + risk's RemediationTemplate. + items: + $ref: '#/definitions/handler.createMilestoneRequest' + type: array + primaryOwnerUserId: + description: |- + PrimaryOwnerUserID optionally overrides the POAM item owner. + If omitted, the risk's own PrimaryOwnerUserID is inherited automatically. + type: string + resourceRequired: + description: ResourceRequired is a free-text planning field describing effort + or budget needed. + type: string + title: + description: |- + Title overrides the risk's title as the POAM item title. + If omitted, the risk's own title is used. + type: string + type: object handler.publicUserResponse: properties: id: @@ -20977,6 +21008,57 @@ paths: summary: Delete risk evidence link for SSP tags: - Risks + /oscal/system-security-plans/{sspId}/risks/{id}/promote-to-poam: + post: + consumes: + - application/json + description: Promotes an investigating risk to a POAM item, scoped to a specific + SSP. The risk must belong to the given SSP and be in investigating status. + On success, the risk transitions to mitigating-planned. + parameters: + - description: SSP ID + in: path + name: sspId + required: true + type: string + - description: Risk ID + in: path + name: id + required: true + type: string + - description: Promotion payload + in: body + name: body + schema: + $ref: '#/definitions/handler.promoteToPoamRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_poamItemResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Promote risk to POAM item (SSP-scoped) + tags: + - Risks /oscal/system-security-plans/{sspId}/risks/{id}/remediation-template: delete: description: Deletes the remediation template linked to a risk scoped to an @@ -22863,6 +22945,54 @@ paths: summary: Delete risk evidence link tags: - Risks + /risks/{id}/promote-to-poam: + post: + consumes: + - application/json + description: Promotes an investigating risk to a POAM item and transitions the + risk to mitigating-planned. The risk must be in investigating status (risk-accepted + risks cannot be promoted — they have been formally accepted as tolerable). + The POAM item is pre-populated from the risk's data and any RemediationTemplate + tasks. The entire operation is transactional. + parameters: + - description: Risk ID + in: path + name: id + required: true + type: string + - description: Promotion payload + in: body + name: body + schema: + $ref: '#/definitions/handler.promoteToPoamRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_poamItemResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Promote risk to POAM item + tags: + - Risks /risks/{id}/remediation-template: delete: description: Deletes the remediation template linked to a risk. diff --git a/internal/api/handler/api.go b/internal/api/handler/api.go index 6562527e..afbc7d21 100644 --- a/internal/api/handler/api.go +++ b/internal/api/handler/api.go @@ -11,6 +11,7 @@ import ( "github.com/compliance-framework/api/internal/service/digest" evidencesvc "github.com/compliance-framework/api/internal/service/relational/evidence" poamsvc "github.com/compliance-framework/api/internal/service/relational/poam" + riskrel "github.com/compliance-framework/api/internal/service/relational/risks" workflowsvc "github.com/compliance-framework/api/internal/service/relational/workflows" "github.com/compliance-framework/api/internal/workflow" "github.com/labstack/echo/v4" @@ -50,7 +51,8 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB evidenceHandler.Register(server.API().Group("/evidence")) poamService := poamsvc.NewPoamService(db) - poamHandler := NewPoamItemsHandler(poamService, logger) + riskService := riskrel.NewRiskService(db) + poamHandler := NewPoamItemsHandler(poamService, riskService, logger) // Flat route: /api/poam-items (supports ?sspId= query filter) poamGroup := server.API().Group("/poam-items") poamGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) @@ -61,7 +63,7 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB sspPoamGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) poamHandler.RegisterSSPScoped(sspPoamGroup) - riskHandler := NewRiskHandler(logger, db) + riskHandler := NewRiskHandler(logger, db, poamService, riskService) riskGroup := server.API().Group("/risks") riskGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) riskHandler.Register(riskGroup) diff --git a/internal/api/handler/poam_items.go b/internal/api/handler/poam_items.go index ae551a05..f6536fa7 100644 --- a/internal/api/handler/poam_items.go +++ b/internal/api/handler/poam_items.go @@ -7,7 +7,9 @@ import ( "time" "github.com/compliance-framework/api/internal/api" + "github.com/compliance-framework/api/internal/authn" poamsvc "github.com/compliance-framework/api/internal/service/relational/poam" + riskrel "github.com/compliance-framework/api/internal/service/relational/risks" "github.com/google/uuid" "github.com/labstack/echo/v4" "go.uber.org/zap" @@ -19,12 +21,13 @@ import ( // imports gorm directly for data access. type PoamItemsHandler struct { poamService *poamsvc.PoamService + riskService *riskrel.RiskService sugar *zap.SugaredLogger } // NewPoamItemsHandler constructs a PoamItemsHandler. -func NewPoamItemsHandler(svc *poamsvc.PoamService, sugar *zap.SugaredLogger) *PoamItemsHandler { - return &PoamItemsHandler{poamService: svc, sugar: sugar} +func NewPoamItemsHandler(svc *poamsvc.PoamService, riskSvc *riskrel.RiskService, sugar *zap.SugaredLogger) *PoamItemsHandler { + return &PoamItemsHandler{poamService: svc, riskService: riskSvc, sugar: sugar} } // Register mounts all POAM routes onto the given Echo group. JWT middleware @@ -83,6 +86,7 @@ type createPoamItemRequest struct { PlannedCompletionDate *time.Time `json:"plannedCompletionDate"` CreatedFromRiskID *string `json:"createdFromRiskId"` AcceptanceRationale *string `json:"acceptanceRationale"` + ResourceRequired *string `json:"resourceRequired"` RiskIDs []string `json:"riskIds"` EvidenceIDs []string `json:"evidenceIds"` ControlRefs []poamControlRefRequest `json:"controlRefs"` @@ -178,6 +182,7 @@ type poamItemResponse struct { CompletedAt *time.Time `json:"completedAt,omitempty"` CreatedFromRiskID *uuid.UUID `json:"createdFromRiskId,omitempty"` AcceptanceRationale *string `json:"acceptanceRationale,omitempty"` + ResourceRequired *string `json:"resourceRequired,omitempty"` LastStatusChangeAt time.Time `json:"lastStatusChangeAt"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` @@ -216,6 +221,7 @@ func toPoamItemResponse(item *poamsvc.PoamItem) poamItemResponse { CompletedAt: item.CompletedAt, CreatedFromRiskID: item.CreatedFromRiskID, AcceptanceRationale: item.AcceptanceRationale, + ResourceRequired: item.ResourceRequired, LastStatusChangeAt: item.LastStatusChangeAt, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, @@ -369,6 +375,7 @@ func (h *PoamItemsHandler) Create(c echo.Context) error { SourceType: in.SourceType, PlannedCompletionDate: in.PlannedCompletionDate, AcceptanceRationale: in.AcceptanceRationale, + ResourceRequired: in.ResourceRequired, } if in.PrimaryOwnerUserID != nil { @@ -410,19 +417,14 @@ func (h *PoamItemsHandler) Create(c echo.Context) error { } params.ControlRefs = controlRefs - for i, mr := range in.Milestones { + for _, mr := range in.Milestones { if mr.Title == "" { return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("milestone title is required"))) } if mr.Status != "" && !poamsvc.MilestoneStatus(mr.Status).IsValid() { return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("invalid milestone status: %s", mr.Status))) } - // When orderIndex is omitted (nil), fall back to the slice position so - // ordering is still deterministic without requiring the client to set it. - msOrderIdx := i - if mr.OrderIndex != nil { - msOrderIdx = *mr.OrderIndex - } + // Pass OrderIndex as a pointer; nil means auto-assign from slice position. params.Milestones = append(params.Milestones, poamsvc.CreateMilestoneParams{ Title: mr.Title, Description: mr.Description, @@ -430,7 +432,7 @@ func (h *PoamItemsHandler) Create(c echo.Context) error { PlannedCompletionDate: mr.PlannedCompletionDate, ResponsibleParty: mr.ResponsibleParty, Remarks: mr.Remarks, - OrderIndex: msOrderIdx, + OrderIndex: mr.OrderIndex, }) } @@ -560,6 +562,16 @@ func (h *PoamItemsHandler) Update(c echo.Context) error { } params.RemoveFindingIDs = removeFindingIDs + // Capture the current status before the update to detect a completion transition. + currentItem, err := h.poamService.GetByID(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, api.NewError(err)) + } + return h.internalError(c, "failed to fetch poam item", err) + } + wasCompleted := currentItem.Status == string(poamsvc.PoamItemStatusCompleted) + item, err := h.poamService.Update(id, params) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -567,6 +579,26 @@ func (h *PoamItemsHandler) Update(c echo.Context) error { } return h.internalError(c, "failed to update poam item", err) } + + // When a POAM item transitions to completed, advance all linked risks from + // mitigating-planned → mitigating-implemented. + nowCompleted := item.Status == string(poamsvc.PoamItemStatusCompleted) + if !wasCompleted && nowCompleted { + var actorUserID *uuid.UUID + if claims, ok := c.Get("user").(*authn.UserClaims); ok && claims != nil { + if uid, err := h.riskService.ResolveUserIDByEmail(claims.Subject); err == nil { + actorUserID = uid + } + } + if err := h.riskService.OnPoamItemCompleted(id, actorUserID); err != nil { + // Log but do not fail the POAM update — the item is already saved. + h.sugar.Warnw("failed to advance linked risk statuses on POAM completion", + "poamItemId", id, + "error", err, + ) + } + } + return c.JSON(http.StatusOK, GenericDataResponse[poamItemResponse]{Data: toPoamItemResponse(item)}) } @@ -668,10 +700,6 @@ func (h *PoamItemsHandler) AddMilestone(c echo.Context) error { if in.Status != "" && !poamsvc.MilestoneStatus(in.Status).IsValid() { return c.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("invalid milestone status: %s", in.Status))) } - var orderIdx int - if in.OrderIndex != nil { - orderIdx = *in.OrderIndex - } m, err := h.poamService.AddMilestone(id, poamsvc.CreateMilestoneParams{ Title: in.Title, Description: in.Description, @@ -679,7 +707,7 @@ func (h *PoamItemsHandler) AddMilestone(c echo.Context) error { PlannedCompletionDate: in.PlannedCompletionDate, ResponsibleParty: in.ResponsibleParty, Remarks: in.Remarks, - OrderIndex: orderIdx, + OrderIndex: in.OrderIndex, }) if err != nil { return h.internalError(c, "failed to add milestone", err) diff --git a/internal/api/handler/risks.go b/internal/api/handler/risks.go index b49e6889..2cb3ada7 100644 --- a/internal/api/handler/risks.go +++ b/internal/api/handler/risks.go @@ -14,6 +14,7 @@ import ( "github.com/compliance-framework/api/internal/api" "github.com/compliance-framework/api/internal/authn" svc "github.com/compliance-framework/api/internal/service" + poamsvc "github.com/compliance-framework/api/internal/service/relational/poam" riskrel "github.com/compliance-framework/api/internal/service/relational/risks" "github.com/google/uuid" "github.com/labstack/echo/v4" @@ -23,6 +24,7 @@ import ( type RiskHandler struct { riskService *riskrel.RiskService + poamService *poamsvc.PoamService sugar *zap.SugaredLogger pagination *svc.PaginationConfig } @@ -32,9 +34,10 @@ const ( maxRiskDescriptionLength = 1000 ) -func NewRiskHandler(sugar *zap.SugaredLogger, db *gorm.DB) *RiskHandler { +func NewRiskHandler(sugar *zap.SugaredLogger, db *gorm.DB, poamSvc *poamsvc.PoamService, riskSvc *riskrel.RiskService) *RiskHandler { return &RiskHandler{ - riskService: riskrel.NewRiskService(db), + riskService: riskSvc, + poamService: poamSvc, sugar: sugar, pagination: svc.NewPaginationConfig(), } @@ -47,6 +50,7 @@ func (h *RiskHandler) Register(api *echo.Group) { api.PUT("/:id", h.Update) api.POST("/:id/accept", h.Accept) api.POST("/:id/review", h.Review) + api.POST("/:id/promote-to-poam", h.PromoteToPoam) api.DELETE("/:id", h.Delete) api.GET("/:id/events", h.GetEvents) api.GET("/:id/reviews", h.GetReviews) @@ -83,6 +87,7 @@ func (h *RiskHandler) RegisterSSPScoped(api *echo.Group) { api.PUT("/:id", h.UpdateForSSP) api.POST("/:id/accept", h.AcceptForSSP) api.POST("/:id/review", h.ReviewForSSP) + api.POST("/:id/promote-to-poam", h.PromoteToPoamForSSP) api.DELETE("/:id", h.DeleteForSSP) api.GET("/:id/events", h.GetEventsForSSP) api.GET("/:id/reviews", h.GetReviewsForSSP) @@ -1986,12 +1991,15 @@ func validateStatusTransition(oldStatus, newStatus string) error { }, string(riskrel.RiskStatusMitigatingPlanned): { string(riskrel.RiskStatusMitigatingImplemented): {}, + string(riskrel.RiskStatusInvestigating): {}, // mitigation can fail; risk returns to investigation }, string(riskrel.RiskStatusMitigatingImplemented): { - string(riskrel.RiskStatusClosed): {}, + string(riskrel.RiskStatusClosed): {}, + string(riskrel.RiskStatusRemediated): {}, // evidence fully green → remediated before close }, string(riskrel.RiskStatusRiskAccepted): { - string(riskrel.RiskStatusClosed): {}, + string(riskrel.RiskStatusClosed): {}, + string(riskrel.RiskStatusInvestigating): {}, // re-open accepted risk for investigation }, string(riskrel.RiskStatusRemediated): { string(riskrel.RiskStatusOpen): {}, @@ -2237,3 +2245,121 @@ func (h *RiskHandler) mapRiskToResponseWithAssociations(risk *riskrel.Risk, asso return response } + +// promoteToPoamRequest is the request body for POST /risks/:id/promote-to-poam. +type promoteToPoamRequest struct { + // Title overrides the risk's title as the POAM item title. + // If omitted, the risk's own title is used. + Title *string `json:"title"` + // Deadline maps to PoamItem.PlannedCompletionDate. + Deadline *time.Time `json:"deadline"` + // ResourceRequired is a free-text planning field describing effort or budget needed. + ResourceRequired *string `json:"resourceRequired"` + // PrimaryOwnerUserID optionally overrides the POAM item owner. + // If omitted, the risk's own PrimaryOwnerUserID is inherited automatically. + PrimaryOwnerUserID *uuid.UUID `json:"primaryOwnerUserId"` + // Milestones are additional milestones to append after any copied from the + // risk's RemediationTemplate. + Milestones []createMilestoneRequest `json:"milestones"` +} + +// PromoteToPoam godoc +// +// @Summary Promote risk to POAM item +// @Description Promotes an investigating risk to a POAM item and transitions the risk to mitigating-planned. The risk must be in investigating status (risk-accepted risks cannot be promoted — they have been formally accepted as tolerable). The POAM item is pre-populated from the risk's data and any RemediationTemplate tasks. The entire operation is transactional. +// @Tags Risks +// @Accept json +// @Produce json +// @Param id path string true "Risk ID" +// @Param body body promoteToPoamRequest false "Promotion payload" +// @Success 201 {object} GenericDataResponse[poamItemResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 422 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /risks/{id}/promote-to-poam [post] +func (h *RiskHandler) PromoteToPoam(ctx echo.Context) error { + riskID, err := parsePathUUID(ctx, "id") + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + var req promoteToPoamRequest + if err := ctx.Bind(&req); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + // Map request milestones to service params. + // OrderIndex is passed as a pointer; nil means auto-assign from slice position. + var milestones []poamsvc.CreateMilestoneParams + for _, m := range req.Milestones { + milestones = append(milestones, poamsvc.CreateMilestoneParams{ + Title: m.Title, + Description: m.Description, + Status: m.Status, + PlannedCompletionDate: m.PlannedCompletionDate, + ResponsibleParty: m.ResponsibleParty, + Remarks: m.Remarks, + OrderIndex: m.OrderIndex, + }) + } + + return h.withActorUserID(ctx, func(actorID *uuid.UUID) error { + poamItem, err := h.riskService.PromoteToPoam(h.poamService, riskrel.PromoteToPoamParams{ + RiskID: riskID, + ActorUserID: actorID, + Title: req.Title, + Deadline: req.Deadline, + ResourceRequired: req.ResourceRequired, + PrimaryOwnerUserID: req.PrimaryOwnerUserID, + ExtraMilestones: milestones, + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("risk not found"))) + } + if riskrel.IsValidationError(err) { + return ctx.JSON(http.StatusUnprocessableEntity, api.NewError(err)) + } + return h.internalServerError(ctx, "failed to promote risk to POAM item", err) + } + + return ctx.JSON(http.StatusCreated, GenericDataResponse[poamItemResponse]{Data: toPoamItemResponse(poamItem)}) + }) +} + +// PromoteToPoamForSSP godoc +// +// @Summary Promote risk to POAM item (SSP-scoped) +// @Description Promotes an investigating risk to a POAM item, scoped to a specific SSP. The risk must belong to the given SSP and be in investigating status. On success, the risk transitions to mitigating-planned. +// @Tags Risks +// @Accept json +// @Produce json +// @Param sspId path string true "SSP ID" +// @Param id path string true "Risk ID" +// @Param body body promoteToPoamRequest false "Promotion payload" +// @Success 201 {object} GenericDataResponse[poamItemResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 422 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{sspId}/risks/{id}/promote-to-poam [post] +func (h *RiskHandler) PromoteToPoamForSSP(ctx echo.Context) error { + sspID, err := parsePathUUID(ctx, "sspId") + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + riskID, err := parsePathUUID(ctx, "id") + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.ensureRiskBelongsToSSP(riskID, sspID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("risk not found"))) + } + return h.internalServerError(ctx, "failed to validate scoped risk", err) + } + return h.PromoteToPoam(ctx) +} diff --git a/internal/api/handler/risks_promote_to_poam_integration_test.go b/internal/api/handler/risks_promote_to_poam_integration_test.go new file mode 100644 index 00000000..60a58cf0 --- /dev/null +++ b/internal/api/handler/risks_promote_to_poam_integration_test.go @@ -0,0 +1,347 @@ +//go:build integration + +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + riskrel "github.com/compliance-framework/api/internal/service/relational/risks" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// BCH-1186: POST /risks/:id/promote-to-poam integration tests +// --------------------------------------------------------------------------- + +// TestPromoteToPoam_HappyPath verifies the full happy-path: a risk in +// investigating status is promoted to a POAM item, the POAM fields are +// populated correctly, and the risk status advances to mitigating-planned. +func (suite *RiskApiIntegrationSuite) TestPromoteToPoam_HappyPath() { + sspID := suite.newSSPID() + + // Use a deterministic owner UUID so we can assert inheritance. + ownerID := uuid.New() + + // Create a risk directly in investigating status with an explicit owner. + created := suite.createRisk(map[string]any{ + "title": "Unencrypted data at rest", + "description": "Sensitive data stored without encryption", + "ssp-id": sspID, + "status": "investigating", + "likelihood": "high", + "impact": "critical", + "primary-owner-user-id": ownerID.String(), + }) + + // Promote to POAM with full payload. + deadline := time.Now().Add(90 * 24 * time.Hour).UTC().Truncate(time.Second) + promoteRec, promoteReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/risks/%s/promote-to-poam", created.ID), map[string]any{ + "title": "Encrypt data at rest — remediation plan", + "deadline": deadline.Format(time.RFC3339), + "resourceRequired": "3 engineer days", + "milestones": []map[string]any{ + {"title": "Identify all unencrypted data stores", "orderIndex": 1}, + {"title": "Apply AES-256 encryption to all stores", "orderIndex": 2}, + }, + }) + suite.server.E().ServeHTTP(promoteRec, promoteReq) + require.Equal(suite.T(), http.StatusCreated, promoteRec.Code, "promote-to-poam should return 201: %s", promoteRec.Body.String()) + + var poamResp GenericDataResponse[poamItemResponse] + require.NoError(suite.T(), json.Unmarshal(promoteRec.Body.Bytes(), &poamResp)) + + // Verify POAM item fields. + require.Equal(suite.T(), "Encrypt data at rest — remediation plan", poamResp.Data.Title) + require.Equal(suite.T(), "Sensitive data stored without encryption", poamResp.Data.Description) + require.Equal(suite.T(), "open", poamResp.Data.Status) + require.Equal(suite.T(), "risk-promotion", poamResp.Data.SourceType) + require.NotNil(suite.T(), poamResp.Data.CreatedFromRiskID) + require.Equal(suite.T(), created.ID.String(), poamResp.Data.CreatedFromRiskID.String()) + require.NotNil(suite.T(), poamResp.Data.PlannedCompletionDate) + require.WithinDuration(suite.T(), deadline, *poamResp.Data.PlannedCompletionDate, time.Second) + // PrimaryOwnerUserID should be inherited from the risk's explicit owner. + require.NotNil(suite.T(), poamResp.Data.PrimaryOwnerUserID) + require.Equal(suite.T(), ownerID, *poamResp.Data.PrimaryOwnerUserID) + require.NotNil(suite.T(), poamResp.Data.ResourceRequired) + require.Equal(suite.T(), "3 engineer days", *poamResp.Data.ResourceRequired) + + // Verify milestones. + require.Len(suite.T(), poamResp.Data.Milestones, 2) + require.Equal(suite.T(), "Identify all unencrypted data stores", poamResp.Data.Milestones[0].Title) + require.Equal(suite.T(), "Apply AES-256 encryption to all stores", poamResp.Data.Milestones[1].Title) + + // Verify risk link was created. + require.Len(suite.T(), poamResp.Data.RiskLinks, 1) + require.Equal(suite.T(), created.ID.String(), poamResp.Data.RiskLinks[0].RiskID.String()) + + // Verify risk_event(poam_promoted) was emitted. + var promotedEvents int64 + require.NoError(suite.T(), suite.DB.Model(&riskrel.RiskEvent{}). + Where("risk_id = ? AND event_type = ?", created.ID, string(riskrel.RiskEventTypePoamPromoted)). + Count(&promotedEvents).Error) + require.Equal(suite.T(), int64(1), promotedEvents) + + // Verify risk status was transitioned to mitigating-planned. + getRec, getReq := suite.authedRequest(http.MethodGet, fmt.Sprintf("/api/risks/%s", created.ID), nil) + suite.server.E().ServeHTTP(getRec, getReq) + require.Equal(suite.T(), http.StatusOK, getRec.Code) + var riskResp GenericDataResponse[riskResponse] + require.NoError(suite.T(), json.Unmarshal(getRec.Body.Bytes(), &riskResp)) + require.Equal(suite.T(), "mitigating-planned", riskResp.Data.Status, "risk status should be mitigating-planned after promotion") +} + +// TestPromoteToPoam_DefaultsFromRisk verifies that when no title/description +// are provided, the POAM item inherits them from the risk. +func (suite *RiskApiIntegrationSuite) TestPromoteToPoam_DefaultsFromRisk() { + sspID := suite.newSSPID() + + created := suite.createRisk(map[string]any{ + "title": "Weak password policy", + "description": "Password complexity rules not enforced", + "ssp-id": sspID, + "status": "investigating", + }) + + // Promote with empty body — title and description should default from risk. + promoteRec, promoteReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/risks/%s/promote-to-poam", created.ID), map[string]any{}) + suite.server.E().ServeHTTP(promoteRec, promoteReq) + require.Equal(suite.T(), http.StatusCreated, promoteRec.Code, "promote-to-poam should return 201: %s", promoteRec.Body.String()) + + var poamResp GenericDataResponse[poamItemResponse] + require.NoError(suite.T(), json.Unmarshal(promoteRec.Body.Bytes(), &poamResp)) + + // Title and description should default to the risk's values. + require.Equal(suite.T(), "Weak password policy", poamResp.Data.Title) + require.Equal(suite.T(), "Password complexity rules not enforced", poamResp.Data.Description) + require.Equal(suite.T(), "open", poamResp.Data.Status) + require.Equal(suite.T(), "risk-promotion", poamResp.Data.SourceType) +} + +// TestPromoteToPoam_RejectsNonInvestigatingRisk verifies that only risks in +// investigating status can be promoted; open and risk-accepted are rejected. +func (suite *RiskApiIntegrationSuite) TestPromoteToPoam_RejectsNonInvestigatingRisk() { + sspID := suite.newSSPID() + + // Risk in "open" status — cannot be promoted. + openRisk := suite.createRisk(map[string]any{ + "title": "Open risk", + "description": "Not yet under investigation", + "ssp-id": sspID, + "status": "open", + }) + + promoteRec, promoteReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/risks/%s/promote-to-poam", openRisk.ID), map[string]any{}) + suite.server.E().ServeHTTP(promoteRec, promoteReq) + require.Equal(suite.T(), http.StatusUnprocessableEntity, promoteRec.Code, "should return 422 for open risk") + + // Risk in "risk-accepted" status — accepted risks are not remediated. + acceptedRisk := suite.createRisk(map[string]any{ + "title": "Accepted risk", + "description": "Formally accepted, not being remediated", + "ssp-id": sspID, + "status": "investigating", + }) + acceptDeadline := time.Now().Add(14 * 24 * time.Hour).UTC() + acceptRec, acceptReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/risks/%s/accept", acceptedRisk.ID), map[string]any{ + "justification": "accepted pending policy review", + "review-deadline": acceptDeadline.Format(time.RFC3339), + }) + suite.server.E().ServeHTTP(acceptRec, acceptReq) + require.Equal(suite.T(), http.StatusOK, acceptRec.Code) + + promoteRec2, promoteReq2 := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/risks/%s/promote-to-poam", acceptedRisk.ID), map[string]any{}) + suite.server.E().ServeHTTP(promoteRec2, promoteReq2) + require.Equal(suite.T(), http.StatusUnprocessableEntity, promoteRec2.Code, "should return 422 for risk-accepted risk") +} + +// TestPromoteToPoam_RejectsActivePoamAlreadyLinked verifies that a second +// promotion is rejected when an active (non-completed) POAM item already exists. +func (suite *RiskApiIntegrationSuite) TestPromoteToPoam_RejectsActivePoamAlreadyLinked() { + sspID := suite.newSSPID() + + created := suite.createRisk(map[string]any{ + "title": "Duplicate promotion risk", + "description": "Testing re-promotion guard", + "ssp-id": sspID, + "status": "investigating", + }) + + // First promotion should succeed. + firstRec, firstReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/risks/%s/promote-to-poam", created.ID), map[string]any{}) + suite.server.E().ServeHTTP(firstRec, firstReq) + require.Equal(suite.T(), http.StatusCreated, firstRec.Code, "first promotion should succeed: %s", firstRec.Body.String()) + + // Second promotion should fail — active POAM item already linked. + // Risk is now mitigating-planned, which is also not investigating, so we + // expect 422 from the status guard. + secondRec, secondReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/risks/%s/promote-to-poam", created.ID), map[string]any{}) + suite.server.E().ServeHTTP(secondRec, secondReq) + require.Equal(suite.T(), http.StatusUnprocessableEntity, secondRec.Code, "should return 422 when active POAM already linked") +} + +// TestPromoteToPoam_RejectsNotFound verifies that promoting a non-existent +// risk returns 404. +func (suite *RiskApiIntegrationSuite) TestPromoteToPoam_RejectsNotFound() { + nonExistentID := uuid.New() + promoteRec, promoteReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/risks/%s/promote-to-poam", nonExistentID), map[string]any{}) + suite.server.E().ServeHTTP(promoteRec, promoteReq) + require.Equal(suite.T(), http.StatusNotFound, promoteRec.Code) +} + +// TestPromoteToPoam_SSPScoped_HappyPath verifies the SSP-scoped endpoint +// returns 201 with correct data for a risk belonging to the given SSP. +func (suite *RiskApiIntegrationSuite) TestPromoteToPoam_SSPScoped_HappyPath() { + sspID := suite.newSSPID() + + // Use a deterministic owner UUID so we can assert inheritance. + sspOwnerID := uuid.New() + + created := suite.createRisk(map[string]any{ + "title": "SSP-scoped promotion risk", + "description": "Testing SSP-scoped promote endpoint", + "ssp-id": sspID, + "status": "investigating", + "primary-owner-user-id": sspOwnerID.String(), + }) + + // Promote via SSP-scoped endpoint. + sspPromoteRec, sspPromoteReq := suite.authedRequest( + http.MethodPost, + fmt.Sprintf("/api/oscal/system-security-plans/%s/risks/%s/promote-to-poam", sspID, created.ID), + map[string]any{}, + ) + suite.server.E().ServeHTTP(sspPromoteRec, sspPromoteReq) + require.Equal(suite.T(), http.StatusCreated, sspPromoteRec.Code, "SSP-scoped promote should return 201: %s", sspPromoteRec.Body.String()) + + var poamResp GenericDataResponse[poamItemResponse] + require.NoError(suite.T(), json.Unmarshal(sspPromoteRec.Body.Bytes(), &poamResp)) + require.Equal(suite.T(), "SSP-scoped promotion risk", poamResp.Data.Title) + // PrimaryOwnerUserID should be inherited from the risk's explicit owner. + require.NotNil(suite.T(), poamResp.Data.PrimaryOwnerUserID) + require.Equal(suite.T(), sspOwnerID, *poamResp.Data.PrimaryOwnerUserID) +} + +// TestPromoteToPoam_SSPScoped_RejectsWrongSSP verifies that promoting via a +// different SSP's scoped endpoint returns 404. +func (suite *RiskApiIntegrationSuite) TestPromoteToPoam_SSPScoped_RejectsWrongSSP() { + sspID := suite.newSSPID() + wrongSSPID := suite.newSSPID() + + created := suite.createRisk(map[string]any{ + "title": "Wrong SSP risk", + "description": "Risk belongs to a different SSP", + "ssp-id": sspID, + "status": "investigating", + }) + + // Attempt to promote via wrong SSP — should return 404. + wrongSSPRec, wrongSSPReq := suite.authedRequest( + http.MethodPost, + fmt.Sprintf("/api/oscal/system-security-plans/%s/risks/%s/promote-to-poam", wrongSSPID, created.ID), + map[string]any{}, + ) + suite.server.E().ServeHTTP(wrongSSPRec, wrongSSPReq) + require.Equal(suite.T(), http.StatusNotFound, wrongSSPRec.Code) +} + +// TestPromoteToPoam_WithRemediationTemplate verifies that milestones from the +// risk's RemediationTemplate are copied first, followed by any extra milestones +// from the request body. +func (suite *RiskApiIntegrationSuite) TestPromoteToPoam_WithRemediationTemplate() { + sspID := suite.newSSPID() + + created := suite.createRisk(map[string]any{ + "title": "Risk with remediation template", + "description": "Has a remediation template with tasks", + "ssp-id": sspID, + "status": "investigating", + }) + + // Create a remediation template with 2 tasks. + // Note: remediationTaskRequest uses "order-index" (kebab-case) as its JSON tag. + remRec, remReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/risks/%s/remediation-template", created.ID), map[string]any{ + "title": "Standard remediation plan", + "tasks": []map[string]any{ + {"title": "Template task 1", "order-index": 1}, + {"title": "Template task 2", "order-index": 2}, + }, + }) + suite.server.E().ServeHTTP(remRec, remReq) + require.Equal(suite.T(), http.StatusCreated, remRec.Code, "remediation template creation should return 201: %s", remRec.Body.String()) + + // Promote with 1 extra milestone — should have 3 total (2 template + 1 extra). + promoteRec, promoteReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/risks/%s/promote-to-poam", created.ID), map[string]any{ + "milestones": []map[string]any{ + {"title": "Extra milestone from request", "orderIndex": 3}, + }, + }) + suite.server.E().ServeHTTP(promoteRec, promoteReq) + require.Equal(suite.T(), http.StatusCreated, promoteRec.Code, "promote-to-poam should return 201: %s", promoteRec.Body.String()) + + var poamResp GenericDataResponse[poamItemResponse] + require.NoError(suite.T(), json.Unmarshal(promoteRec.Body.Bytes(), &poamResp)) + + // Should have 3 milestones: 2 from template + 1 from request. + require.Len(suite.T(), poamResp.Data.Milestones, 3) + require.Equal(suite.T(), "Template task 1", poamResp.Data.Milestones[0].Title) + require.Equal(suite.T(), "Template task 2", poamResp.Data.Milestones[1].Title) + require.Equal(suite.T(), "Extra milestone from request", poamResp.Data.Milestones[2].Title) +} + +// TestPromoteToPoam_CompletionAdvancesRiskStatus verifies the full lifecycle: +// promote (investigating → mitigating-planned), then complete the POAM item +// (mitigating-planned → mitigating-implemented). +func (suite *RiskApiIntegrationSuite) TestPromoteToPoam_CompletionAdvancesRiskStatus() { + sspID := suite.newSSPID() + + created := suite.createRisk(map[string]any{ + "title": "Lifecycle risk", + "description": "Testing full POAM lifecycle", + "ssp-id": sspID, + "status": "investigating", + }) + + // Promote to POAM. + promoteRec, promoteReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/risks/%s/promote-to-poam", created.ID), map[string]any{}) + suite.server.E().ServeHTTP(promoteRec, promoteReq) + require.Equal(suite.T(), http.StatusCreated, promoteRec.Code, "promote should succeed: %s", promoteRec.Body.String()) + + var poamResp GenericDataResponse[poamItemResponse] + require.NoError(suite.T(), json.Unmarshal(promoteRec.Body.Bytes(), &poamResp)) + poamID := poamResp.Data.ID + + // Verify risk is now mitigating-planned. + getRec, getReq := suite.authedRequest(http.MethodGet, fmt.Sprintf("/api/risks/%s", created.ID), nil) + suite.server.E().ServeHTTP(getRec, getReq) + require.Equal(suite.T(), http.StatusOK, getRec.Code) + var riskResp GenericDataResponse[riskResponse] + require.NoError(suite.T(), json.Unmarshal(getRec.Body.Bytes(), &riskResp)) + require.Equal(suite.T(), "mitigating-planned", riskResp.Data.Status) + + // Complete the POAM item. + completeRec, completeReq := suite.authedRequest(http.MethodPut, fmt.Sprintf("/api/poam-items/%s", poamID), map[string]any{ + "status": "completed", + }) + suite.server.E().ServeHTTP(completeRec, completeReq) + require.Equal(suite.T(), http.StatusOK, completeRec.Code, "POAM completion should succeed: %s", completeRec.Body.String()) + + // Verify risk has advanced to mitigating-implemented. + getRec2, getReq2 := suite.authedRequest(http.MethodGet, fmt.Sprintf("/api/risks/%s", created.ID), nil) + suite.server.E().ServeHTTP(getRec2, getReq2) + require.Equal(suite.T(), http.StatusOK, getRec2.Code) + var riskResp2 GenericDataResponse[riskResponse] + require.NoError(suite.T(), json.Unmarshal(getRec2.Body.Bytes(), &riskResp2)) + require.Equal(suite.T(), "mitigating-implemented", riskResp2.Data.Status, "risk should advance to mitigating-implemented after POAM completion") + + // Verify poam_completed event was emitted on the risk. + var completedEvents int64 + require.NoError(suite.T(), suite.DB.Model(&riskrel.RiskEvent{}). + Where("risk_id = ? AND event_type = ?", created.ID, string(riskrel.RiskEventTypePoamCompleted)). + Count(&completedEvents).Error) + require.Equal(suite.T(), int64(1), completedEvents) +} + diff --git a/internal/service/relational/poam/models.go b/internal/service/relational/poam/models.go index 2bb0aed9..4ee2770a 100644 --- a/internal/service/relational/poam/models.go +++ b/internal/service/relational/poam/models.go @@ -78,9 +78,13 @@ type PoamItem struct { CompletedAt *time.Time ` json:"completedAt,omitempty"` CreatedFromRiskID *uuid.UUID `gorm:"type:uuid" json:"createdFromRiskId,omitempty"` AcceptanceRationale *string ` json:"acceptanceRationale,omitempty"` - LastStatusChangeAt time.Time `gorm:"not null" json:"lastStatusChangeAt"` - CreatedAt time.Time ` json:"createdAt"` - UpdatedAt time.Time ` json:"updatedAt"` + // ResourceRequired is a free-text planning field describing effort or budget needed. + // Point-of-contact identity is expressed via PrimaryOwnerUserID (a FK to the users table) + // rather than free-text poc_name/poc_email fields. + ResourceRequired *string `gorm:"type:text" json:"resourceRequired,omitempty"` + LastStatusChangeAt time.Time `gorm:"not null" json:"lastStatusChangeAt"` + CreatedAt time.Time ` json:"createdAt"` + UpdatedAt time.Time ` json:"updatedAt"` // Associations — loaded on demand via Preload. Milestones []PoamItemMilestone `gorm:"foreignKey:PoamItemID;constraint:OnDelete:CASCADE" json:"milestones,omitempty"` diff --git a/internal/service/relational/poam/service.go b/internal/service/relational/poam/service.go index 9e052c94..b05938a6 100644 --- a/internal/service/relational/poam/service.go +++ b/internal/service/relational/poam/service.go @@ -38,6 +38,7 @@ type CreatePoamItemParams struct { PlannedCompletionDate *time.Time CreatedFromRiskID *uuid.UUID AcceptanceRationale *string + ResourceRequired *string RiskIDs []uuid.UUID EvidenceIDs []uuid.UUID ControlRefs []ControlRef @@ -67,6 +68,8 @@ type UpdatePoamItemParams struct { } // CreateMilestoneParams carries all data required to create a single milestone. +// OrderIndex is a pointer so that callers can distinguish "not provided" (nil, +// auto-assign) from "explicitly set to 0" (valid 0-based position). type CreateMilestoneParams struct { Title string Description string @@ -74,7 +77,7 @@ type CreateMilestoneParams struct { PlannedCompletionDate *time.Time ResponsibleParty *string Remarks *string - OrderIndex int + OrderIndex *int } // UpdateMilestoneParams carries the fields that may be patched on an existing @@ -116,6 +119,7 @@ func (s *PoamService) Create(params CreatePoamItemParams) (*PoamItem, error) { PlannedCompletionDate: params.PlannedCompletionDate, CreatedFromRiskID: params.CreatedFromRiskID, AcceptanceRationale: params.AcceptanceRationale, + ResourceRequired: params.ResourceRequired, } tx, err := beginTx(s.db) @@ -130,8 +134,10 @@ func (s *PoamService) Create(params CreatePoamItemParams) (*PoamItem, error) { } for i, mp := range params.Milestones { - orderIdx := mp.OrderIndex - if orderIdx == 0 { + var orderIdx int + if mp.OrderIndex != nil { + orderIdx = *mp.OrderIndex + } else { orderIdx = i } ms := PoamItemMilestone{ @@ -434,6 +440,10 @@ func (s *PoamService) ListMilestones(poamItemID uuid.UUID) ([]PoamItemMilestone, // AddMilestone inserts a new milestone for the given POAM item. func (s *PoamService) AddMilestone(poamItemID uuid.UUID, params CreateMilestoneParams) (*PoamItemMilestone, error) { + var orderIdx int + if params.OrderIndex != nil { + orderIdx = *params.OrderIndex + } m := PoamItemMilestone{ PoamItemID: poamItemID, Title: params.Title, @@ -442,7 +452,7 @@ func (s *PoamService) AddMilestone(poamItemID uuid.UUID, params CreateMilestoneP PlannedCompletionDate: params.PlannedCompletionDate, ResponsibleParty: params.ResponsibleParty, Remarks: params.Remarks, - OrderIndex: params.OrderIndex, + OrderIndex: orderIdx, } if err := s.db.Create(&m).Error; err != nil { return nil, err @@ -688,6 +698,67 @@ func (s *PoamService) DeleteFindingLink(poamItemID, findingID uuid.UUID) error { return nil } +// CreateWithTx inserts a new POAM item and its milestones/links within an +// externally-managed *gorm.DB transaction. The caller is responsible for +// committing or rolling back the transaction. This is used by cross-context +// operations such as RiskService.PromoteToPoam that need atomicity across +// multiple bounded contexts. +// +// NOTE: This method only processes Milestones and RiskIDs from +// CreatePoamItemParams. EvidenceIDs, ControlRefs, and FindingIDs present in +// the params struct are intentionally ignored — this method is scoped to the +// risk-promotion use case. Use the full Create method for general POAM item +// creation with all link types. +func (s *PoamService) CreateWithTx(tx *gorm.DB, params CreatePoamItemParams) (*PoamItem, error) { + item := PoamItem{ + SspID: params.SspID, + Title: params.Title, + Description: params.Description, + Status: params.Status, + SourceType: params.SourceType, + PrimaryOwnerUserID: params.PrimaryOwnerUserID, + PlannedCompletionDate: params.PlannedCompletionDate, + CreatedFromRiskID: params.CreatedFromRiskID, + AcceptanceRationale: params.AcceptanceRationale, + ResourceRequired: params.ResourceRequired, + } + + if err := tx.Create(&item).Error; err != nil { + return nil, err + } + + for i, mp := range params.Milestones { + var orderIdx int + if mp.OrderIndex != nil { + orderIdx = *mp.OrderIndex + } else { + orderIdx = i + } + ms := PoamItemMilestone{ + PoamItemID: item.ID, + Title: mp.Title, + Description: mp.Description, + Status: mp.Status, + PlannedCompletionDate: mp.PlannedCompletionDate, + ResponsibleParty: mp.ResponsibleParty, + Remarks: mp.Remarks, + OrderIndex: orderIdx, + } + if err := tx.Create(&ms).Error; err != nil { + return nil, err + } + } + + for _, riskID := range params.RiskIDs { + link := PoamItemRiskLink{PoamItemID: item.ID, RiskID: riskID} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&link).Error; err != nil { + return nil, err + } + } + + return &item, nil +} + // --------------------------------------------------------------------------- // Transaction helpers // --------------------------------------------------------------------------- diff --git a/internal/service/relational/risks/events.go b/internal/service/relational/risks/events.go index 379ea4d4..b5dfd981 100644 --- a/internal/service/relational/risks/events.go +++ b/internal/service/relational/risks/events.go @@ -34,6 +34,8 @@ const ( RiskEventTypeRemediationUpdated RiskEventType = "remediation_updated" RiskEventTypeRemediationDeleted RiskEventType = "remediation_deleted" RiskEventTypeEvidenceRecovered RiskEventType = "evidence_recovered" + RiskEventTypePoamPromoted RiskEventType = "poam_promoted" + RiskEventTypePoamCompleted RiskEventType = "poam_completed" ) type RiskEvent struct { @@ -187,6 +189,16 @@ func BuildRiskEventDetails(eventType string, payload datatypes.JSONMap, occurred return fmt.Sprintf("Evidence %s recovered; risk is accepted so no automatic status change was applied.", evidenceID) } return "Evidence recovered; risk is accepted so no automatic status change was applied." + case string(RiskEventTypePoamPromoted): + if poamItemID := payloadString(payload, "poamItemId"); poamItemID != "" { + return fmt.Sprintf("Risk was promoted to POAM item %s; status transitioned to mitigating-planned.", poamItemID) + } + return "Risk was promoted to a POAM item; status transitioned to mitigating-planned." + case string(RiskEventTypePoamCompleted): + if poamItemID := payloadString(payload, "poamItemId"); poamItemID != "" { + return fmt.Sprintf("Linked POAM item %s was completed; risk status advanced to mitigating-implemented.", poamItemID) + } + return "A linked POAM item was completed; risk status advanced to mitigating-implemented." default: return fmt.Sprintf("Risk event recorded: %s.", eventType) } diff --git a/internal/service/relational/risks/poam_completion.go b/internal/service/relational/risks/poam_completion.go new file mode 100644 index 00000000..51dd081a --- /dev/null +++ b/internal/service/relational/risks/poam_completion.go @@ -0,0 +1,93 @@ +package risks + +import ( + "github.com/google/uuid" + "gorm.io/datatypes" + "gorm.io/gorm/clause" +) + +// OnPoamItemCompleted is called by the POAM handler when a POAM item +// transitions to the "completed" status. It advances every linked risk that is +// currently in mitigating-planned status to mitigating-implemented, emitting a +// status_changed event and a poam_completed event for each one. +// +// Only risks in mitigating-planned are advanced; risks in any other status are +// left untouched (they may have been manually moved or re-accepted). +func (s *RiskService) OnPoamItemCompleted(poamItemID uuid.UUID, actorUserID *uuid.UUID) error { + // Find all risk IDs linked to this POAM item. + type linkRow struct { + RiskID uuid.UUID + } + var links []linkRow + if err := s.db.Raw(` + SELECT risk_id FROM ccf_poam_item_risk_links WHERE poam_item_id = ? + `, poamItemID).Scan(&links).Error; err != nil { + return err + } + if len(links) == 0 { + return nil + } + + for _, link := range links { + if err := s.advanceRiskToMitigatingImplemented(link.RiskID, poamItemID, actorUserID); err != nil { + return err + } + } + return nil +} + +// advanceRiskToMitigatingImplemented transitions a single risk from +// mitigating-planned → mitigating-implemented inside its own transaction. +// If the risk is not in mitigating-planned, it is silently skipped. +func (s *RiskService) advanceRiskToMitigatingImplemented(riskID, poamItemID uuid.UUID, actorUserID *uuid.UUID) error { + tx, err := beginTx(s.db) + if err != nil { + return err + } + defer rollbackTxOnPanic(tx) + + var risk Risk + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + First(&risk, "id = ?", riskID).Error; err != nil { + tx.Rollback() + return err + } + + // Only advance risks that are in mitigating-planned. + if risk.Status != string(RiskStatusMitigatingPlanned) { + tx.Rollback() + return nil + } + + oldStatus := risk.Status + risk.Status = string(RiskStatusMitigatingImplemented) + if err := tx.Save(&risk).Error; err != nil { + tx.Rollback() + return err + } + + riskSnapshot, err := s.getRiskSnapshot(tx, riskID) + if err != nil { + tx.Rollback() + return err + } + + // Emit status_changed event. + if err := s.logRiskEventWithSnapshot(tx, riskID, RiskEventTypeStatusChange, actorUserID, datatypes.JSONMap{ + "from": oldStatus, + "to": risk.Status, + }, riskSnapshot); err != nil { + tx.Rollback() + return err + } + + // Emit poam_completed event. + if err := s.logRiskEventWithSnapshot(tx, riskID, RiskEventTypePoamCompleted, actorUserID, datatypes.JSONMap{ + "poamItemId": poamItemID.String(), + }, riskSnapshot); err != nil { + tx.Rollback() + return err + } + + return tx.Commit().Error +} diff --git a/internal/service/relational/risks/promote_to_poam.go b/internal/service/relational/risks/promote_to_poam.go new file mode 100644 index 00000000..dd94c029 --- /dev/null +++ b/internal/service/relational/risks/promote_to_poam.go @@ -0,0 +1,207 @@ +package risks + +import ( + "errors" + "time" + + poamsvc "github.com/compliance-framework/api/internal/service/relational/poam" + "github.com/google/uuid" + "gorm.io/datatypes" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// PromoteToPoamParams carries all inputs required to promote an investigating +// risk to a POAM item. +type PromoteToPoamParams struct { + // RiskID is the UUID of the risk to promote. The risk must be in + // investigating status; any other status returns a 422 ValidationError. + RiskID uuid.UUID + // ActorUserID is the authenticated user performing the promotion. + ActorUserID *uuid.UUID + // Title overrides the risk's title as the POAM item title. + // If nil, the risk's own title is used. + Title *string + // Deadline maps to PoamItem.PlannedCompletionDate. + Deadline *time.Time + // ResourceRequired is a free-text planning field describing effort or budget needed. + ResourceRequired *string + // PrimaryOwnerUserID optionally overrides the POAM item owner. + // If nil, the risk's own PrimaryOwnerUserID is inherited automatically. + PrimaryOwnerUserID *uuid.UUID + // ExtraMilestones are additional milestones supplied in the request body. + // They are appended after any milestones copied from the risk's + // RemediationTemplate, with order_index offset accordingly. + ExtraMilestones []poamsvc.CreateMilestoneParams +} + +// PromoteToPoam promotes an investigating risk to a POAM item and transitions +// the risk status to mitigating-planned. The entire operation — POAM item +// creation, milestone creation, risk link creation, risk status transition, and +// risk event emission — is executed inside a single database transaction so +// that no partial state is persisted on failure. +// +// Re-promotion is allowed only if all previously linked POAM items are in +// completed status. If an active (non-completed) POAM item already exists for +// this risk, a ValidationError is returned. +func (s *RiskService) PromoteToPoam(poamSvc *poamsvc.PoamService, params PromoteToPoamParams) (*poamsvc.PoamItem, error) { + tx, err := beginTx(s.db) + if err != nil { + return nil, err + } + defer rollbackTxOnPanic(tx) + + // 1. Load and lock the risk row. + var risk Risk + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + First(&risk, "id = ?", params.RiskID).Error; err != nil { + tx.Rollback() + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + return nil, err + } + + // 2. Guard: risk must be in investigating status. + // A risk that is accepted (risk-accepted) has been formally accepted as + // tolerable — it should not receive a remediation plan. POAM promotion + // is only valid while the risk is actively being investigated. + if risk.Status != string(RiskStatusInvestigating) { + tx.Rollback() + return nil, newValidationError("only risks in status investigating can be promoted to a POAM item") + } + + // 3. Re-promotion guard: reject if an active (non-completed) POAM item + // is already linked to this risk. + type linkRow struct { + PoamItemID uuid.UUID + Status string + } + var activeLinks []linkRow + if err := tx.Raw(` + SELECT l.poam_item_id, p.status + FROM ccf_poam_item_risk_links l + JOIN ccf_poam_items p ON p.id = l.poam_item_id + WHERE l.risk_id = ? AND p.status != 'completed' + `, params.RiskID).Scan(&activeLinks).Error; err != nil { + tx.Rollback() + return nil, err + } + if len(activeLinks) > 0 { + tx.Rollback() + return nil, newValidationError("an active POAM item is already linked to this risk; complete it before re-promoting") + } + + // 4. Load the risk's RemediationTemplate (if any) to copy tasks as + // initial milestones. + var templateMilestones []poamsvc.CreateMilestoneParams + var remediationTemplate RiskRemediationTemplate + err = tx. + Where("risk_id = ?", params.RiskID). + Preload("Tasks", func(db *gorm.DB) *gorm.DB { return db.Order("order_index ASC") }). + First(&remediationTemplate).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + tx.Rollback() + return nil, err + } + if err == nil { + for _, task := range remediationTemplate.Tasks { + idx := task.OrderIndex + templateMilestones = append(templateMilestones, poamsvc.CreateMilestoneParams{ + Title: task.Title, + OrderIndex: &idx, + }) + } + } + + // 5. Merge template milestones with extra milestones from the request. + // Extra milestones are appended after template tasks, with order_index + // offset by the number of template tasks. A nil OrderIndex means + // "auto-assign"; a non-nil pointer (including 0) is used as-is. + offset := len(templateMilestones) + for i, extra := range params.ExtraMilestones { + if extra.OrderIndex == nil { + idx := offset + i + extra.OrderIndex = &idx + } + templateMilestones = append(templateMilestones, extra) + } + + // 6. Resolve the POAM item title — default to risk title if not overridden. + title := risk.Title + if params.Title != nil && *params.Title != "" { + title = *params.Title + } + + // 7. Build the POAM item creation params. + riskID := params.RiskID + createParams := poamsvc.CreatePoamItemParams{ + SspID: risk.SSPID, + Title: title, + Description: risk.Description, + Status: string(poamsvc.PoamItemStatusOpen), + SourceType: string(poamsvc.PoamItemSourceTypeRiskPromotion), + PrimaryOwnerUserID: coalesceUUID(params.PrimaryOwnerUserID, risk.PrimaryOwnerUserID), + PlannedCompletionDate: params.Deadline, + CreatedFromRiskID: &riskID, + ResourceRequired: params.ResourceRequired, + RiskIDs: []uuid.UUID{params.RiskID}, + Milestones: templateMilestones, + } + + // 8. Create the POAM item within the shared transaction. + poamItem, err := poamSvc.CreateWithTx(tx, createParams) + if err != nil { + tx.Rollback() + return nil, err + } + + // 9. Transition the risk status: investigating → mitigating-planned. + // This records that a formal remediation plan now exists for the risk. + oldStatus := risk.Status + risk.Status = string(RiskStatusMitigatingPlanned) + if err := tx.Save(&risk).Error; err != nil { + tx.Rollback() + return nil, err + } + + // 10. Emit risk events: status_changed + poam_promoted. + riskSnapshot, err := s.getRiskSnapshot(tx, params.RiskID) + if err != nil { + tx.Rollback() + return nil, err + } + if err := s.logRiskEventWithSnapshot(tx, params.RiskID, RiskEventTypeStatusChange, params.ActorUserID, datatypes.JSONMap{ + "from": oldStatus, + "to": risk.Status, + }, riskSnapshot); err != nil { + tx.Rollback() + return nil, err + } + if err := s.logRiskEventWithSnapshot(tx, params.RiskID, RiskEventTypePoamPromoted, params.ActorUserID, datatypes.JSONMap{ + "poamItemId": poamItem.ID.String(), + }, riskSnapshot); err != nil { + tx.Rollback() + return nil, err + } + + // 11. Commit the transaction. + if err := tx.Commit().Error; err != nil { + return nil, err + } + + // 12. Return the fully-loaded POAM item (with milestones and links). + return poamSvc.GetByID(poamItem.ID) +} + +// coalesceUUID returns the first non-nil UUID pointer from the provided +// arguments, mirroring the SQL COALESCE semantics. Used to allow an optional +// caller-supplied override to fall back to a model-derived default. +func coalesceUUID(vals ...*uuid.UUID) *uuid.UUID { + for _, v := range vals { + if v != nil { + return v + } + } + return nil +}