From 52ebc97496ca8648126383ce54bc2d41e138c176 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 13 Apr 2026 12:06:22 -0300 Subject: [PATCH 1/3] feat: risk scores Signed-off-by: Gustavo Carvalho --- docs/docs.go | 329 ++++++++++++++++++ docs/swagger.json | 329 ++++++++++++++++++ docs/swagger.yaml | 213 ++++++++++++ internal/api/handler/risks.go | 260 ++++++++++++++ .../api/handler/risks_integration_test.go | 109 ++++++ internal/service/migrator.go | 2 + internal/service/relational/risks/events.go | 3 + internal/service/relational/risks/models.go | 7 + .../relational/risks/poam_completion.go | 4 + .../relational/risks/promote_to_poam.go | 4 + internal/service/relational/risks/scores.go | 267 ++++++++++++++ .../service/relational/risks/scores_test.go | 190 ++++++++++ internal/service/relational/risks/service.go | 77 +++- .../service/relational/risks/service_test.go | 3 +- .../service/worker/risk_evidence_worker.go | 9 + .../worker/risk_evidence_worker_test.go | 1 + internal/service/worker/risk_workers_test.go | 1 + internal/tests/migrate.go | 2 + 18 files changed, 1806 insertions(+), 4 deletions(-) create mode 100644 internal/service/relational/risks/scores.go create mode 100644 internal/service/relational/risks/scores_test.go diff --git a/docs/docs.go b/docs/docs.go index 6945fd8d..11dcf4cd 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -17878,6 +17878,76 @@ const docTemplate = `{ ] } }, + "/oscal/system-security-plans/{sspId}/risks/score-timeseries": { + "get": { + "description": "Returns aggregate open baseline and residual score time series for an SSP.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Get risk score timeseries for SSP", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "sspId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Start timestamp (RFC3339)", + "name": "from", + "in": "query" + }, + { + "type": "string", + "description": "End timestamp (RFC3339)", + "name": "to", + "in": "query" + }, + { + "type": "string", + "description": "Bucket size; only day is supported", + "name": "bucket", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-handler_riskScoreTimeseriesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/oscal/system-security-plans/{sspId}/risks/{id}": { "get": { "description": "Retrieves a risk register entry by ID scoped to an SSP.", @@ -19281,6 +19351,65 @@ const docTemplate = `{ ] } }, + "/oscal/system-security-plans/{sspId}/risks/{id}/score-history": { + "get": { + "description": "Lists score snapshots for a risk scoped to an SSP.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "List risk score history for SSP", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "sspId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-handler_riskScoreResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/oscal/system-security-plans/{sspId}/risks/{id}/threat-ids": { "get": { "description": "Lists threat references linked to a risk scoped to an SSP.", @@ -20980,6 +21109,69 @@ const docTemplate = `{ ] } }, + "/risks/score-timeseries": { + "get": { + "description": "Returns aggregate open baseline and residual score time series.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Get risk score timeseries", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "sspId", + "in": "query" + }, + { + "type": "string", + "description": "Start timestamp (RFC3339)", + "name": "from", + "in": "query" + }, + { + "type": "string", + "description": "End timestamp (RFC3339)", + "name": "to", + "in": "query" + }, + { + "type": "string", + "description": "Bucket size; only day is supported", + "name": "bucket", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-handler_riskScoreTimeseriesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/risks/{id}": { "get": { "description": "Retrieves a risk register entry by ID.", @@ -22236,6 +22428,58 @@ const docTemplate = `{ ] } }, + "/risks/{id}/score-history": { + "get": { + "description": "Lists score snapshots for a risk.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "List risk score history", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-handler_riskScoreResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/risks/{id}/subjects": { "get": { "description": "Lists subjects linked to a risk.", @@ -26712,6 +26956,30 @@ const docTemplate = `{ } } }, + "handler.GenericDataListResponse-handler_riskScoreResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/handler.riskScoreResponse" + } + } + } + }, + "handler.GenericDataListResponse-handler_riskScoreTimeseriesResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/handler.riskScoreTimeseriesResponse" + } + } + } + }, "handler.GenericDataListResponse-handler_selectableUserResponse": { "type": "object", "properties": { @@ -29127,6 +29395,67 @@ const docTemplate = `{ } } }, + "handler.riskScoreResponse": { + "type": "object", + "properties": { + "actor-user-id": { + "type": "string" + }, + "baseline-score": { + "type": "integer" + }, + "created-at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "impact": { + "type": "string" + }, + "likelihood": { + "type": "string" + }, + "occurred-at": { + "type": "string" + }, + "open-baseline-score": { + "type": "integer" + }, + "open-residual-score": { + "type": "integer" + }, + "residual-score": { + "type": "integer" + }, + "risk-id": { + "type": "string" + }, + "source-event-type": { + "type": "string" + }, + "ssp-id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "handler.riskScoreTimeseriesResponse": { + "type": "object", + "properties": { + "bucket-start": { + "type": "string" + }, + "open-baseline-score": { + "type": "integer" + }, + "open-residual-score": { + "type": "integer" + } + } + }, "handler.selectableUserResponse": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index c10bbd6b..42b25d9d 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -17872,6 +17872,76 @@ ] } }, + "/oscal/system-security-plans/{sspId}/risks/score-timeseries": { + "get": { + "description": "Returns aggregate open baseline and residual score time series for an SSP.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Get risk score timeseries for SSP", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "sspId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Start timestamp (RFC3339)", + "name": "from", + "in": "query" + }, + { + "type": "string", + "description": "End timestamp (RFC3339)", + "name": "to", + "in": "query" + }, + { + "type": "string", + "description": "Bucket size; only day is supported", + "name": "bucket", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-handler_riskScoreTimeseriesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/oscal/system-security-plans/{sspId}/risks/{id}": { "get": { "description": "Retrieves a risk register entry by ID scoped to an SSP.", @@ -19275,6 +19345,65 @@ ] } }, + "/oscal/system-security-plans/{sspId}/risks/{id}/score-history": { + "get": { + "description": "Lists score snapshots for a risk scoped to an SSP.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "List risk score history for SSP", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "sspId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-handler_riskScoreResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/oscal/system-security-plans/{sspId}/risks/{id}/threat-ids": { "get": { "description": "Lists threat references linked to a risk scoped to an SSP.", @@ -20974,6 +21103,69 @@ ] } }, + "/risks/score-timeseries": { + "get": { + "description": "Returns aggregate open baseline and residual score time series.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Get risk score timeseries", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "sspId", + "in": "query" + }, + { + "type": "string", + "description": "Start timestamp (RFC3339)", + "name": "from", + "in": "query" + }, + { + "type": "string", + "description": "End timestamp (RFC3339)", + "name": "to", + "in": "query" + }, + { + "type": "string", + "description": "Bucket size; only day is supported", + "name": "bucket", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-handler_riskScoreTimeseriesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/risks/{id}": { "get": { "description": "Retrieves a risk register entry by ID.", @@ -22230,6 +22422,58 @@ ] } }, + "/risks/{id}/score-history": { + "get": { + "description": "Lists score snapshots for a risk.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "List risk score history", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-handler_riskScoreResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/risks/{id}/subjects": { "get": { "description": "Lists subjects linked to a risk.", @@ -26706,6 +26950,30 @@ } } }, + "handler.GenericDataListResponse-handler_riskScoreResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/handler.riskScoreResponse" + } + } + } + }, + "handler.GenericDataListResponse-handler_riskScoreTimeseriesResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/handler.riskScoreTimeseriesResponse" + } + } + } + }, "handler.GenericDataListResponse-handler_selectableUserResponse": { "type": "object", "properties": { @@ -29121,6 +29389,67 @@ } } }, + "handler.riskScoreResponse": { + "type": "object", + "properties": { + "actor-user-id": { + "type": "string" + }, + "baseline-score": { + "type": "integer" + }, + "created-at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "impact": { + "type": "string" + }, + "likelihood": { + "type": "string" + }, + "occurred-at": { + "type": "string" + }, + "open-baseline-score": { + "type": "integer" + }, + "open-residual-score": { + "type": "integer" + }, + "residual-score": { + "type": "integer" + }, + "risk-id": { + "type": "string" + }, + "source-event-type": { + "type": "string" + }, + "ssp-id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "handler.riskScoreTimeseriesResponse": { + "type": "object", + "properties": { + "bucket-start": { + "type": "string" + }, + "open-baseline-score": { + "type": "integer" + }, + "open-residual-score": { + "type": "integer" + } + } + }, "handler.selectableUserResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index f37e0d93..2c4c1d59 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -571,6 +571,22 @@ definitions: $ref: '#/definitions/handler.poamItemResponse' type: array type: object + handler.GenericDataListResponse-handler_riskScoreResponse: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/handler.riskScoreResponse' + type: array + type: object + handler.GenericDataListResponse-handler_riskScoreTimeseriesResponse: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/handler.riskScoreTimeseriesResponse' + type: array + type: object handler.GenericDataListResponse-handler_selectableUserResponse: properties: data: @@ -2055,6 +2071,46 @@ definitions: updated-at: type: string type: object + handler.riskScoreResponse: + properties: + actor-user-id: + type: string + baseline-score: + type: integer + created-at: + type: string + id: + type: string + impact: + type: string + likelihood: + type: string + occurred-at: + type: string + open-baseline-score: + type: integer + open-residual-score: + type: integer + residual-score: + type: integer + risk-id: + type: string + source-event-type: + type: string + ssp-id: + type: string + status: + type: string + type: object + handler.riskScoreTimeseriesResponse: + properties: + bucket-start: + type: string + open-baseline-score: + type: integer + open-residual-score: + type: integer + type: object handler.selectableUserResponse: properties: displayName: @@ -21568,6 +21624,44 @@ paths: summary: List risk audit trail for SSP tags: - Risks + /oscal/system-security-plans/{sspId}/risks/{id}/score-history: + get: + description: Lists score snapshots for a risk scoped to an SSP. + parameters: + - description: SSP ID + in: path + name: sspId + required: true + type: string + - description: Risk ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-handler_riskScoreResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List risk score history for SSP + tags: + - Risks /oscal/system-security-plans/{sspId}/risks/{id}/threat-ids: get: description: Lists threat references linked to a risk scoped to an SSP. @@ -21792,6 +21886,52 @@ paths: summary: Update risk threat reference for SSP tags: - Risks + /oscal/system-security-plans/{sspId}/risks/score-timeseries: + get: + description: Returns aggregate open baseline and residual score time series + for an SSP. + parameters: + - description: SSP ID + in: path + name: sspId + required: true + type: string + - description: Start timestamp (RFC3339) + in: query + name: from + type: string + - description: End timestamp (RFC3339) + in: query + name: to + type: string + - description: Bucket size; only day is supported + in: query + name: bucket + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-handler_riskScoreTimeseriesResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Get risk score timeseries for SSP + tags: + - Risks /poam-items: get: parameters: @@ -23471,6 +23611,39 @@ paths: summary: List risk audit trail tags: - Risks + /risks/{id}/score-history: + get: + description: Lists score snapshots for a risk. + parameters: + - description: Risk ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-handler_riskScoreResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List risk score history + tags: + - Risks /risks/{id}/subjects: get: description: Lists subjects linked to a risk. @@ -23751,6 +23924,46 @@ paths: summary: Update risk threat reference tags: - Risks + /risks/score-timeseries: + get: + description: Returns aggregate open baseline and residual score time series. + parameters: + - description: SSP ID + in: query + name: sspId + type: string + - description: Start timestamp (RFC3339) + in: query + name: from + type: string + - description: End timestamp (RFC3339) + in: query + name: to + type: string + - description: Bucket size; only day is supported + in: query + name: bucket + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-handler_riskScoreTimeseriesResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Get risk score timeseries + tags: + - Risks /users/{id}: get: description: Get minimal user details by user ID diff --git a/internal/api/handler/risks.go b/internal/api/handler/risks.go index ed54482f..78bb1f6c 100644 --- a/internal/api/handler/risks.go +++ b/internal/api/handler/risks.go @@ -46,12 +46,14 @@ func NewRiskHandler(sugar *zap.SugaredLogger, db *gorm.DB, poamSvc *poamsvc.Poam func (h *RiskHandler) Register(api *echo.Group) { api.GET("", h.List) api.POST("", h.Create) + api.GET("/score-timeseries", h.GetScoreTimeseries) api.GET("/:id", h.Get) 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/score-history", h.GetScoreHistory) api.GET("/:id/events", h.GetEvents) api.GET("/:id/reviews", h.GetReviews) @@ -83,12 +85,14 @@ func (h *RiskHandler) Register(api *echo.Group) { func (h *RiskHandler) RegisterSSPScoped(api *echo.Group) { api.GET("", h.ListForSSP) api.POST("", h.CreateForSSP) + api.GET("/score-timeseries", h.GetScoreTimeseriesForSSP) api.GET("/:id", h.GetForSSP) 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/score-history", h.GetScoreHistoryForSSP) api.GET("/:id/events", h.GetEventsForSSP) api.GET("/:id/reviews", h.GetReviewsForSSP) api.GET("/:id/evidence", h.GetEvidenceLinksForSSP) @@ -231,6 +235,29 @@ type riskResponse struct { Remediation *remediationTemplateResponse `json:"remediation-template,omitempty"` } +type riskScoreResponse struct { + ID uuid.UUID `json:"id"` + RiskID uuid.UUID `json:"risk-id"` + SSPID uuid.UUID `json:"ssp-id"` + OccurredAt time.Time `json:"occurred-at"` + CreatedAt time.Time `json:"created-at"` + ActorUserID *uuid.UUID `json:"actor-user-id"` + SourceEventType string `json:"source-event-type"` + Status string `json:"status"` + Likelihood *string `json:"likelihood"` + Impact *string `json:"impact"` + BaselineScore int `json:"baseline-score"` + ResidualScore int `json:"residual-score"` + OpenBaselineScore int `json:"open-baseline-score"` + OpenResidualScore int `json:"open-residual-score"` +} + +type riskScoreTimeseriesResponse struct { + BucketStart time.Time `json:"bucket-start"` + OpenBaselineScore int `json:"open-baseline-score"` + OpenResidualScore int `json:"open-residual-score"` +} + type addEvidenceLinkRequest struct { EvidenceID uuid.UUID `json:"evidence-id"` } @@ -805,6 +832,8 @@ func (h *RiskHandler) Update(ctx echo.Context) error { } oldStatus := risk.Status + oldLikelihood := cloneStringPtr(risk.Likelihood) + oldImpact := cloneStringPtr(risk.Impact) statusChanged := false if req.Status != nil { @@ -869,6 +898,7 @@ func (h *RiskHandler) Update(ctx echo.Context) error { ownerAssignments = normalizeOwnerAssignmentsForPrimaryOwner(ownerAssignments, effectivePrimaryOwnerUserID) recordReview := req.LastReviewedAt != nil || req.ReviewDeadline != nil || req.ReviewJustification != nil + scoreChanged := !stringPtrEqual(oldLikelihood, risk.Likelihood) || !stringPtrEqual(oldImpact, risk.Impact) var reviewedAt *time.Time if req.LastReviewedAt != nil { reviewedAtUTC := req.LastReviewedAt.UTC() @@ -882,7 +912,10 @@ func (h *RiskHandler) Update(ctx echo.Context) error { PrimaryOwnerUserID: effectivePrimaryOwnerUserID, ActorUserID: actorID, OldStatus: oldStatus, + OldLikelihood: oldLikelihood, + OldImpact: oldImpact, StatusChanged: statusChanged, + ScoreChanged: scoreChanged, RecordReview: recordReview, ReviewedAt: reviewedAt, ReviewJustification: req.ReviewJustification, @@ -1183,6 +1216,156 @@ func (h *RiskHandler) GetReviews(ctx echo.Context) error { }) } +// GetScoreHistoryForSSP godoc +// +// @Summary List risk score history for SSP +// @Description Lists score snapshots for a risk scoped to an SSP. +// @Tags Risks +// @Produce json +// @Param sspId path string true "SSP ID" +// @Param id path string true "Risk ID" +// @Success 200 {object} GenericDataListResponse[riskScoreResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{sspId}/risks/{id}/score-history [get] +func (h *RiskHandler) GetScoreHistoryForSSP(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.GetScoreHistory(ctx) +} + +// GetScoreHistory godoc +// +// @Summary List risk score history +// @Description Lists score snapshots for a risk. +// @Tags Risks +// @Produce json +// @Param id path string true "Risk ID" +// @Success 200 {object} GenericDataListResponse[riskScoreResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /risks/{id}/score-history [get] +func (h *RiskHandler) GetScoreHistory(ctx echo.Context) error { + riskID, err := parsePathUUID(ctx, "id") + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.ensureRiskExists(riskID); 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 risk", err) + } + + scores, err := h.riskService.ListScoreHistory(riskID) + if err != nil { + return h.internalServerError(ctx, "failed to list risk score history", err) + } + + resp := make([]riskScoreResponse, 0, len(scores)) + for _, score := range scores { + resp = append(resp, mapRiskScoreToResponse(score)) + } + return ctx.JSON(http.StatusOK, GenericDataListResponse[riskScoreResponse]{Data: resp}) +} + +// GetScoreTimeseriesForSSP godoc +// +// @Summary Get risk score timeseries for SSP +// @Description Returns aggregate open baseline and residual score time series for an SSP. +// @Tags Risks +// @Produce json +// @Param sspId path string true "SSP ID" +// @Param from query string false "Start timestamp (RFC3339)" +// @Param to query string false "End timestamp (RFC3339)" +// @Param bucket query string false "Bucket size; only day is supported" +// @Success 200 {object} GenericDataListResponse[riskScoreTimeseriesResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/system-security-plans/{sspId}/risks/score-timeseries [get] +func (h *RiskHandler) GetScoreTimeseriesForSSP(ctx echo.Context) error { + sspID, err := parsePathUUID(ctx, "sspId") + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.riskService.EnsureSSPExists(sspID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("ssp not found"))) + } + return h.internalServerError(ctx, "failed to validate ssp", err) + } + q := ctx.QueryParams() + q.Set("sspId", sspID.String()) + ctx.Request().URL.RawQuery = q.Encode() + return h.GetScoreTimeseries(ctx) +} + +// GetScoreTimeseries godoc +// +// @Summary Get risk score timeseries +// @Description Returns aggregate open baseline and residual score time series. +// @Tags Risks +// @Produce json +// @Param sspId query string false "SSP ID" +// @Param from query string false "Start timestamp (RFC3339)" +// @Param to query string false "End timestamp (RFC3339)" +// @Param bucket query string false "Bucket size; only day is supported" +// @Success 200 {object} GenericDataListResponse[riskScoreTimeseriesResponse] +// @Failure 400 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /risks/score-timeseries [get] +func (h *RiskHandler) GetScoreTimeseries(ctx echo.Context) error { + sspID, from, to, bucket, err := parseScoreTimeseriesParams(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if sspID != nil { + if err := h.riskService.EnsureSSPExists(*sspID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("ssp not found"))) + } + return h.internalServerError(ctx, "failed to validate ssp", err) + } + } + + points, err := h.riskService.ListScoreTimeseries(sspID, from, to, bucket) + if err != nil { + if riskrel.IsValidationError(err) { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + return h.internalServerError(ctx, "failed to list risk score timeseries", err) + } + + resp := make([]riskScoreTimeseriesResponse, 0, len(points)) + for _, point := range points { + resp = append(resp, riskScoreTimeseriesResponse{ + BucketStart: point.BucketStart, + OpenBaselineScore: point.OpenBaselineScore, + OpenResidualScore: point.OpenResidualScore, + }) + } + return ctx.JSON(http.StatusOK, GenericDataListResponse[riskScoreTimeseriesResponse]{Data: resp}) +} + // GetEvidenceLinksForSSP godoc // // @Summary List risk evidence links for SSP @@ -1932,6 +2115,83 @@ func parseListFilters(ctx echo.Context) (riskrel.ListFilters, error) { return filters, nil } +func parseScoreTimeseriesParams(ctx echo.Context) (*uuid.UUID, time.Time, time.Time, string, error) { + var sspID *uuid.UUID + if rawSSPID := ctx.QueryParam("sspId"); rawSSPID != "" { + parsed, err := uuid.Parse(rawSSPID) + if err != nil { + return nil, time.Time{}, time.Time{}, "", fmt.Errorf("invalid sspId") + } + sspID = &parsed + } + + to := time.Now().UTC() + if rawTo := ctx.QueryParam("to"); rawTo != "" { + parsed, err := time.Parse(time.RFC3339, rawTo) + if err != nil { + return nil, time.Time{}, time.Time{}, "", fmt.Errorf("invalid to") + } + to = parsed.UTC() + } + + from := to.AddDate(0, 0, -30) + if rawFrom := ctx.QueryParam("from"); rawFrom != "" { + parsed, err := time.Parse(time.RFC3339, rawFrom) + if err != nil { + return nil, time.Time{}, time.Time{}, "", fmt.Errorf("invalid from") + } + from = parsed.UTC() + } + + bucket := ctx.QueryParam("bucket") + if bucket == "" { + bucket = riskrel.RiskScoreBucketDay + } + + return sspID, from, to, bucket, nil +} + +func mapRiskScoreToResponse(score riskrel.RiskScore) riskScoreResponse { + id := uuid.Nil + if score.ID != nil { + id = *score.ID + } + return riskScoreResponse{ + ID: id, + RiskID: score.RiskID, + SSPID: score.SSPID, + OccurredAt: score.OccurredAt, + CreatedAt: score.CreatedAt, + ActorUserID: score.ActorUserID, + SourceEventType: score.SourceEventType, + Status: score.Status, + Likelihood: score.Likelihood, + Impact: score.Impact, + BaselineScore: score.BaselineScore, + ResidualScore: score.ResidualScore, + OpenBaselineScore: score.OpenBaselineScore, + OpenResidualScore: score.OpenResidualScore, + } +} + +func cloneStringPtr(value *string) *string { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func stringPtrEqual(a, b *string) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} + func validateRiskLevel(level *string) error { if level == nil || *level == "" { return nil diff --git a/internal/api/handler/risks_integration_test.go b/internal/api/handler/risks_integration_test.go index e8b980cd..0778526c 100644 --- a/internal/api/handler/risks_integration_test.go +++ b/internal/api/handler/risks_integration_test.go @@ -425,6 +425,28 @@ func (suite *RiskApiIntegrationSuite) TestRiskReassessReviewEndpoints() { require.Equal(suite.T(), "low", scoreReassessedEvents[0].Payload["fromImpact"]) require.Equal(suite.T(), "moderate", scoreReassessedEvents[0].Payload["toLikelihood"]) require.Equal(suite.T(), "critical", scoreReassessedEvents[0].Payload["toImpact"]) + require.Equal(suite.T(), "4", fmt.Sprint(scoreReassessedEvents[0].Payload["fromScore"])) + require.Equal(suite.T(), "15", fmt.Sprint(scoreReassessedEvents[0].Payload["toScore"])) + + historyRec, historyReq := suite.authedRequest(http.MethodGet, fmt.Sprintf("/api/risks/%s/score-history", created.ID), nil) + suite.server.E().ServeHTTP(historyRec, historyReq) + require.Equal(suite.T(), http.StatusOK, historyRec.Code) + + var history GenericDataListResponse[riskScoreResponse] + require.NoError(suite.T(), json.Unmarshal(historyRec.Body.Bytes(), &history)) + require.Len(suite.T(), history.Data, 2) + require.Equal(suite.T(), 4, history.Data[0].BaselineScore) + require.Equal(suite.T(), 4, history.Data[0].ResidualScore) + require.Equal(suite.T(), 4, history.Data[0].OpenBaselineScore) + require.Equal(suite.T(), 4, history.Data[0].OpenResidualScore) + require.Equal(suite.T(), 4, history.Data[1].BaselineScore) + require.Equal(suite.T(), 15, history.Data[1].ResidualScore) + require.Equal(suite.T(), 4, history.Data[1].OpenBaselineScore) + require.Equal(suite.T(), 15, history.Data[1].OpenResidualScore) + + scopedHistoryRec, scopedHistoryReq := suite.authedRequest(http.MethodGet, fmt.Sprintf("/api/oscal/system-security-plans/%s/risks/%s/score-history", created.SSPID, created.ID), nil) + suite.server.E().ServeHTTP(scopedHistoryRec, scopedHistoryReq) + require.Equal(suite.T(), http.StatusOK, scopedHistoryRec.Code) var reviewedEvents int64 require.NoError(suite.T(), suite.DB.Model(&riskrel.RiskEvent{}). @@ -502,6 +524,78 @@ func (suite *RiskApiIntegrationSuite) TestRiskReassessReviewEndpoints() { require.Equal(suite.T(), http.StatusBadRequest, acceptedReassessRec.Code) } +func (suite *RiskApiIntegrationSuite) TestRiskScoreTimeseriesAggregatesBySSPAndGlobally() { + sspA := uuid.New() + sspB := uuid.New() + suite.ensureSSPExists(sspA.String()) + suite.ensureSSPExists(sspB.String()) + + riskA1 := suite.createScoredRiskSnapshotFixture(sspA, "a1") + riskA2 := suite.createScoredRiskSnapshotFixture(sspA, "a2") + riskB1 := suite.createScoredRiskSnapshotFixture(sspB, "b1") + + low := "low" + high := "high" + critical := "critical" + day1 := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + day2 := time.Date(2026, 1, 2, 10, 0, 0, 0, time.UTC) + day3 := time.Date(2026, 1, 3, 10, 0, 0, 0, time.UTC) + + require.NoError(suite.T(), suite.DB.Create(&[]riskrel.RiskScore{ + { + RiskID: riskA1, SSPID: sspA, OccurredAt: day1, SourceEventType: string(riskrel.RiskEventTypeCreated), Status: string(riskrel.RiskStatusOpen), + Likelihood: &high, Impact: &high, BaselineScore: 16, ResidualScore: 16, OpenBaselineScore: 16, OpenResidualScore: 16, + }, + { + RiskID: riskA1, SSPID: sspA, OccurredAt: day2, SourceEventType: string(riskrel.RiskEventTypeScoreReassessed), Status: string(riskrel.RiskStatusOpen), + Likelihood: &low, Impact: &high, BaselineScore: 16, ResidualScore: 8, OpenBaselineScore: 16, OpenResidualScore: 8, + }, + { + RiskID: riskA1, SSPID: sspA, OccurredAt: day3, SourceEventType: string(riskrel.RiskEventTypeStatusChange), Status: string(riskrel.RiskStatusClosed), + Likelihood: &low, Impact: &high, BaselineScore: 16, ResidualScore: 8, OpenBaselineScore: 0, OpenResidualScore: 0, + }, + { + RiskID: riskA2, SSPID: sspA, OccurredAt: day1, SourceEventType: string(riskrel.RiskEventTypeCreated), Status: string(riskrel.RiskStatusOpen), + Likelihood: &low, Impact: &low, BaselineScore: 4, ResidualScore: 4, OpenBaselineScore: 4, OpenResidualScore: 4, + }, + { + RiskID: riskB1, SSPID: sspB, OccurredAt: day1, SourceEventType: string(riskrel.RiskEventTypeCreated), Status: string(riskrel.RiskStatusOpen), + Likelihood: &critical, Impact: &critical, BaselineScore: 25, ResidualScore: 25, OpenBaselineScore: 25, OpenResidualScore: 25, + }, + }).Error) + + from := url.QueryEscape(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339)) + to := url.QueryEscape(time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC).Format(time.RFC3339)) + + scopedRec, scopedReq := suite.authedRequest(http.MethodGet, fmt.Sprintf("/api/oscal/system-security-plans/%s/risks/score-timeseries?from=%s&to=%s&bucket=day", sspA, from, to), nil) + suite.server.E().ServeHTTP(scopedRec, scopedReq) + require.Equal(suite.T(), http.StatusOK, scopedRec.Code) + + var scoped GenericDataListResponse[riskScoreTimeseriesResponse] + require.NoError(suite.T(), json.Unmarshal(scopedRec.Body.Bytes(), &scoped)) + require.Len(suite.T(), scoped.Data, 3) + require.Equal(suite.T(), 20, scoped.Data[0].OpenBaselineScore) + require.Equal(suite.T(), 20, scoped.Data[0].OpenResidualScore) + require.Equal(suite.T(), 20, scoped.Data[1].OpenBaselineScore) + require.Equal(suite.T(), 12, scoped.Data[1].OpenResidualScore) + require.Equal(suite.T(), 4, scoped.Data[2].OpenBaselineScore) + require.Equal(suite.T(), 4, scoped.Data[2].OpenResidualScore) + + globalRec, globalReq := suite.authedRequest(http.MethodGet, fmt.Sprintf("/api/risks/score-timeseries?from=%s&to=%s&bucket=day", from, to), nil) + suite.server.E().ServeHTTP(globalRec, globalReq) + require.Equal(suite.T(), http.StatusOK, globalRec.Code) + + var global GenericDataListResponse[riskScoreTimeseriesResponse] + require.NoError(suite.T(), json.Unmarshal(globalRec.Body.Bytes(), &global)) + require.Len(suite.T(), global.Data, 3) + require.Equal(suite.T(), 45, global.Data[0].OpenBaselineScore) + require.Equal(suite.T(), 45, global.Data[0].OpenResidualScore) + require.Equal(suite.T(), 45, global.Data[1].OpenBaselineScore) + require.Equal(suite.T(), 37, global.Data[1].OpenResidualScore) + require.Equal(suite.T(), 29, global.Data[2].OpenBaselineScore) + require.Equal(suite.T(), 29, global.Data[2].OpenResidualScore) +} + func (suite *RiskApiIntegrationSuite) TestSSPScopedRiskCRUD() { sspID := suite.newSSPID() otherSSPID := suite.newSSPID() @@ -1905,3 +1999,18 @@ func (suite *RiskApiIntegrationSuite) createRisk(reqBody map[string]any) riskRes require.NoError(suite.T(), json.Unmarshal(rec.Body.Bytes(), &created)) return created.Data } + +func (suite *RiskApiIntegrationSuite) createScoredRiskSnapshotFixture(sspID uuid.UUID, suffix string) uuid.UUID { + riskID := uuid.New() + now := time.Now().UTC() + require.NoError(suite.T(), suite.DB.Create(&riskrel.Risk{ + UUIDModel: relational.UUIDModel{ID: &riskID}, + Title: "timeseries risk " + suffix, + Description: "aggregate score fixture", + Status: string(riskrel.RiskStatusOpen), + SSPID: sspID, + FirstSeenAt: now, + LastSeenAt: now, + }).Error) + return riskID +} diff --git a/internal/service/migrator.go b/internal/service/migrator.go index 092cd12e..9e987d1a 100644 --- a/internal/service/migrator.go +++ b/internal/service/migrator.go @@ -97,6 +97,7 @@ func MigrateUp(db *gorm.DB) error { &relational.Observation{}, &relational.Finding{}, &riskrel.Risk{}, + &riskrel.RiskScore{}, &riskrel.RiskEvent{}, &riskrel.RiskReview{}, &riskrel.RiskEvidenceLink{}, @@ -440,6 +441,7 @@ func MigrateDown(db *gorm.DB) error { &relational.Observation{}, &relational.Finding{}, &riskrel.Risk{}, + &riskrel.RiskScore{}, &riskrel.RiskEvent{}, &riskrel.RiskReview{}, &riskrel.RiskEvidenceLink{}, diff --git a/internal/service/relational/risks/events.go b/internal/service/relational/risks/events.go index b5dfd981..ce099152 100644 --- a/internal/service/relational/risks/events.go +++ b/internal/service/relational/risks/events.go @@ -20,6 +20,7 @@ const ( RiskEventTypeAccepted RiskEventType = "accepted" RiskEventTypeReviewed RiskEventType = "reviewed" RiskEventTypeScoreReassessed RiskEventType = "score_reassessed" + RiskEventTypeScoreUpdated RiskEventType = "score_updated" RiskEventTypeEvidenceLink RiskEventType = "evidence_linked" RiskEventTypeEvidenceUnlink RiskEventType = "evidence_unlinked" RiskEventTypeControlLink RiskEventType = "control_linked" @@ -112,6 +113,8 @@ func BuildRiskEventDetails(eventType string, payload datatypes.JSONMap, occurred return fmt.Sprintf("Risk score was reassessed from likelihood=%s impact=%s to likelihood=%s impact=%s.", fromLikelihood, fromImpact, toLikelihood, toImpact) } return "Risk score was reassessed." + case string(RiskEventTypeScoreUpdated): + return "Risk score was updated." case string(RiskEventTypeEvidenceLink): if evidenceID := payloadString(payload, "evidenceId", "evidence_id"); evidenceID != "" { return fmt.Sprintf("Evidence %s was linked to this risk.", evidenceID) diff --git a/internal/service/relational/risks/models.go b/internal/service/relational/risks/models.go index 8e737db4..631801df 100644 --- a/internal/service/relational/risks/models.go +++ b/internal/service/relational/risks/models.go @@ -189,6 +189,13 @@ func EnsureIndexes(db *gorm.DB) error { `CREATE UNIQUE INDEX IF NOT EXISTS idx_risk_register_dedupe_active ON risk_register_risks (dedupe_key) WHERE status <> 'closed' AND dedupe_key <> ''`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_risk_owner_primary_unique ON risk_owner_assignments (risk_id) WHERE is_primary = true`, } + if db.Migrator().HasTable(&RiskScore{}) { + stmts = append(stmts, + `CREATE INDEX IF NOT EXISTS idx_risk_scores_risk_time ON risk_scores (risk_id, occurred_at DESC, created_at DESC, id DESC)`, + `CREATE INDEX IF NOT EXISTS idx_risk_scores_ssp_time_risk ON risk_scores (ssp_id, occurred_at DESC, risk_id)`, + `CREATE INDEX IF NOT EXISTS idx_risk_scores_ssp_risk_time ON risk_scores (ssp_id, risk_id, occurred_at DESC, created_at DESC, id DESC)`, + ) + } for _, stmt := range stmts { if err := db.Exec(stmt).Error; err != nil { return err diff --git a/internal/service/relational/risks/poam_completion.go b/internal/service/relational/risks/poam_completion.go index 343c3864..5dfdb5c2 100644 --- a/internal/service/relational/risks/poam_completion.go +++ b/internal/service/relational/risks/poam_completion.go @@ -107,6 +107,10 @@ func (s *RiskService) advanceRiskToMitigatingImplemented(riskID, poamItemID uuid tx.Rollback() return err } + if err := s.RecordRiskScoreSnapshot(tx, riskID, RiskEventTypeStatusChange, actorUserID, risk.UpdatedAt); 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 index 658c9009..3f6dbf8d 100644 --- a/internal/service/relational/risks/promote_to_poam.go +++ b/internal/service/relational/risks/promote_to_poam.go @@ -182,6 +182,10 @@ func (s *RiskService) PromoteToPoam(poamSvc *poamsvc.PoamService, params Promote tx.Rollback() return nil, err } + if err := s.RecordRiskScoreSnapshot(tx, params.RiskID, RiskEventTypeStatusChange, params.ActorUserID, time.Now().UTC()); err != nil { + tx.Rollback() + return nil, err + } // 11. Commit the transaction. if err := tx.Commit().Error; err != nil { diff --git a/internal/service/relational/risks/scores.go b/internal/service/relational/risks/scores.go new file mode 100644 index 00000000..53dbb9f8 --- /dev/null +++ b/internal/service/relational/risks/scores.go @@ -0,0 +1,267 @@ +package risks + +import ( + "errors" + "fmt" + "time" + + "github.com/compliance-framework/api/internal/service/relational" + "github.com/google/uuid" + "gorm.io/gorm" +) + +const ( + RiskScoreBucketDay = "day" +) + +type RiskScore struct { + relational.UUIDModel + CreatedAt time.Time `json:"createdAt"` + + RiskID uuid.UUID `json:"riskId" gorm:"type:uuid;not null;index"` + SSPID uuid.UUID `json:"sspId" gorm:"type:uuid;not null;index"` + OccurredAt time.Time `json:"occurredAt" gorm:"not null;index"` + ActorUserID *uuid.UUID `json:"actorUserId" gorm:"type:uuid;index"` + SourceEventType string `json:"sourceEventType" gorm:"type:varchar(64);not null;index"` + Status string `json:"status" gorm:"type:varchar(64);not null;index"` + Likelihood *string `json:"likelihood" gorm:"type:varchar(16)"` + Impact *string `json:"impact" gorm:"type:varchar(16)"` + BaselineScore int `json:"baselineScore" gorm:"not null"` + ResidualScore int `json:"residualScore" gorm:"not null"` + OpenBaselineScore int `json:"openBaselineScore" gorm:"not null"` + OpenResidualScore int `json:"openResidualScore" gorm:"not null"` +} + +func (RiskScore) TableName() string { + return "risk_scores" +} + +func (s *RiskScore) BeforeCreate(_ *gorm.DB) error { + if s.ID == nil { + id := uuid.New() + s.ID = &id + } + if s.CreatedAt.IsZero() { + s.CreatedAt = time.Now().UTC() + } + if s.OccurredAt.IsZero() { + s.OccurredAt = s.CreatedAt + } + return nil +} + +func (s *RiskScore) BeforeUpdate(_ *gorm.DB) error { + return errors.New("risk scores are append-only") +} + +func (s *RiskScore) BeforeDelete(_ *gorm.DB) error { + return errors.New("risk scores are append-only") +} + +type RiskScoreTimeseriesPoint struct { + BucketStart time.Time `json:"bucketStart" gorm:"column:bucket_start"` + OpenBaselineScore int `json:"openBaselineScore" gorm:"column:open_baseline_score"` + OpenResidualScore int `json:"openResidualScore" gorm:"column:open_residual_score"` +} + +func RiskLevelRank(level RiskLevel) (int, bool) { + switch NormalizeRiskLevel(string(level)) { + case RiskLevelNegligible: + return 1, true + case RiskLevelLow: + return 2, true + case RiskLevelModerate: + return 3, true + case RiskLevelHigh: + return 4, true + case RiskLevelCritical: + return 5, true + default: + return 0, false + } +} + +func NumericalRiskScore(likelihood, impact *string) (int, bool) { + if likelihood == nil || impact == nil { + return 0, false + } + + likelihoodRank, ok := RiskLevelRank(RiskLevel(*likelihood)) + if !ok { + return 0, false + } + impactRank, ok := RiskLevelRank(RiskLevel(*impact)) + if !ok { + return 0, false + } + + return likelihoodRank * impactRank, true +} + +func (s *RiskService) RecordRiskScoreSnapshot(tx *gorm.DB, riskID uuid.UUID, sourceEventType RiskEventType, actorUserID *uuid.UUID, occurredAt time.Time) error { + if tx == nil { + tx = s.db + } + if occurredAt.IsZero() { + occurredAt = time.Now().UTC() + } else { + occurredAt = occurredAt.UTC() + } + + var risk Risk + if err := tx.Select("id", "ssp_id", "status", "likelihood", "impact").First(&risk, "id = ?", riskID).Error; err != nil { + return err + } + if risk.ID == nil { + return fmt.Errorf("risk %s missing id", riskID) + } + + currentScore, hasCurrentScore := NumericalRiskScore(risk.Likelihood, risk.Impact) + + var latest RiskScore + err := tx. + Where("risk_id = ?", riskID). + Order("occurred_at DESC, created_at DESC, id DESC"). + First(&latest).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + hasLatest := err == nil + if !hasLatest && !hasCurrentScore { + return nil + } + + baselineScore := currentScore + residualScore := currentScore + if hasLatest { + baselineScore = latest.BaselineScore + residualScore = latest.ResidualScore + if isScoreValueEvent(sourceEventType) && hasCurrentScore { + residualScore = currentScore + } + } + + openBaselineScore := baselineScore + openResidualScore := residualScore + if isTerminalRiskStatus(risk.Status) { + openBaselineScore = 0 + openResidualScore = 0 + } + + score := RiskScore{ + RiskID: riskID, + SSPID: risk.SSPID, + OccurredAt: occurredAt, + ActorUserID: actorUserID, + SourceEventType: string(sourceEventType), + Status: risk.Status, + Likelihood: risk.Likelihood, + Impact: risk.Impact, + BaselineScore: baselineScore, + ResidualScore: residualScore, + OpenBaselineScore: openBaselineScore, + OpenResidualScore: openResidualScore, + } + return tx.Create(&score).Error +} + +func isScoreValueEvent(eventType RiskEventType) bool { + switch eventType { + case RiskEventTypeScoreReassessed, RiskEventTypeScoreUpdated: + return true + default: + return false + } +} + +func isTerminalRiskStatus(status string) bool { + return status == string(RiskStatusClosed) || status == string(RiskStatusRemediated) +} + +func (s *RiskService) ListScoreHistory(riskID uuid.UUID) ([]RiskScore, error) { + var scores []RiskScore + if err := s.db. + Where("risk_id = ?", riskID). + Order("occurred_at ASC, created_at ASC, id ASC"). + Find(&scores).Error; err != nil { + return nil, err + } + return scores, nil +} + +func (s *RiskService) ListScoreTimeseries(sspID *uuid.UUID, from, to time.Time, bucket string) ([]RiskScoreTimeseriesPoint, error) { + if bucket == "" { + bucket = RiskScoreBucketDay + } + if bucket != RiskScoreBucketDay { + return nil, newValidationError("bucket must be day") + } + if to.Before(from) { + return nil, newValidationError("to must be greater than or equal to from") + } + + from = from.UTC() + to = to.UTC() + + query := ` + WITH buckets AS ( + SELECT generate_series( + date_trunc('day', ?::timestamptz), + date_trunc('day', ?::timestamptz), + interval '1 day' + ) AS bucket_start + ) + SELECT + b.bucket_start, + COALESCE(SUM(latest.open_baseline_score), 0)::int AS open_baseline_score, + COALESCE(SUM(latest.open_residual_score), 0)::int AS open_residual_score + FROM buckets b + LEFT JOIN LATERAL ( + SELECT DISTINCT ON (rs.risk_id) + rs.risk_id, + rs.open_baseline_score, + rs.open_residual_score + FROM risk_scores rs + WHERE rs.occurred_at < b.bucket_start + interval '1 day' + ORDER BY rs.risk_id, rs.occurred_at DESC, rs.created_at DESC, rs.id DESC + ) latest ON true + GROUP BY b.bucket_start + ORDER BY b.bucket_start ASC + ` + args := []any{from, to} + if sspID != nil { + query = ` + WITH buckets AS ( + SELECT generate_series( + date_trunc('day', ?::timestamptz), + date_trunc('day', ?::timestamptz), + interval '1 day' + ) AS bucket_start + ) + SELECT + b.bucket_start, + COALESCE(SUM(latest.open_baseline_score), 0)::int AS open_baseline_score, + COALESCE(SUM(latest.open_residual_score), 0)::int AS open_residual_score + FROM buckets b + LEFT JOIN LATERAL ( + SELECT DISTINCT ON (rs.risk_id) + rs.risk_id, + rs.open_baseline_score, + rs.open_residual_score + FROM risk_scores rs + WHERE rs.ssp_id = ? + AND rs.occurred_at < b.bucket_start + interval '1 day' + ORDER BY rs.risk_id, rs.occurred_at DESC, rs.created_at DESC, rs.id DESC + ) latest ON true + GROUP BY b.bucket_start + ORDER BY b.bucket_start ASC + ` + args = []any{from, to, *sspID} + } + + var rows []RiskScoreTimeseriesPoint + if err := s.db.Raw(query, args...).Scan(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} diff --git a/internal/service/relational/risks/scores_test.go b/internal/service/relational/risks/scores_test.go new file mode 100644 index 00000000..cda5c2d2 --- /dev/null +++ b/internal/service/relational/risks/scores_test.go @@ -0,0 +1,190 @@ +package risks + +import ( + "testing" + "time" + + "github.com/compliance-framework/api/internal/service/relational" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestRiskLevelRankAndNumericalRiskScore(t *testing.T) { + cases := []struct { + level RiskLevel + rank int + }{ + {RiskLevelNegligible, 1}, + {RiskLevelLow, 2}, + {RiskLevelModerate, 3}, + {RiskLevelMediumLegacy, 3}, + {RiskLevelHigh, 4}, + {RiskLevelCritical, 5}, + } + for _, tc := range cases { + rank, ok := RiskLevelRank(tc.level) + require.True(t, ok) + require.Equal(t, tc.rank, rank) + } + + likelihood := "medium" + impact := "critical" + score, ok := NumericalRiskScore(&likelihood, &impact) + require.True(t, ok) + require.Equal(t, 15, score) + + score, ok = NumericalRiskScore(nil, &impact) + require.False(t, ok) + require.Equal(t, 0, score) +} + +func TestRiskScoreSnapshots(t *testing.T) { + db := newRiskServiceTestDB(t) + svc := NewRiskService(db) + + actorID := uuid.New() + sspID := uuid.New() + low := "low" + high := "high" + critical := "critical" + now := time.Now().UTC() + + created, err := svc.Create(CreateRiskParams{ + Risk: Risk{ + Title: "scored risk", + Description: "desc", + Status: string(RiskStatusInvestigating), + SSPID: sspID, + Likelihood: &low, + Impact: &high, + FirstSeenAt: now, + LastSeenAt: now, + }, + ActorUserID: &actorID, + }) + require.NoError(t, err) + + history, err := svc.ListScoreHistory(*created.ID) + require.NoError(t, err) + require.Len(t, history, 1) + require.Equal(t, 8, history[0].BaselineScore) + require.Equal(t, 8, history[0].ResidualScore) + require.Equal(t, 8, history[0].OpenBaselineScore) + require.Equal(t, 8, history[0].OpenResidualScore) + require.Equal(t, string(RiskEventTypeCreated), history[0].SourceEventType) + + reviewedAt := now.Add(time.Hour) + reassessed, err := svc.ReviewRisk(ReviewRiskParams{ + RiskID: *created.ID, + ActorUserID: &actorID, + ReviewedAt: &reviewedAt, + Decision: RiskReviewDecisionReassess, + Likelihood: &low, + Impact: &critical, + }) + require.NoError(t, err) + require.Equal(t, string(RiskStatusInvestigating), reassessed.Status) + + history, err = svc.ListScoreHistory(*created.ID) + require.NoError(t, err) + require.Len(t, history, 2) + require.Equal(t, 8, history[1].BaselineScore) + require.Equal(t, 10, history[1].ResidualScore) + require.Equal(t, 8, history[1].OpenBaselineScore) + require.Equal(t, 10, history[1].OpenResidualScore) + require.Equal(t, string(RiskEventTypeScoreReassessed), history[1].SourceEventType) + + accepted, err := svc.AcceptRisk(AcceptRiskParams{ + RiskID: *created.ID, + ActorUserID: &actorID, + Justification: "accepted risk remains tracked", + ReviewDeadline: now.Add(30 * 24 * time.Hour), + }) + require.NoError(t, err) + require.Equal(t, string(RiskStatusRiskAccepted), accepted.Status) + + history, err = svc.ListScoreHistory(*created.ID) + require.NoError(t, err) + require.Len(t, history, 3) + require.Equal(t, 8, history[2].OpenBaselineScore) + require.Equal(t, 10, history[2].OpenResidualScore) + + acceptedCopy := *accepted + oldStatus := acceptedCopy.Status + acceptedCopy.Status = string(RiskStatusClosed) + closed, err := svc.Update(UpdateRiskParams{ + Risk: &acceptedCopy, + ActorUserID: &actorID, + OldStatus: oldStatus, + StatusChanged: true, + }) + require.NoError(t, err) + require.Equal(t, string(RiskStatusClosed), closed.Status) + + history, err = svc.ListScoreHistory(*created.ID) + require.NoError(t, err) + require.Len(t, history, 4) + var closedScore RiskScore + require.NoError(t, db.Where("risk_id = ? AND status = ?", *created.ID, string(RiskStatusClosed)).First(&closedScore).Error) + require.Equal(t, 8, closedScore.BaselineScore) + require.Equal(t, 10, closedScore.ResidualScore) + require.Equal(t, 0, closedScore.OpenBaselineScore) + require.Equal(t, 0, closedScore.OpenResidualScore) + + closedCopy := *closed + oldStatus = closedCopy.Status + closedCopy.Status = string(RiskStatusOpen) + reopened, err := svc.Update(UpdateRiskParams{ + Risk: &closedCopy, + ActorUserID: &actorID, + OldStatus: oldStatus, + StatusChanged: true, + }) + require.NoError(t, err) + require.Equal(t, string(RiskStatusOpen), reopened.Status) + + history, err = svc.ListScoreHistory(*created.ID) + require.NoError(t, err) + require.Len(t, history, 5) + var reopenedScore RiskScore + require.NoError(t, db.Where("risk_id = ? AND status = ?", *created.ID, string(RiskStatusOpen)).First(&reopenedScore).Error) + require.Equal(t, 8, reopenedScore.OpenBaselineScore) + require.Equal(t, 10, reopenedScore.OpenResidualScore) +} + +func TestRiskScoreSnapshotFirstCompleteScoreBecomesBaseline(t *testing.T) { + db := newRiskServiceTestDB(t) + svc := NewRiskService(db) + + riskID := uuid.New() + sspID := uuid.New() + require.NoError(t, db.Create(&Risk{ + UUIDModel: relational.UUIDModel{ID: &riskID}, + Title: "unscored", + Description: "desc", + Status: string(RiskStatusOpen), + SSPID: sspID, + SourceType: string(RiskSourceTypeManual), + FirstSeenAt: time.Now().UTC(), + LastSeenAt: time.Now().UTC(), + }).Error) + + require.NoError(t, svc.RecordRiskScoreSnapshot(db, riskID, RiskEventTypeCreated, nil, time.Now().UTC())) + history, err := svc.ListScoreHistory(riskID) + require.NoError(t, err) + require.Empty(t, history) + + low := "low" + critical := "critical" + require.NoError(t, db.Model(&Risk{}).Where("id = ?", riskID).Updates(map[string]any{ + "likelihood": low, + "impact": critical, + }).Error) + require.NoError(t, svc.RecordRiskScoreSnapshot(db, riskID, RiskEventTypeScoreUpdated, nil, time.Now().UTC())) + + history, err = svc.ListScoreHistory(riskID) + require.NoError(t, err) + require.Len(t, history, 1) + require.Equal(t, 10, history[0].BaselineScore) + require.Equal(t, 10, history[0].ResidualScore) +} diff --git a/internal/service/relational/risks/service.go b/internal/service/relational/risks/service.go index 1f74b371..cf2bf843 100644 --- a/internal/service/relational/risks/service.go +++ b/internal/service/relational/risks/service.go @@ -45,7 +45,10 @@ type UpdateRiskParams struct { PrimaryOwnerUserID *uuid.UUID ActorUserID *uuid.UUID OldStatus string + OldLikelihood *string + OldImpact *string StatusChanged bool + ScoreChanged bool RecordReview bool ReviewedAt *time.Time ReviewJustification *string @@ -159,6 +162,10 @@ func (s *RiskService) Create(params CreateRiskParams) (*Risk, error) { tx.Rollback() return nil, err } + if err := s.RecordRiskScoreSnapshot(tx, *risk.ID, RiskEventTypeCreated, params.ActorUserID, risk.CreatedAt); err != nil { + tx.Rollback() + return nil, err + } if risk.Status == string(RiskStatusRiskAccepted) { if err := s.logRiskEventWithSnapshot(tx, *risk.ID, RiskEventTypeAccepted, params.ActorUserID, datatypes.JSONMap{"status": risk.Status}, riskSnapshot); err != nil { tx.Rollback() @@ -249,7 +256,7 @@ func (s *RiskService) Update(params UpdateRiskParams) (*Risk, error) { } var riskSnapshot datatypes.JSONMap - if params.StatusChanged || params.RecordReview { + if params.StatusChanged || params.ScoreChanged || params.RecordReview { snapshot, err := s.getRiskSnapshot(tx, *params.Risk.ID) if err != nil { tx.Rollback() @@ -270,6 +277,36 @@ func (s *RiskService) Update(params UpdateRiskParams) (*Risk, error) { } } } + if params.ScoreChanged { + fromScore, fromScoreOK := NumericalRiskScore(params.OldLikelihood, params.OldImpact) + toScore, toScoreOK := NumericalRiskScore(params.Risk.Likelihood, params.Risk.Impact) + payload := datatypes.JSONMap{ + "fromLikelihood": stringPtrValue(params.OldLikelihood), + "fromImpact": stringPtrValue(params.OldImpact), + "toLikelihood": stringPtrValue(params.Risk.Likelihood), + "toImpact": stringPtrValue(params.Risk.Impact), + } + if fromScoreOK { + payload["fromScore"] = fromScore + } + if toScoreOK { + payload["toScore"] = toScore + } + if err := s.logRiskEventWithSnapshot(tx, *params.Risk.ID, RiskEventTypeScoreUpdated, params.ActorUserID, payload, riskSnapshot); err != nil { + tx.Rollback() + return nil, err + } + } + if params.StatusChanged || params.ScoreChanged { + sourceEventType := RiskEventTypeStatusChange + if params.ScoreChanged { + sourceEventType = RiskEventTypeScoreUpdated + } + if err := s.RecordRiskScoreSnapshot(tx, *params.Risk.ID, sourceEventType, params.ActorUserID, time.Now().UTC()); err != nil { + tx.Rollback() + return nil, err + } + } if params.RecordReview { reviewedAt := time.Now().UTC() @@ -365,6 +402,10 @@ func (s *RiskService) AcceptRisk(params AcceptRiskParams) (*Risk, error) { tx.Rollback() return nil, err } + if err := s.RecordRiskScoreSnapshot(tx, *risk.ID, RiskEventTypeStatusChange, params.ActorUserID, now); err != nil { + tx.Rollback() + return nil, err + } // TODO(BCH-1182): enqueue a risk-accepted notification worker job once its type/worker is available in this branch. @@ -563,15 +604,29 @@ func (s *RiskService) ReviewRisk(params ReviewRiskParams) (*Risk, error) { if fromImpact != nil { fromImpactValue = *fromImpact } + fromScore, fromScoreOK := NumericalRiskScore(fromLikelihood, fromImpact) + toScore, toScoreOK := NumericalRiskScore(reassessedLikelihood, reassessedImpact) - if err := s.logRiskEventWithSnapshot(tx, *risk.ID, RiskEventTypeScoreReassessed, params.ActorUserID, datatypes.JSONMap{ + payload := datatypes.JSONMap{ "decision": string(decision), "status": risk.Status, "fromLikelihood": fromLikelihoodValue, "fromImpact": fromImpactValue, "toLikelihood": toLikelihood, "toImpact": toImpact, - }, riskSnapshot); err != nil { + } + if fromScoreOK { + payload["fromScore"] = fromScore + } + if toScoreOK { + payload["toScore"] = toScore + } + + if err := s.logRiskEventWithSnapshot(tx, *risk.ID, RiskEventTypeScoreReassessed, params.ActorUserID, payload, riskSnapshot); err != nil { + tx.Rollback() + return nil, err + } + if err := s.RecordRiskScoreSnapshot(tx, *risk.ID, RiskEventTypeScoreReassessed, params.ActorUserID, reviewedAt); err != nil { tx.Rollback() return nil, err } @@ -582,6 +637,12 @@ func (s *RiskService) ReviewRisk(params ReviewRiskParams) (*Risk, error) { tx.Rollback() return nil, err } + if decision == RiskReviewDecisionReopen || decision == RiskReviewDecisionImplement { + if err := s.RecordRiskScoreSnapshot(tx, *risk.ID, RiskEventTypeStatusChange, params.ActorUserID, reviewedAt); err != nil { + tx.Rollback() + return nil, err + } + } } if err := tx.Commit().Error; err != nil { @@ -1362,6 +1423,13 @@ func rollbackTxOnPanic(tx *gorm.DB) { } } +func stringPtrValue(value *string) any { + if value == nil { + return nil + } + return *value +} + // ControlKey is a composite key used to match a risk's linked controls against // a profile's control set. CatalogID may be empty when only control IDs are // available (e.g. from the profile resolution layer). @@ -1537,6 +1605,9 @@ func (s *RiskService) RemediateOrphanedRisks( if err := s.logRiskEventWithSnapshot(tx, event.riskID, RiskEventTypeStatusChange, nil, event.payload, snapshot); err != nil { return remediated, fmt.Errorf("remediate orphaned risks: emit event failed for risk %s: %w", event.riskID, err) } + if err := s.RecordRiskScoreSnapshot(tx, event.riskID, RiskEventTypeStatusChange, nil, time.Now().UTC()); err != nil { + return remediated, fmt.Errorf("remediate orphaned risks: score snapshot failed for risk %s: %w", event.riskID, err) + } } return remediated, nil diff --git a/internal/service/relational/risks/service_test.go b/internal/service/relational/risks/service_test.go index 1f201b8f..ad0fe513 100644 --- a/internal/service/relational/risks/service_test.go +++ b/internal/service/relational/risks/service_test.go @@ -401,7 +401,7 @@ func TestRiskServiceUpdateStatusAndReviewUsesSingleRiskSnapshotLoad(t *testing.T ReviewJustification: &reviewJustification, }) require.NoError(t, err) - require.Equal(t, 2, riskQueryCount, fmt.Sprintf("expected one risk snapshot load plus final GetByID, got %d", riskQueryCount)) + require.Equal(t, 3, riskQueryCount, fmt.Sprintf("expected risk snapshot load, score snapshot load, and final GetByID, got %d", riskQueryCount)) } func TestRiskServiceResolveUserIDAndPrimaryValidation(t *testing.T) { @@ -1356,6 +1356,7 @@ func newRiskServiceTestDB(t *testing.T) *gorm.DB { require.NoError(t, err) require.NoError(t, db.AutoMigrate( &Risk{}, + &RiskScore{}, &RiskEvent{}, &RiskReview{}, &RiskEvidenceLink{}, diff --git a/internal/service/worker/risk_evidence_worker.go b/internal/service/worker/risk_evidence_worker.go index 12d288f6..7f5ea959 100644 --- a/internal/service/worker/risk_evidence_worker.go +++ b/internal/service/worker/risk_evidence_worker.go @@ -481,6 +481,9 @@ func (w *RiskEvidenceWorker) updateExistingRisk(ctx context.Context, existingRis }); err != nil { return fmt.Errorf("failed to emit reopen status change event: %w", err) } + if err := risks.NewRiskService(w.db).RecordRiskScoreSnapshot(tx, *existingRisk.ID, risks.RiskEventTypeStatusChange, nil, now); err != nil { + return fmt.Errorf("failed to record reopened risk score snapshot: %w", err) + } } // Emit a risk_event(last_seen) using the typed constant. @@ -555,6 +558,9 @@ func (w *RiskEvidenceWorker) createNewRiskForSSP(ctx context.Context, riskTempla }); err != nil { return fmt.Errorf("failed to emit created risk event: %w", err) } + if err := risks.NewRiskService(w.db).RecordRiskScoreSnapshot(tx, *newRisk.ID, risks.RiskEventTypeCreated, nil, now); err != nil { + return fmt.Errorf("failed to record created risk score snapshot: %w", err) + } return nil }) if err != nil { @@ -897,6 +903,9 @@ func (w *RiskEvidenceWorker) resolveRiskEvidenceLink(ctx context.Context, risk * }); err != nil { return fmt.Errorf("failed to emit status change event: %w", err) } + if err := risks.NewRiskService(w.db).RecordRiskScoreSnapshot(tx, *risk.ID, risks.RiskEventTypeStatusChange, nil, time.Now().UTC()); err != nil { + return fmt.Errorf("failed to record remediated risk score snapshot: %w", err) + } w.logger.Infow("Risk transitioned to remediated", "risk_id", risk.ID, "evidence_id", evidenceStreamID, "from_status", oldStatus) diff --git a/internal/service/worker/risk_evidence_worker_test.go b/internal/service/worker/risk_evidence_worker_test.go index 24497f36..7b9079b2 100644 --- a/internal/service/worker/risk_evidence_worker_test.go +++ b/internal/service/worker/risk_evidence_worker_test.go @@ -47,6 +47,7 @@ func newRiskEvidenceWorkerTestDB(t *testing.T) *gorm.DB { &templates.RemediationTemplate{}, &templates.RemediationTask{}, &risks.Risk{}, + &risks.RiskScore{}, &risks.RiskEvidenceLink{}, &risks.RiskSubjectLink{}, &risks.RiskComponentLink{}, diff --git a/internal/service/worker/risk_workers_test.go b/internal/service/worker/risk_workers_test.go index 3b849110..8a7d1f31 100644 --- a/internal/service/worker/risk_workers_test.go +++ b/internal/service/worker/risk_workers_test.go @@ -61,6 +61,7 @@ func newRiskWorkersTestDB(t *testing.T) *gorm.DB { &relational.SystemComponent{}, &relational.InventoryItem{}, &riskrel.Risk{}, + &riskrel.RiskScore{}, &riskrel.RiskOwnerAssignment{}, &riskrel.RiskControlLink{}, &riskrel.RiskEvidenceLink{}, diff --git a/internal/tests/migrate.go b/internal/tests/migrate.go index a2432f72..ad5a4014 100644 --- a/internal/tests/migrate.go +++ b/internal/tests/migrate.go @@ -118,6 +118,7 @@ func (t *TestMigrator) Up() error { &relational.Observation{}, &relational.Finding{}, &riskrel.Risk{}, + &riskrel.RiskScore{}, &riskrel.RiskEvent{}, &riskrel.RiskReview{}, &riskrel.RiskEvidenceLink{}, @@ -330,6 +331,7 @@ func (t *TestMigrator) Down() error { &relational.Observation{}, &relational.Finding{}, &riskrel.Risk{}, + &riskrel.RiskScore{}, &riskrel.RiskEvent{}, &riskrel.RiskReview{}, &riskrel.RiskEvidenceLink{}, From ba7abf9762317e9d429462ca3b7a3d9baa140372 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Tue, 14 Apr 2026 06:01:24 -0300 Subject: [PATCH 2/3] Harden risk score timestamps and timeseries validation --- internal/api/handler/risks.go | 25 +++++++---- internal/api/handler/risks_test.go | 41 +++++++++++++++++++ internal/service/relational/risks/service.go | 6 ++- .../service/worker/risk_evidence_worker.go | 24 +++++++---- 4 files changed, 81 insertions(+), 15 deletions(-) create mode 100644 internal/api/handler/risks_test.go diff --git a/internal/api/handler/risks.go b/internal/api/handler/risks.go index 78bb1f6c..4e996163 100644 --- a/internal/api/handler/risks.go +++ b/internal/api/handler/risks.go @@ -32,6 +32,7 @@ type RiskHandler struct { const ( maxRiskTitleLength = 1000 maxRiskDescriptionLength = 1000 + maxRiskScoreRangeDays = 366 ) func NewRiskHandler(sugar *zap.SugaredLogger, db *gorm.DB, poamSvc *poamsvc.PoamService, riskSvc *riskrel.RiskService) *RiskHandler { @@ -1280,7 +1281,11 @@ func (h *RiskHandler) GetScoreHistory(ctx echo.Context) error { resp := make([]riskScoreResponse, 0, len(scores)) for _, score := range scores { - resp = append(resp, mapRiskScoreToResponse(score)) + mapped, err := mapRiskScoreToResponse(score) + if err != nil { + return h.internalServerError(ctx, "failed to map risk score history", err) + } + resp = append(resp, mapped) } return ctx.JSON(http.StatusOK, GenericDataListResponse[riskScoreResponse]{Data: resp}) } @@ -2148,16 +2153,22 @@ func parseScoreTimeseriesParams(ctx echo.Context) (*uuid.UUID, time.Time, time.T bucket = riskrel.RiskScoreBucketDay } + if to.Before(from) { + return nil, time.Time{}, time.Time{}, "", fmt.Errorf("to must be greater than or equal to from") + } + if to.Sub(from) > time.Duration(maxRiskScoreRangeDays)*24*time.Hour { + return nil, time.Time{}, time.Time{}, "", fmt.Errorf("score timeseries range must not exceed %d days", maxRiskScoreRangeDays) + } + return sspID, from, to, bucket, nil } -func mapRiskScoreToResponse(score riskrel.RiskScore) riskScoreResponse { - id := uuid.Nil - if score.ID != nil { - id = *score.ID +func mapRiskScoreToResponse(score riskrel.RiskScore) (riskScoreResponse, error) { + if score.ID == nil { + return riskScoreResponse{}, fmt.Errorf("risk score is missing required id") } return riskScoreResponse{ - ID: id, + ID: *score.ID, RiskID: score.RiskID, SSPID: score.SSPID, OccurredAt: score.OccurredAt, @@ -2171,7 +2182,7 @@ func mapRiskScoreToResponse(score riskrel.RiskScore) riskScoreResponse { ResidualScore: score.ResidualScore, OpenBaselineScore: score.OpenBaselineScore, OpenResidualScore: score.OpenResidualScore, - } + }, nil } func cloneStringPtr(value *string) *string { diff --git a/internal/api/handler/risks_test.go b/internal/api/handler/risks_test.go new file mode 100644 index 00000000..4410a96e --- /dev/null +++ b/internal/api/handler/risks_test.go @@ -0,0 +1,41 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/compliance-framework/api/internal/service/relational" + riskrel "github.com/compliance-framework/api/internal/service/relational/risks" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" +) + +func TestParseScoreTimeseriesParamsRejectsExcessiveRange(t *testing.T) { + e := echo.New() + from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + to := from.AddDate(0, 0, maxRiskScoreRangeDays+1) + req := httptest.NewRequest(http.MethodGet, "/risks/score-timeseries?from="+from.Format(time.RFC3339)+"&to="+to.Format(time.RFC3339), nil) + ctx := e.NewContext(req, httptest.NewRecorder()) + + _, _, _, _, err := parseScoreTimeseriesParams(ctx) + require.Error(t, err) + require.Contains(t, err.Error(), "score timeseries range must not exceed") +} + +func TestMapRiskScoreToResponseRequiresID(t *testing.T) { + _, err := mapRiskScoreToResponse(riskrel.RiskScore{}) + require.Error(t, err) + require.Contains(t, err.Error(), "missing required id") + + id := uuid.New() + resp, err := mapRiskScoreToResponse(riskrel.RiskScore{ + UUIDModel: relational.UUIDModel{ID: &id}, + RiskID: uuid.New(), + SSPID: uuid.New(), + }) + require.NoError(t, err) + require.Equal(t, id, resp.ID) +} diff --git a/internal/service/relational/risks/service.go b/internal/service/relational/risks/service.go index cf2bf843..a2568bc9 100644 --- a/internal/service/relational/risks/service.go +++ b/internal/service/relational/risks/service.go @@ -302,7 +302,11 @@ func (s *RiskService) Update(params UpdateRiskParams) (*Risk, error) { if params.ScoreChanged { sourceEventType = RiskEventTypeScoreUpdated } - if err := s.RecordRiskScoreSnapshot(tx, *params.Risk.ID, sourceEventType, params.ActorUserID, time.Now().UTC()); err != nil { + snapshotOccurredAt := params.Risk.UpdatedAt.UTC() + if params.Risk.UpdatedAt.IsZero() { + snapshotOccurredAt = time.Now().UTC() + } + if err := s.RecordRiskScoreSnapshot(tx, *params.Risk.ID, sourceEventType, params.ActorUserID, snapshotOccurredAt); err != nil { tx.Rollback() return nil, err } diff --git a/internal/service/worker/risk_evidence_worker.go b/internal/service/worker/risk_evidence_worker.go index 7f5ea959..2668949d 100644 --- a/internal/service/worker/risk_evidence_worker.go +++ b/internal/service/worker/risk_evidence_worker.go @@ -473,12 +473,12 @@ func (w *RiskEvidenceWorker) updateExistingRisk(ctx context.Context, existingRis } if reopened { - if err := w.emitRiskEvent(ctx, tx, *existingRisk.ID, string(risks.RiskEventTypeStatusChange), map[string]interface{}{ + if err := w.emitRiskEventAt(ctx, tx, *existingRisk.ID, string(risks.RiskEventTypeStatusChange), map[string]interface{}{ "from": oldStatus, "to": string(risks.RiskStatusOpen), "evidence_id": evidence.UUID, "reason": "new_failing_evidence", - }); err != nil { + }, now); err != nil { return fmt.Errorf("failed to emit reopen status change event: %w", err) } if err := risks.NewRiskService(w.db).RecordRiskScoreSnapshot(tx, *existingRisk.ID, risks.RiskEventTypeStatusChange, nil, now); err != nil { @@ -550,12 +550,12 @@ func (w *RiskEvidenceWorker) createNewRiskForSSP(ctx context.Context, riskTempla return err } // Emit a risk_event(created) using the typed constant - if err := w.emitRiskEvent(ctx, tx, *newRisk.ID, string(risks.RiskEventTypeCreated), map[string]interface{}{ + if err := w.emitRiskEventAt(ctx, tx, *newRisk.ID, string(risks.RiskEventTypeCreated), map[string]interface{}{ "evidence_id": evidence.UUID, "template_id": riskTemplate.ID, "dedupe_key": dedupeKey, "ssp_id": sspID, - }); err != nil { + }, now); err != nil { return fmt.Errorf("failed to emit created risk event: %w", err) } if err := risks.NewRiskService(w.db).RecordRiskScoreSnapshot(tx, *newRisk.ID, risks.RiskEventTypeCreated, nil, now); err != nil { @@ -893,17 +893,18 @@ func (w *RiskEvidenceWorker) resolveRiskEvidenceLink(ctx context.Context, risk * // Transition to remediated. oldStatus := risk.Status + occurredAt := time.Now().UTC() if err := tx.Model(risk).Update("status", string(risks.RiskStatusRemediated)).Error; err != nil { return fmt.Errorf("failed to transition risk to remediated: %w", err) } - if err := w.emitRiskEvent(ctx, tx, *risk.ID, string(risks.RiskEventTypeStatusChange), map[string]interface{}{ + if err := w.emitRiskEventAt(ctx, tx, *risk.ID, string(risks.RiskEventTypeStatusChange), map[string]interface{}{ "from": oldStatus, "to": string(risks.RiskStatusRemediated), "reason": "all_evidence_resolved", - }); err != nil { + }, occurredAt); err != nil { return fmt.Errorf("failed to emit status change event: %w", err) } - if err := risks.NewRiskService(w.db).RecordRiskScoreSnapshot(tx, *risk.ID, risks.RiskEventTypeStatusChange, nil, time.Now().UTC()); err != nil { + if err := risks.NewRiskService(w.db).RecordRiskScoreSnapshot(tx, *risk.ID, risks.RiskEventTypeStatusChange, nil, occurredAt); err != nil { return fmt.Errorf("failed to record remediated risk score snapshot: %w", err) } @@ -1034,6 +1035,15 @@ func normalizeRenderedRiskLevel(level *string) *string { // Accepts a *gorm.DB so the caller can pass a transaction handle. func (w *RiskEvidenceWorker) emitRiskEvent(ctx context.Context, db *gorm.DB, riskID uuid.UUID, eventType string, payload map[string]interface{}) error { occurredAt := time.Now().UTC() + return w.emitRiskEventAt(ctx, db, riskID, eventType, payload, occurredAt) +} + +func (w *RiskEvidenceWorker) emitRiskEventAt(ctx context.Context, db *gorm.DB, riskID uuid.UUID, eventType string, payload map[string]interface{}, occurredAt time.Time) error { + if occurredAt.IsZero() { + occurredAt = time.Now().UTC() + } else { + occurredAt = occurredAt.UTC() + } payloadMap := datatypes.JSONMap(payload) details := risks.BuildRiskEventDetails(eventType, payloadMap, occurredAt) From dd8afca85c4fb199dbd92ac79f835a9fd5969009 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Tue, 14 Apr 2026 06:18:45 -0300 Subject: [PATCH 3/3] Document paginated risk score history --- docs/docs.go | 63 ++++++++++++++----- docs/swagger.json | 63 ++++++++++++++----- docs/swagger.yaml | 43 ++++++++++--- internal/api/handler/risks.go | 56 +++++++++-------- .../api/handler/risks_integration_test.go | 33 ++++++++-- internal/service/relational/risks/events.go | 1 + internal/service/relational/risks/scores.go | 33 +++++++--- .../service/relational/risks/scores_test.go | 31 +++++++++ internal/service/relational/risks/service.go | 4 ++ 9 files changed, 251 insertions(+), 76 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 8f28fc6b..cea59d3f 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -19375,13 +19375,25 @@ const docTemplate = `{ "name": "id", "in": "path", "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataListResponse-handler_riskScoreResponse" + "$ref": "#/definitions/service.ListResponse-handler_riskScoreResponse" } }, "400": { @@ -22445,13 +22457,25 @@ const docTemplate = `{ "name": "id", "in": "path", "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataListResponse-handler_riskScoreResponse" + "$ref": "#/definitions/service.ListResponse-handler_riskScoreResponse" } }, "400": { @@ -26956,18 +26980,6 @@ const docTemplate = `{ } } }, - "handler.GenericDataListResponse-handler_riskScoreResponse": { - "type": "object", - "properties": { - "data": { - "description": "Items from the list response", - "type": "array", - "items": { - "$ref": "#/definitions/handler.riskScoreResponse" - } - } - } - }, "handler.GenericDataListResponse-handler_riskScoreTimeseriesResponse": { "type": "object", "properties": { @@ -37648,6 +37660,29 @@ const docTemplate = `{ } } }, + "service.ListResponse-handler_riskScoreResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.riskScoreResponse" + } + }, + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, "service.ListResponse-handler_threatIDResponse": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 57bc10c2..aec9ea25 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -19369,13 +19369,25 @@ "name": "id", "in": "path", "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataListResponse-handler_riskScoreResponse" + "$ref": "#/definitions/service.ListResponse-handler_riskScoreResponse" } }, "400": { @@ -22439,13 +22451,25 @@ "name": "id", "in": "path", "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.GenericDataListResponse-handler_riskScoreResponse" + "$ref": "#/definitions/service.ListResponse-handler_riskScoreResponse" } }, "400": { @@ -26950,18 +26974,6 @@ } } }, - "handler.GenericDataListResponse-handler_riskScoreResponse": { - "type": "object", - "properties": { - "data": { - "description": "Items from the list response", - "type": "array", - "items": { - "$ref": "#/definitions/handler.riskScoreResponse" - } - } - } - }, "handler.GenericDataListResponse-handler_riskScoreTimeseriesResponse": { "type": "object", "properties": { @@ -37642,6 +37654,29 @@ } } }, + "service.ListResponse-handler_riskScoreResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.riskScoreResponse" + } + }, + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, "service.ListResponse-handler_threatIDResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 3e1a739a..dc2861e3 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -571,14 +571,6 @@ definitions: $ref: '#/definitions/handler.poamItemResponse' type: array type: object - handler.GenericDataListResponse-handler_riskScoreResponse: - properties: - data: - description: Items from the list response - items: - $ref: '#/definitions/handler.riskScoreResponse' - type: array - type: object handler.GenericDataListResponse-handler_riskScoreTimeseriesResponse: properties: data: @@ -7524,6 +7516,21 @@ definitions: totalPages: type: integer type: object + service.ListResponse-handler_riskScoreResponse: + properties: + data: + items: + $ref: '#/definitions/handler.riskScoreResponse' + type: array + limit: + type: integer + page: + type: integer + total: + type: integer + totalPages: + type: integer + type: object service.ListResponse-handler_threatIDResponse: properties: data: @@ -21629,13 +21636,21 @@ paths: name: id required: true type: string + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/handler.GenericDataListResponse-handler_riskScoreResponse' + $ref: '#/definitions/service.ListResponse-handler_riskScoreResponse' "400": description: Bad Request schema: @@ -23611,13 +23626,21 @@ paths: name: id required: true type: string + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/handler.GenericDataListResponse-handler_riskScoreResponse' + $ref: '#/definitions/service.ListResponse-handler_riskScoreResponse' "400": description: Bad Request schema: diff --git a/internal/api/handler/risks.go b/internal/api/handler/risks.go index 4e996163..bea7d960 100644 --- a/internal/api/handler/risks.go +++ b/internal/api/handler/risks.go @@ -1225,7 +1225,9 @@ func (h *RiskHandler) GetReviews(ctx echo.Context) error { // @Produce json // @Param sspId path string true "SSP ID" // @Param id path string true "Risk ID" -// @Success 200 {object} GenericDataListResponse[riskScoreResponse] +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} svc.ListResponse[riskScoreResponse] // @Failure 400 {object} api.Error // @Failure 404 {object} api.Error // @Failure 500 {object} api.Error @@ -1255,39 +1257,39 @@ func (h *RiskHandler) GetScoreHistoryForSSP(ctx echo.Context) error { // @Description Lists score snapshots for a risk. // @Tags Risks // @Produce json -// @Param id path string true "Risk ID" -// @Success 200 {object} GenericDataListResponse[riskScoreResponse] -// @Failure 400 {object} api.Error -// @Failure 404 {object} api.Error -// @Failure 500 {object} api.Error +// @Param id path string true "Risk ID" +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} svc.ListResponse[riskScoreResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error // @Security OAuth2Password // @Router /risks/{id}/score-history [get] func (h *RiskHandler) GetScoreHistory(ctx echo.Context) error { - riskID, err := parsePathUUID(ctx, "id") - if err != nil { - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - if err := h.ensureRiskExists(riskID); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("risk not found"))) + return h.withRiskListContext(ctx, func(riskID uuid.UUID, pagination *svc.PaginationParams) error { + if err := h.ensureRiskExists(riskID); 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 risk", err) } - return h.internalServerError(ctx, "failed to validate risk", err) - } - scores, err := h.riskService.ListScoreHistory(riskID) - if err != nil { - return h.internalServerError(ctx, "failed to list risk score history", err) - } - - resp := make([]riskScoreResponse, 0, len(scores)) - for _, score := range scores { - mapped, err := mapRiskScoreToResponse(score) + scores, total, err := h.riskService.ListScoreHistoryPage(riskID, pagination.Limit, pagination.Offset) if err != nil { - return h.internalServerError(ctx, "failed to map risk score history", err) + return h.internalServerError(ctx, "failed to list risk score history", err) } - resp = append(resp, mapped) - } - return ctx.JSON(http.StatusOK, GenericDataListResponse[riskScoreResponse]{Data: resp}) + + resp := make([]riskScoreResponse, 0, len(scores)) + for _, score := range scores { + mapped, err := mapRiskScoreToResponse(score) + if err != nil { + return h.internalServerError(ctx, "failed to map risk score history", err) + } + resp = append(resp, mapped) + } + return ctx.JSON(http.StatusOK, svc.NewListResponse(resp, total, pagination.Page, pagination.Limit)) + }) } // GetScoreTimeseriesForSSP godoc diff --git a/internal/api/handler/risks_integration_test.go b/internal/api/handler/risks_integration_test.go index 0778526c..6d8bac8a 100644 --- a/internal/api/handler/risks_integration_test.go +++ b/internal/api/handler/risks_integration_test.go @@ -444,6 +444,22 @@ func (suite *RiskApiIntegrationSuite) TestRiskReassessReviewEndpoints() { require.Equal(suite.T(), 4, history.Data[1].OpenBaselineScore) require.Equal(suite.T(), 15, history.Data[1].OpenResidualScore) + pagedHistoryRec, pagedHistoryReq := suite.authedRequest(http.MethodGet, fmt.Sprintf("/api/risks/%s/score-history?page=1&limit=1", created.ID), nil) + suite.server.E().ServeHTTP(pagedHistoryRec, pagedHistoryReq) + require.Equal(suite.T(), http.StatusOK, pagedHistoryRec.Code) + + var pagedHistory struct { + Data []riskScoreResponse `json:"data"` + Total int64 `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` + } + require.NoError(suite.T(), json.Unmarshal(pagedHistoryRec.Body.Bytes(), &pagedHistory)) + require.Len(suite.T(), pagedHistory.Data, 1) + require.Equal(suite.T(), int64(2), pagedHistory.Total) + require.Equal(suite.T(), 1, pagedHistory.Page) + require.Equal(suite.T(), 1, pagedHistory.Limit) + scopedHistoryRec, scopedHistoryReq := suite.authedRequest(http.MethodGet, fmt.Sprintf("/api/oscal/system-security-plans/%s/risks/%s/score-history", created.SSPID, created.ID), nil) suite.server.E().ServeHTTP(scopedHistoryRec, scopedHistoryReq) require.Equal(suite.T(), http.StatusOK, scopedHistoryRec.Code) @@ -532,6 +548,7 @@ func (suite *RiskApiIntegrationSuite) TestRiskScoreTimeseriesAggregatesBySSPAndG riskA1 := suite.createScoredRiskSnapshotFixture(sspA, "a1") riskA2 := suite.createScoredRiskSnapshotFixture(sspA, "a2") + riskA3 := suite.createScoredRiskSnapshotFixture(sspA, "a3") riskB1 := suite.createScoredRiskSnapshotFixture(sspB, "b1") low := "low" @@ -558,6 +575,14 @@ func (suite *RiskApiIntegrationSuite) TestRiskScoreTimeseriesAggregatesBySSPAndG RiskID: riskA2, SSPID: sspA, OccurredAt: day1, SourceEventType: string(riskrel.RiskEventTypeCreated), Status: string(riskrel.RiskStatusOpen), Likelihood: &low, Impact: &low, BaselineScore: 4, ResidualScore: 4, OpenBaselineScore: 4, OpenResidualScore: 4, }, + { + RiskID: riskA3, SSPID: sspA, OccurredAt: day1, SourceEventType: string(riskrel.RiskEventTypeCreated), Status: string(riskrel.RiskStatusOpen), + Likelihood: &high, Impact: &low, BaselineScore: 8, ResidualScore: 8, OpenBaselineScore: 8, OpenResidualScore: 8, + }, + { + RiskID: riskA3, SSPID: sspA, OccurredAt: day2, SourceEventType: string(riskrel.RiskEventTypeDeleted), Status: riskrel.RiskScoreStatusDeleted, + Likelihood: &high, Impact: &low, BaselineScore: 8, ResidualScore: 8, OpenBaselineScore: 0, OpenResidualScore: 0, + }, { RiskID: riskB1, SSPID: sspB, OccurredAt: day1, SourceEventType: string(riskrel.RiskEventTypeCreated), Status: string(riskrel.RiskStatusOpen), Likelihood: &critical, Impact: &critical, BaselineScore: 25, ResidualScore: 25, OpenBaselineScore: 25, OpenResidualScore: 25, @@ -574,8 +599,8 @@ func (suite *RiskApiIntegrationSuite) TestRiskScoreTimeseriesAggregatesBySSPAndG var scoped GenericDataListResponse[riskScoreTimeseriesResponse] require.NoError(suite.T(), json.Unmarshal(scopedRec.Body.Bytes(), &scoped)) require.Len(suite.T(), scoped.Data, 3) - require.Equal(suite.T(), 20, scoped.Data[0].OpenBaselineScore) - require.Equal(suite.T(), 20, scoped.Data[0].OpenResidualScore) + require.Equal(suite.T(), 28, scoped.Data[0].OpenBaselineScore) + require.Equal(suite.T(), 28, scoped.Data[0].OpenResidualScore) require.Equal(suite.T(), 20, scoped.Data[1].OpenBaselineScore) require.Equal(suite.T(), 12, scoped.Data[1].OpenResidualScore) require.Equal(suite.T(), 4, scoped.Data[2].OpenBaselineScore) @@ -588,8 +613,8 @@ func (suite *RiskApiIntegrationSuite) TestRiskScoreTimeseriesAggregatesBySSPAndG var global GenericDataListResponse[riskScoreTimeseriesResponse] require.NoError(suite.T(), json.Unmarshal(globalRec.Body.Bytes(), &global)) require.Len(suite.T(), global.Data, 3) - require.Equal(suite.T(), 45, global.Data[0].OpenBaselineScore) - require.Equal(suite.T(), 45, global.Data[0].OpenResidualScore) + require.Equal(suite.T(), 53, global.Data[0].OpenBaselineScore) + require.Equal(suite.T(), 53, global.Data[0].OpenResidualScore) require.Equal(suite.T(), 45, global.Data[1].OpenBaselineScore) require.Equal(suite.T(), 37, global.Data[1].OpenResidualScore) require.Equal(suite.T(), 29, global.Data[2].OpenBaselineScore) diff --git a/internal/service/relational/risks/events.go b/internal/service/relational/risks/events.go index ce099152..d43562e6 100644 --- a/internal/service/relational/risks/events.go +++ b/internal/service/relational/risks/events.go @@ -15,6 +15,7 @@ type RiskEventType string const ( RiskEventTypeCreated RiskEventType = "created" + RiskEventTypeDeleted RiskEventType = "deleted" RiskEventTypeLastSeen RiskEventType = "last_seen" RiskEventTypeStatusChange RiskEventType = "status_changed" RiskEventTypeAccepted RiskEventType = "accepted" diff --git a/internal/service/relational/risks/scores.go b/internal/service/relational/risks/scores.go index 53dbb9f8..dd2b750f 100644 --- a/internal/service/relational/risks/scores.go +++ b/internal/service/relational/risks/scores.go @@ -11,7 +11,8 @@ import ( ) const ( - RiskScoreBucketDay = "day" + RiskScoreBucketDay = "day" + RiskScoreStatusDeleted = "deleted" ) type RiskScore struct { @@ -141,9 +142,14 @@ func (s *RiskService) RecordRiskScoreSnapshot(tx *gorm.DB, riskID uuid.UUID, sou } } + status := risk.Status openBaselineScore := baselineScore openResidualScore := residualScore - if isTerminalRiskStatus(risk.Status) { + if sourceEventType == RiskEventTypeDeleted { + status = RiskScoreStatusDeleted + openBaselineScore = 0 + openResidualScore = 0 + } else if isTerminalRiskStatus(risk.Status) { openBaselineScore = 0 openResidualScore = 0 } @@ -154,7 +160,7 @@ func (s *RiskService) RecordRiskScoreSnapshot(tx *gorm.DB, riskID uuid.UUID, sou OccurredAt: occurredAt, ActorUserID: actorUserID, SourceEventType: string(sourceEventType), - Status: risk.Status, + Status: status, Likelihood: risk.Likelihood, Impact: risk.Impact, BaselineScore: baselineScore, @@ -179,14 +185,27 @@ func isTerminalRiskStatus(status string) bool { } func (s *RiskService) ListScoreHistory(riskID uuid.UUID) ([]RiskScore, error) { + scores, _, err := s.ListScoreHistoryPage(riskID, -1, 0) + return scores, err +} + +func (s *RiskService) ListScoreHistoryPage(riskID uuid.UUID, limit, offset int) ([]RiskScore, int64, error) { + q := s.db.Model(&RiskScore{}).Where("risk_id = ?", riskID) + + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var scores []RiskScore - if err := s.db. - Where("risk_id = ?", riskID). + if err := q. Order("occurred_at ASC, created_at ASC, id ASC"). + Limit(limit). + Offset(offset). Find(&scores).Error; err != nil { - return nil, err + return nil, 0, err } - return scores, nil + return scores, total, nil } func (s *RiskService) ListScoreTimeseries(sspID *uuid.UUID, from, to time.Time, bucket string) ([]RiskScoreTimeseriesPoint, error) { diff --git a/internal/service/relational/risks/scores_test.go b/internal/service/relational/risks/scores_test.go index cda5c2d2..1bbde6bc 100644 --- a/internal/service/relational/risks/scores_test.go +++ b/internal/service/relational/risks/scores_test.go @@ -188,3 +188,34 @@ func TestRiskScoreSnapshotFirstCompleteScoreBecomesBaseline(t *testing.T) { require.Equal(t, 10, history[0].BaselineScore) require.Equal(t, 10, history[0].ResidualScore) } + +func TestRiskDeleteRecordsZeroContributionScoreSnapshot(t *testing.T) { + db := newRiskServiceTestDB(t) + svc := NewRiskService(db) + + sspID := uuid.New() + high := "high" + created, err := svc.Create(CreateRiskParams{ + Risk: Risk{ + Title: "deleted scored risk", + Description: "desc", + Status: string(RiskStatusOpen), + SSPID: sspID, + Likelihood: &high, + Impact: &high, + }, + }) + require.NoError(t, err) + + require.NoError(t, svc.Delete(*created.ID)) + + history, err := svc.ListScoreHistory(*created.ID) + require.NoError(t, err) + require.Len(t, history, 2) + require.Equal(t, string(RiskEventTypeDeleted), history[1].SourceEventType) + require.Equal(t, RiskScoreStatusDeleted, history[1].Status) + require.Equal(t, 16, history[1].BaselineScore) + require.Equal(t, 16, history[1].ResidualScore) + require.Equal(t, 0, history[1].OpenBaselineScore) + require.Equal(t, 0, history[1].OpenResidualScore) +} diff --git a/internal/service/relational/risks/service.go b/internal/service/relational/risks/service.go index a2568bc9..99bb5ed0 100644 --- a/internal/service/relational/risks/service.go +++ b/internal/service/relational/risks/service.go @@ -700,6 +700,10 @@ func (s *RiskService) Delete(riskID uuid.UUID) error { tx.Rollback() return err } + if err := s.RecordRiskScoreSnapshot(tx, riskID, RiskEventTypeDeleted, nil, time.Now().UTC()); err != nil { + tx.Rollback() + return err + } result := tx.Delete(&Risk{}, "id = ?", riskID) if result.Error != nil {