diff --git a/docs/docs.go b/docs/docs.go index dd0af376..bf3826d0 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -3914,6 +3914,64 @@ const docTemplate = `{ } } }, + "/evidence/{id}/risks": { + "get": { + "description": "Retrieves every risk register entry linked to the evidence record's stream, across all System Security Plans.", + "produces": [ + "application/json" + ], + "tags": [ + "Evidence" + ], + "summary": "List Risks linked to an Evidence stream", + "parameters": [ + { + "type": "string", + "description": "Evidence ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-handler_EvidenceRiskResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "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": [] + } + ] + } + }, "/evidence/{id}/signature": { "get": { "description": "Retrieves the stored signature envelope for a single Evidence record.", @@ -32936,6 +32994,38 @@ const docTemplate = `{ } } }, + "handler.EvidenceRiskResponse": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "impact": { + "type": "string" + }, + "likelihood": { + "type": "string" + }, + "source-type": { + "type": "string" + }, + "ssp-id": { + "type": "string" + }, + "ssp-title": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "handler.EvidenceSignatureResponse": { "type": "object", "properties": { @@ -33113,6 +33203,19 @@ const docTemplate = `{ "meta": {} } }, + "handler.GenericDataListResponse-handler_EvidenceRiskResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/handler.EvidenceRiskResponse" + } + }, + "meta": {} + } + }, "handler.GenericDataListResponse-handler_FilterWithAssociations": { "type": "object", "properties": { @@ -35588,6 +35691,13 @@ const docTemplate = `{ "sspBreakdown": { "$ref": "#/definitions/handler.LineageSSPBreakdown" }, + "sspId": { + "description": "RiskSSPID/RiskSSPTitle identify the single SSP a risk node belongs to\n(risk_register_risks.ssp_id is not-null — every risk has exactly one).\nPopulated regardless of scope so the UI can group risk nodes by SSP in\nthe unscoped \"All SSPs\" view.", + "type": "string" + }, + "sspTitle": { + "type": "string" + }, "statement": { "description": "Statement is the control's requirement prose (OSCAL \"statement\" part),\nsurfaced for hover/tooltip in the lineage tree \u0026 graph. Control nodes only.", "type": "string" diff --git a/docs/swagger.json b/docs/swagger.json index fc8b2ac3..ab6a7593 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -3908,6 +3908,64 @@ } } }, + "/evidence/{id}/risks": { + "get": { + "description": "Retrieves every risk register entry linked to the evidence record's stream, across all System Security Plans.", + "produces": [ + "application/json" + ], + "tags": [ + "Evidence" + ], + "summary": "List Risks linked to an Evidence stream", + "parameters": [ + { + "type": "string", + "description": "Evidence ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-handler_EvidenceRiskResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "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": [] + } + ] + } + }, "/evidence/{id}/signature": { "get": { "description": "Retrieves the stored signature envelope for a single Evidence record.", @@ -32930,6 +32988,38 @@ } } }, + "handler.EvidenceRiskResponse": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "impact": { + "type": "string" + }, + "likelihood": { + "type": "string" + }, + "source-type": { + "type": "string" + }, + "ssp-id": { + "type": "string" + }, + "ssp-title": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "handler.EvidenceSignatureResponse": { "type": "object", "properties": { @@ -33107,6 +33197,19 @@ "meta": {} } }, + "handler.GenericDataListResponse-handler_EvidenceRiskResponse": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "type": "array", + "items": { + "$ref": "#/definitions/handler.EvidenceRiskResponse" + } + }, + "meta": {} + } + }, "handler.GenericDataListResponse-handler_FilterWithAssociations": { "type": "object", "properties": { @@ -35582,6 +35685,13 @@ "sspBreakdown": { "$ref": "#/definitions/handler.LineageSSPBreakdown" }, + "sspId": { + "description": "RiskSSPID/RiskSSPTitle identify the single SSP a risk node belongs to\n(risk_register_risks.ssp_id is not-null — every risk has exactly one).\nPopulated regardless of scope so the UI can group risk nodes by SSP in\nthe unscoped \"All SSPs\" view.", + "type": "string" + }, + "sspTitle": { + "type": "string" + }, "statement": { "description": "Statement is the control's requirement prose (OSCAL \"statement\" part),\nsurfaced for hover/tooltip in the lineage tree \u0026 graph. Control nodes only.", "type": "string" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 945024b5..244aa571 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -419,6 +419,27 @@ definitions: for a specific purpose.\"" type: string type: object + handler.EvidenceRiskResponse: + properties: + description: + type: string + id: + type: string + impact: + type: string + likelihood: + type: string + source-type: + type: string + ssp-id: + type: string + ssp-title: + type: string + status: + type: string + title: + type: string + type: object handler.EvidenceSignatureResponse: properties: data: @@ -535,6 +556,15 @@ definitions: type: array meta: {} type: object + handler.GenericDataListResponse-handler_EvidenceRiskResponse: + properties: + data: + description: Items from the list response + items: + $ref: '#/definitions/handler.EvidenceRiskResponse' + type: array + meta: {} + type: object handler.GenericDataListResponse-handler_FilterWithAssociations: properties: data: @@ -2038,6 +2068,15 @@ definitions: SSPBreakdown replaces both in the global (no sspId) view on control nodes. sspBreakdown: $ref: '#/definitions/handler.LineageSSPBreakdown' + sspId: + description: |- + RiskSSPID/RiskSSPTitle identify the single SSP a risk node belongs to + (risk_register_risks.ssp_id is not-null — every risk has exactly one). + Populated regardless of scope so the UI can group risk nodes by SSP in + the unscoped "All SSPs" view. + type: string + sspTitle: + type: string statement: description: |- Statement is the control's requirement prose (OSCAL "statement" part), @@ -13687,6 +13726,44 @@ paths: summary: Get Evidence by ID tags: - Evidence + /evidence/{id}/risks: + get: + description: Retrieves every risk register entry linked to the evidence record's + stream, across all System Security Plans. + parameters: + - description: Evidence ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-handler_EvidenceRiskResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + 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 Risks linked to an Evidence stream + tags: + - Evidence /evidence/{id}/signature: get: description: Retrieves the stored signature envelope for a single Evidence record. diff --git a/internal/api/handler/api.go b/internal/api/handler/api.go index 3c780744..0131dd26 100644 --- a/internal/api/handler/api.go +++ b/internal/api/handler/api.go @@ -100,7 +100,10 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB // Keep the legacy operator-facing metrics route stable while protecting it with user auth. heartbeatHandler.RegisterOverTime(server.API().Group("/agent/heartbeat"), middleware.JWTMiddleware(config.JWTPublicKey), heartbeatGuard.Read()) - evidenceHandler := NewEvidenceHandler(logger, services.EvidenceService) + riskService := riskrel.NewRiskService(db) + riskGuard := pep.For(authz.ResourceRisk) + + evidenceHandler := NewEvidenceHandler(logger, services.EvidenceService, riskService) evidenceGuard := pep.For(authz.ResourceEvidence) evidenceGroup := server.API().Group("/evidence") evidenceHandler.RegisterCreate( @@ -119,8 +122,13 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB evidenceSignatureGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) evidenceHandler.RegisterSignatureRoutes(evidenceSignatureGroup, evidenceGuard.Read()) + // Evidence→risk lookups return risk register data, so they need auth and the risk + // read guard rather than joining the intentionally anonymous evidence read routes. + evidenceRiskGroup := server.API().Group("/evidence") + evidenceRiskGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) + evidenceHandler.RegisterRiskRoutes(evidenceRiskGroup, riskGuard.Read()) + poamService := poamsvc.NewPoamService(db) - riskService := riskrel.NewRiskService(db) poamHandler := NewPoamItemsHandler(poamService, riskService, logger) // Flat route: /api/poam-items (supports ?sspId= query filter) poamGroup := server.API().Group("/poam-items") @@ -134,7 +142,6 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB poamHandler.RegisterSSPScoped(sspPoamGroup, poamGuard) riskHandler := NewRiskHandler(logger, db, poamService, riskService) - riskGuard := pep.For(authz.ResourceRisk) riskGroup := server.API().Group("/risks") riskGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) riskHandler.Register(riskGroup, riskGuard) diff --git a/internal/api/handler/evidence.go b/internal/api/handler/evidence.go index 1451b8cf..0aed92ac 100644 --- a/internal/api/handler/evidence.go +++ b/internal/api/handler/evidence.go @@ -14,6 +14,7 @@ import ( svc "github.com/compliance-framework/api/internal/service" "github.com/compliance-framework/api/internal/service/relational" evidencesvc "github.com/compliance-framework/api/internal/service/relational/evidence" + riskrel "github.com/compliance-framework/api/internal/service/relational/risks" oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" "github.com/google/uuid" "github.com/labstack/echo/v4" @@ -24,13 +25,15 @@ import ( type EvidenceHandler struct { evidenceService *evidencesvc.EvidenceService + riskService *riskrel.RiskService pagination *svc.PaginationConfig sugar *zap.SugaredLogger } -func NewEvidenceHandler(sugar *zap.SugaredLogger, evidenceService *evidencesvc.EvidenceService) *EvidenceHandler { +func NewEvidenceHandler(sugar *zap.SugaredLogger, evidenceService *evidencesvc.EvidenceService, riskService *riskrel.RiskService) *EvidenceHandler { return &EvidenceHandler{ evidenceService: evidenceService, + riskService: riskService, pagination: svc.NewPaginationConfig(), sugar: sugar, } @@ -76,6 +79,13 @@ func (h *EvidenceHandler) RegisterSignatureRoutes(api *echo.Group, middlewares . api.POST("/:id/verify", h.VerifySignature, middlewares...) } +// RegisterRiskRoutes mounts the evidence→risk lookup. The response exposes risk +// register data, so the caller must pass an authenticated group and the risk read +// guard rather than the (anonymous-readable) evidence one. +func (h *EvidenceHandler) RegisterRiskRoutes(api *echo.Group, middlewares ...echo.MiddlewareFunc) { + api.GET("/:id/risks", h.Risks, middlewares...) +} + type EvidenceActivityStep struct { UUID uuid.UUID Title string @@ -779,6 +789,85 @@ func (h *EvidenceHandler) VerifySignature(ctx echo.Context) error { return ctx.JSON(http.StatusOK, EvidenceSignatureVerificationResponse{Data: result}) } +type EvidenceRiskResponse struct { + ID uuid.UUID `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Likelihood *string `json:"likelihood,omitempty"` + Impact *string `json:"impact,omitempty"` + SSPID uuid.UUID `json:"ssp-id"` + SSPTitle string `json:"ssp-title"` + SourceType string `json:"source-type"` +} + +// Risks godoc +// +// @Summary List Risks linked to an Evidence stream +// @Description Retrieves every risk register entry linked to the evidence record's stream, across all System Security Plans. +// @Tags Evidence +// @Produce json +// @Param id path string true "Evidence ID" +// @Success 200 {object} GenericDataListResponse[EvidenceRiskResponse] +// @Failure 400 {object} api.Error +// @Failure 401 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /evidence/{id}/risks [get] +func (h *EvidenceHandler) Risks(ctx echo.Context) error { + idParam := ctx.Param("id") + id, err := parseEvidenceID(ctx) + if err != nil { + h.sugar.Warnw("Invalid evidence id", "id", idParam, "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + evidence, err := h.evidenceService.GetByID(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(err)) + } + h.sugar.Warnw("Failed to load evidence", "id", idParam, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + // Evidence created without a stream UUID can never be linked to a risk, since + // risk_evidence_links keys on the stream. Report that as "no risks", not an error. + if evidence.UUID == uuid.Nil { + return ctx.JSON(http.StatusOK, GenericDataListResponse[EvidenceRiskResponse]{Data: []EvidenceRiskResponse{}}) + } + + risks, err := h.riskService.ListForEvidenceStream(evidence.UUID) + if err != nil { + h.sugar.Warnw("Failed to load risks for evidence", "id", idParam, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + output := make([]EvidenceRiskResponse, 0, len(risks)) + for _, risk := range risks { + if risk.ID == nil { + continue + } + item := EvidenceRiskResponse{ + ID: *risk.ID, + Title: risk.Title, + Description: risk.Description, + Status: risk.Status, + Likelihood: risk.Likelihood, + Impact: risk.Impact, + SSPID: risk.SSPID, + SourceType: risk.SourceType, + } + if risk.SystemSecurityPlan != nil { + item.SSPTitle = risk.SystemSecurityPlan.Metadata.Title + } + output = append(output, item) + } + + return ctx.JSON(http.StatusOK, GenericDataListResponse[EvidenceRiskResponse]{Data: output}) +} + // ForControl godoc // // @Summary List Evidence for a Control diff --git a/internal/api/handler/evidence_test.go b/internal/api/handler/evidence_test.go index 95da6ce5..0e5ed7bb 100644 --- a/internal/api/handler/evidence_test.go +++ b/internal/api/handler/evidence_test.go @@ -25,7 +25,7 @@ func TestEvidenceHandler_Create_WithFutureDate_ReturnsError(t *testing.T) { req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) rec := httptest.NewRecorder() ctx := e.NewContext(req, rec) - h := NewEvidenceHandler(nil, nil) + h := NewEvidenceHandler(nil, nil, nil) // Assertions if assert.NoError(t, h.Create(ctx)) { diff --git a/internal/api/handler/lineage.go b/internal/api/handler/lineage.go index d3744bdd..1c9048af 100644 --- a/internal/api/handler/lineage.go +++ b/internal/api/handler/lineage.go @@ -179,6 +179,12 @@ type LineageNode struct { LastReviewedAt *time.Time `json:"lastReviewedAt,omitempty"` FirstSeenAt *time.Time `json:"firstSeenAt,omitempty"` LastSeenAt *time.Time `json:"lastSeenAt,omitempty"` + // RiskSSPID/RiskSSPTitle identify the single SSP a risk node belongs to + // (risk_register_risks.ssp_id is not-null — every risk has exactly one). + // Populated regardless of scope so the UI can group risk nodes by SSP in + // the unscoped "All SSPs" view. + RiskSSPID string `json:"sspId,omitempty"` + RiskSSPTitle string `json:"sspTitle,omitempty"` // Evidence-node detail. Reason string `json:"reason,omitempty"` @@ -533,6 +539,9 @@ func (h *LineageHandler) buildEngine(sspID, componentID *uuid.UUID) (*lineageEng if err := e.loadImplementationStatuses(h.db, []uuid.UUID{*sspID}); err != nil { return nil, err } + if err := e.loadSSPTitle(h.db, *sspID); err != nil { + return nil, err + } } else { if err := e.loadGlobalSSPScope(h.db); err != nil { return nil, err @@ -886,6 +895,17 @@ func (e *lineageEngine) loadImplementationStatuses(db *gorm.DB, sspIDs []uuid.UU return nil } +// loadSSPTitle resolves the single scoped SSP's title into sspTitles, so risk +// nodes can carry it the same way they do in the global (no sspId) view. +func (e *lineageEngine) loadSSPTitle(db *gorm.DB, sspID uuid.UUID) error { + var ssp relational.SystemSecurityPlan + if err := db.Preload("Metadata").First(&ssp, "id = ?", sspID).Error; err != nil { + return err + } + e.sspTitles[sspID] = ssp.Metadata.Title + return nil +} + // loadGlobalSSPScope loads the full SSP list, each SSP's resolved profile controls // and every SSP's implementation statuses, so control nodes can report a cross-SSP // posture breakdown in the global (no sspId) view. @@ -1550,13 +1570,14 @@ type riskRow struct { LastReviewedAt *time.Time `gorm:"column:last_reviewed_at"` FirstSeenAt *time.Time `gorm:"column:first_seen_at"` LastSeenAt *time.Time `gorm:"column:last_seen_at"` + SSPID uuid.UUID `gorm:"column:ssp_id"` } // riskNodesForControl loads the risks directly linked to a control (same SSP/ // component scoping as loadRisks) as leaf-ish lineage nodes that expand to evidence. func (e *lineageEngine) riskNodesForControl(ref relational.ControlRef) ([]LineageNode, error) { q := e.db.Table("risk_control_links rcl"). - Select("r.id, r.title, r.status, r.likelihood, r.impact, r.review_deadline, r.last_reviewed_at, r.first_seen_at, r.last_seen_at"). + Select("r.id, r.title, r.status, r.likelihood, r.impact, r.review_deadline, r.last_reviewed_at, r.first_seen_at, r.last_seen_at, r.ssp_id"). Joins("JOIN risk_register_risks r ON r.id = rcl.risk_id"). Where("rcl.catalog_id = ? AND rcl.control_id = ?", ref.CatalogID, ref.ControlID). // Closed risks are omitted as nodes (matches loadRisks / the child count). @@ -1590,7 +1611,7 @@ func (e *lineageEngine) riskNodesForControl(ref relational.ControlRef) ([]Lineag nodes := make([]LineageNode, 0, len(order)) for _, id := range order { r := seen[id] - nodes = append(nodes, riskNode(r, counts[id])) + nodes = append(nodes, riskNode(r, counts[id], e.sspTitles[r.SSPID])) } return nodes, nil } @@ -1666,7 +1687,7 @@ func (e *lineageEngine) evidenceNodesForRisk(riskID uuid.UUID) ([]LineageNode, e return nodes, nil } -func riskNode(r riskRow, evidenceCount int) LineageNode { +func riskNode(r riskRow, evidenceCount int, sspTitle string) LineageNode { score, _ := riskrel.NumericalRiskScore(r.Likelihood, r.Impact) s := score linked := evidenceCount @@ -1687,6 +1708,8 @@ func riskNode(r riskRow, evidenceCount int) LineageNode { Risk: bucketRisks([]riskEntry{{riskID: r.ID, status: r.Status, score: score}}), HasChildren: evidenceCount > 0, ChildrenCount: evidenceCount, + RiskSSPID: r.SSPID.String(), + RiskSSPTitle: sspTitle, } if r.Likelihood != nil { node.Likelihood = *r.Likelihood diff --git a/internal/api/handler/lineage_risk_evidence_integration_test.go b/internal/api/handler/lineage_risk_evidence_integration_test.go index bc281e84..7aca7aed 100644 --- a/internal/api/handler/lineage_risk_evidence_integration_test.go +++ b/internal/api/handler/lineage_risk_evidence_integration_test.go @@ -59,13 +59,19 @@ func (suite *LineageRiskEvidenceSuite) TestControlToRiskToEvidence() { } suite.Require().NoError(suite.DB.Create(&catalog).Error) + sspID := uuid.New() + suite.Require().NoError(suite.DB.Create(&relational.SystemSecurityPlan{ + UUIDModel: relational.UUIDModel{ID: &sspID}, + Metadata: relational.Metadata{Title: "Prod SSP", Version: "1.0.0", OscalVersion: "1.1.3", LastModified: &now}, + }).Error) + // A risk (high x high = 16) linked to ac-1. high := "high" risk := riskrel.Risk{ Title: "Test Risk", Description: "d", Status: string(riskrel.RiskStatusOpen), - SSPID: uuid.New(), + SSPID: sspID, Likelihood: &high, Impact: &high, SourceType: string(riskrel.RiskSourceTypeManual), @@ -113,6 +119,8 @@ func (suite *LineageRiskEvidenceSuite) TestControlToRiskToEvidence() { suite.NotNil(rn.FirstSeenAt) suite.True(rn.HasChildren) suite.Equal(1, rn.ChildrenCount, "one linked stream") + suite.Equal(sspID.String(), rn.RiskSSPID, "risk nodes carry their owning SSP id") + suite.Equal("Prod SSP", rn.RiskSSPTitle, "risk nodes carry their owning SSP title in the global view") // risk -> evidence (latest per stream: one node, satisfied) evs := suite.childrenOf(rn.Key) diff --git a/internal/api/handler/oscal/inventory_integration_test.go b/internal/api/handler/oscal/inventory_integration_test.go index 0fdec399..67f3374a 100644 --- a/internal/api/handler/oscal/inventory_integration_test.go +++ b/internal/api/handler/oscal/inventory_integration_test.go @@ -22,6 +22,7 @@ import ( "github.com/compliance-framework/api/internal/api/middleware" "github.com/compliance-framework/api/internal/authz" evidencesvc "github.com/compliance-framework/api/internal/service/relational/evidence" + riskrel "github.com/compliance-framework/api/internal/service/relational/risks" "github.com/compliance-framework/api/internal/tests" oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" ) @@ -44,7 +45,7 @@ func (suite *InventoryApiIntegrationSuite) SetupSuite() { evidenceSvc := evidencesvc.NewEvidenceService(suite.DB, logger, suite.Config, nil) suite.sspHandler = NewSystemSecurityPlanHandler(logger, suite.DB, evidenceSvc, nil) suite.poamHandler = NewPlanOfActionAndMilestonesHandler(logger, suite.DB) - suite.evidenceHandler = handler.NewEvidenceHandler(logger, evidenceSvc) + suite.evidenceHandler = handler.NewEvidenceHandler(logger, evidenceSvc, riskrel.NewRiskService(suite.DB)) // Initialize server metrics := api.NewMetricsHandler(context.Background(), logger) diff --git a/internal/service/relational/risks/queries.go b/internal/service/relational/risks/queries.go index 754194fc..ed4422fe 100644 --- a/internal/service/relational/risks/queries.go +++ b/internal/service/relational/risks/queries.go @@ -96,6 +96,15 @@ func ApplyRiskFilters(query *gorm.DB, filters ListFilters) *gorm.DB { return q } +// ApplyEvidenceStreamFilter narrows a risk query to the risks linked to a single +// evidence stream. risk_evidence_links.evidence_id holds the evidence stream UUID +// (evidences.uuid), not an evidence row ID. +func ApplyEvidenceStreamFilter(query *gorm.DB, evidenceStreamUUID uuid.UUID) *gorm.DB { + return query.Model(&Risk{}). + Joins("JOIN risk_evidence_links rel ON rel.risk_id = risk_register_risks.id"). + Where("rel.evidence_id = ?", evidenceStreamUUID) +} + func ApplyRiskSorting(query *gorm.DB, sortField, sortOrder string) *gorm.DB { column := mapSortField(sortField) order := strings.ToUpper(sortOrder) diff --git a/internal/service/relational/risks/queries_test.go b/internal/service/relational/risks/queries_test.go index 8e621e89..4c63ebda 100644 --- a/internal/service/relational/risks/queries_test.go +++ b/internal/service/relational/risks/queries_test.go @@ -316,6 +316,63 @@ func TestOwnerAssignmentUniqueness(t *testing.T) { require.Error(t, err) } +func TestApplyEvidenceStreamFilter(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&Risk{}, &RiskEvidenceLink{})) + + sspA := uuid.New() + sspB := uuid.New() + streamUUID := uuid.New() + otherStreamUUID := uuid.New() + now := time.Now().UTC() + + newRisk := func(title string, sspID uuid.UUID) Risk { + return Risk{ + Title: title, + Description: title, + Status: string(RiskStatusOpen), + SSPID: sspID, + SourceType: string(RiskSourceTypeEvidenceAuto), + FirstSeenAt: now, + LastSeenAt: now, + } + } + + // Two SSPs share the same evidence stream; a third risk is on another stream. + linkedA := newRisk("linked-in-ssp-a", sspA) + linkedB := newRisk("linked-in-ssp-b", sspB) + unlinked := newRisk("other-stream", sspA) + require.NoError(t, db.Create(&linkedA).Error) + require.NoError(t, db.Create(&linkedB).Error) + require.NoError(t, db.Create(&unlinked).Error) + require.NoError(t, db.Create(&RiskEvidenceLink{RiskID: *linkedA.ID, EvidenceID: streamUUID}).Error) + require.NoError(t, db.Create(&RiskEvidenceLink{RiskID: *linkedB.ID, EvidenceID: streamUUID}).Error) + require.NoError(t, db.Create(&RiskEvidenceLink{RiskID: *unlinked.ID, EvidenceID: otherStreamUUID}).Error) + + t.Run("returns risks across every ssp on the stream", func(t *testing.T) { + var out []Risk + require.NoError(t, ApplyEvidenceStreamFilter(db, streamUUID).Find(&out).Error) + require.Len(t, out, 2) + + ssps := []uuid.UUID{out[0].SSPID, out[1].SSPID} + require.ElementsMatch(t, []uuid.UUID{sspA, sspB}, ssps) + }) + + t.Run("excludes risks linked to other streams", func(t *testing.T) { + var out []Risk + require.NoError(t, ApplyEvidenceStreamFilter(db, otherStreamUUID).Find(&out).Error) + require.Len(t, out, 1) + require.Equal(t, *unlinked.ID, *out[0].ID) + }) + + t.Run("returns nothing for an unknown stream", func(t *testing.T) { + var out []Risk + require.NoError(t, ApplyEvidenceStreamFilter(db, uuid.New()).Find(&out).Error) + require.Empty(t, out) + }) +} + func ptrTime(v time.Time) *time.Time { return &v } type testEvidenceQueryRow struct { diff --git a/internal/service/relational/risks/service.go b/internal/service/relational/risks/service.go index 99bb5ed0..640d0cb6 100644 --- a/internal/service/relational/risks/service.go +++ b/internal/service/relational/risks/service.go @@ -117,6 +117,22 @@ func (s *RiskService) List(params ListParams) ([]Risk, int64, error) { return items, total, nil } +// ListForEvidenceStream returns every risk linked to an evidence stream, across all +// SSPs, newest first. The owning SSP's metadata is preloaded so callers can title +// each risk's plan without a second round trip. +func (s *RiskService) ListForEvidenceStream(evidenceStreamUUID uuid.UUID) ([]Risk, error) { + var items []Risk + query := ApplyEvidenceStreamFilter(s.db, evidenceStreamUUID). + Preload("SystemSecurityPlan.Metadata"). + Order("risk_register_risks.created_at DESC") + + if err := query.Find(&items).Error; err != nil { + return nil, err + } + + return items, nil +} + func (s *RiskService) Create(params CreateRiskParams) (*Risk, error) { risk := params.Risk if err := validateRiskThreatRefs(params.ThreatRefs); err != nil {