From b5b82c0c5cb571325aecb3f1fcd8deede1cfce20 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 9 Mar 2026 06:06:45 -0300 Subject: [PATCH 1/6] fix: risks should operate with labels Signed-off-by: Gustavo Carvalho --- .../service/worker/risk_evidence_worker.go | 23 +++++++++---------- .../worker/risk_evidence_worker_test.go | 18 ++++++--------- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/internal/service/worker/risk_evidence_worker.go b/internal/service/worker/risk_evidence_worker.go index 0cb9ebec..4a630f20 100644 --- a/internal/service/worker/risk_evidence_worker.go +++ b/internal/service/worker/risk_evidence_worker.go @@ -65,7 +65,7 @@ func (w *RiskEvidenceWorker) Work(ctx context.Context, job *river.Job[RiskProces } // 3. Violation Filtering: Filter the risk templates by checking the fired violation.id against risk_template.violation_ids - filteredRiskTemplates, err := w.filterRiskTemplatesByViolations(ctx, riskTemplates, []relational.Prop(evidence.Props)) + filteredRiskTemplates, err := w.filterRiskTemplatesByViolations(ctx, riskTemplates, evidence.Labels) if err != nil { w.logger.Errorw("Failed to filter risk templates by violations", "error", err, "evidence_id", args.EvidenceID) return err @@ -166,10 +166,10 @@ func (w *RiskEvidenceWorker) loadRiskTemplates(ctx context.Context, evidenceLabe return riskTemplates, nil } -// filterRiskTemplatesByViolations filters risk templates based on violation IDs in evidence props -func (w *RiskEvidenceWorker) filterRiskTemplatesByViolations(ctx context.Context, riskTemplates []templates.RiskTemplate, evidenceProps []relational.Prop) ([]templates.RiskTemplate, error) { - // Extract violation IDs from evidence props - violationIDs := w.extractViolationIDs(evidenceProps) +// filterRiskTemplatesByViolations filters risk templates based on violation IDs in evidence labels +func (w *RiskEvidenceWorker) filterRiskTemplatesByViolations(ctx context.Context, riskTemplates []templates.RiskTemplate, evidenceLabels []relational.Labels) ([]templates.RiskTemplate, error) { + // Extract violation IDs from evidence labels + violationIDs := w.extractViolationIDs(evidenceLabels) var filteredTemplates []templates.RiskTemplate @@ -189,14 +189,14 @@ func (w *RiskEvidenceWorker) filterRiskTemplatesByViolations(ctx context.Context return filteredTemplates, nil } -// extractViolationIDs extracts violation IDs from evidence props -func (w *RiskEvidenceWorker) extractViolationIDs(props []relational.Prop) []string { +// extractViolationIDs extracts violation IDs from evidence labels +func (w *RiskEvidenceWorker) extractViolationIDs(labels []relational.Labels) []string { var violationIDs []string - for _, prop := range props { - // Look for props with name exactly "violation_id" - if prop.Name == "violation_id" && prop.Value != "" { - violationIDs = append(violationIDs, prop.Value) + for _, label := range labels { + // Look for labels with name exactly "violation_id" + if label.Name == "_violation_id" && label.Value != "" { + violationIDs = append(violationIDs, label.Value) } } @@ -230,7 +230,6 @@ func (w *RiskEvidenceWorker) violationMatches(templateViolationIDs, evidenceViol // createOrUpdateRisksForSSPs creates or updates risks for each SSP associated with the evidence func (w *RiskEvidenceWorker) createOrUpdateRisksForSSPs(ctx context.Context, riskTemplate templates.RiskTemplate, evidence *relational.Evidence) error { - // TODO: we are using Evidence.Components as a proxy for now, but in reality this should use SubjectTemplates to find the appropriate SystemComponents -> SSPIds // Get unique SSP IDs from evidence components sspIDs, err := w.extractSSPIDsFromComponents(ctx, evidence.Components) diff --git a/internal/service/worker/risk_evidence_worker_test.go b/internal/service/worker/risk_evidence_worker_test.go index 9e57d3ec..2ed38a25 100644 --- a/internal/service/worker/risk_evidence_worker_test.go +++ b/internal/service/worker/risk_evidence_worker_test.go @@ -14,7 +14,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" - "gorm.io/datatypes" "gorm.io/driver/sqlite" "gorm.io/gorm" ) @@ -72,8 +71,6 @@ func createTestEvidence(t *testing.T, db *gorm.DB) *relational.Evidence { {Name: "environment", Value: "production"}, {Name: "category", Value: "security"}, {Name: "_policy", Value: "test-policy"}, - }, - Props: datatypes.JSONSlice[relational.Prop]{ {Name: "violation_id", Value: "VIOL-001"}, }, } @@ -349,8 +346,7 @@ func TestRiskEvidenceWorker_loadEvidenceWithRelations(t *testing.T) { assert.NotNil(t, loaded) assert.Equal(t, evidence.ID, loaded.ID) assert.Equal(t, evidence.UUID, loaded.UUID) - assert.Len(t, loaded.Labels, 3) // environment, category, _policy - assert.Len(t, loaded.Props, 1) + assert.Len(t, loaded.Labels, 4) // environment, category, _policy, violation_id } func TestRiskEvidenceWorker_loadEvidenceWithRelations_NotFound(t *testing.T) { @@ -715,13 +711,13 @@ func TestRiskEvidenceWorker_filterRiskTemplatesByViolations(t *testing.T) { riskTemplates := []templates.RiskTemplate{*template1, *template2, *template3} - // Create evidence props with violation ID - evidenceProps := []relational.Prop{ + // Create evidence labels with violation ID + evidenceLabels := []relational.Labels{ {Name: "violation_id", Value: "VIOL-001"}, } // Filter templates - filtered, err := worker.filterRiskTemplatesByViolations(ctx, riskTemplates, evidenceProps) + filtered, err := worker.filterRiskTemplatesByViolations(ctx, riskTemplates, evidenceLabels) assert.NoError(t, err) assert.Len(t, filtered, 2) // template1 and template3 should match @@ -732,13 +728,13 @@ func TestRiskEvidenceWorker_extractViolationIDs(t *testing.T) { worker := createTestRiskEvidenceWorker(t) - props := []relational.Prop{ + labels := []relational.Labels{ {Name: "violation_id", Value: "VIOL-001"}, - {Name: "other_prop", Value: "value"}, + {Name: "other_label", Value: "value"}, {Name: "violation_id", Value: "VIOL-002"}, } - violationIDs := worker.extractViolationIDs(props) + violationIDs := worker.extractViolationIDs(labels) assert.Len(t, violationIDs, 2) assert.Contains(t, violationIDs, "VIOL-001") From 014045009c9ba28a270b83ea3a1efe0cf46d5543 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 9 Mar 2026 14:58:45 -0300 Subject: [PATCH 2/6] fix: several fixes Signed-off-by: Gustavo Carvalho --- docs/docs.go | 1 + docs/swagger.json | 1 + docs/swagger.yaml | 2 + internal/api/handler/api.go | 4 + ...urity_plan_suggestions_integration_test.go | 48 ++++++++ .../handler/oscal/system_security_plans.go | 82 ++++++++++++++ internal/api/handler/risks.go | 107 ++++++++++++++++++ .../api/handler/risks_integration_test.go | 57 +++++++++- internal/service/relational/risks/links.go | 3 +- .../service/relational/risks/queries_test.go | 10 +- internal/service/relational/risks/service.go | 46 ++++++-- .../service/relational/risks/service_test.go | 11 +- .../system_component_suggestions.go | 65 ++++++++++- .../system_component_suggestions_test.go | 65 +++++++++++ .../templates/subject_template_service.go | 30 +++-- .../subject_template_service_test.go | 58 ++++++++++ .../service/worker/risk_evidence_worker.go | 16 +-- .../worker/risk_evidence_worker_test.go | 6 +- 18 files changed, 578 insertions(+), 34 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 8c3b170c..7352d9e6 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -31595,6 +31595,7 @@ const docTemplate = `{ "type": "string" }, "evidenceId": { + "description": "EvidenceID stores the evidence stream UUID (evidences.uuid), not a single evidence row ID.", "type": "string" }, "riskId": { diff --git a/docs/swagger.json b/docs/swagger.json index b278c91c..30c97b35 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -31589,6 +31589,7 @@ "type": "string" }, "evidenceId": { + "description": "EvidenceID stores the evidence stream UUID (evidences.uuid), not a single evidence row ID.", "type": "string" }, "riskId": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index ff9e2c56..5c1f54a1 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -6668,6 +6668,8 @@ definitions: createdById: type: string evidenceId: + description: EvidenceID stores the evidence stream UUID (evidences.uuid), + not a single evidence row ID. type: string riskId: type: string diff --git a/internal/api/handler/api.go b/internal/api/handler/api.go index 9e9cfb02..bf2bf407 100644 --- a/internal/api/handler/api.go +++ b/internal/api/handler/api.go @@ -53,6 +53,10 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB riskGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) riskHandler.Register(riskGroup) + sspRiskGroup := server.API().Group("/ssp/:sspId/risks") + sspRiskGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) + riskHandler.RegisterSSPScoped(sspRiskGroup) + riskTemplateHandler := templatehandlers.NewRiskTemplateHandler(logger, db) riskTemplateGroup := server.API().Group("/risk-templates") riskTemplateGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) diff --git a/internal/api/handler/oscal/system_security_plan_suggestions_integration_test.go b/internal/api/handler/oscal/system_security_plan_suggestions_integration_test.go index de00f4fb..6504c0f3 100644 --- a/internal/api/handler/oscal/system_security_plan_suggestions_integration_test.go +++ b/internal/api/handler/oscal/system_security_plan_suggestions_integration_test.go @@ -207,6 +207,18 @@ func (suite *SystemComponentSuggestionsIntegrationSuite) buildFilterAndEvidence( return dc.ID.String() } +func (suite *SystemComponentSuggestionsIntegrationSuite) addStatement(implReqID string) string { + stmtUUID := uuid.New() + reqUUID := uuid.MustParse(implReqID) + stmt := relational.Statement{ + UUIDModel: relational.UUIDModel{ID: &stmtUUID}, + StatementId: "statement-" + stmtUUID.String(), + ImplementedRequirementId: reqUUID, + } + suite.Require().NoError(suite.DB.Create(&stmt).Error) + return stmtUUID.String() +} + // --------------------------------------------------------------------------- // Tests: Create/Update SystemComponent with definedComponentId // --------------------------------------------------------------------------- @@ -398,6 +410,24 @@ func (suite *SystemComponentSuggestionsIntegrationSuite) TestSuggestComponents_A suite.Empty(resp.Data, "component %s should no longer appear as suggestion after being applied", dcUUID) } +func (suite *SystemComponentSuggestionsIntegrationSuite) TestSuggestComponentsForStatement_ReturnsMatchingDefinedComponent() { + const controlID = "ac-5" + sspID, implReqID := suite.buildSSP(controlID) + stmtID := suite.addStatement(implReqID) + suite.buildFilterAndEvidence(controlID) + + rec, req := suite.req(http.MethodPost, + fmt.Sprintf("/api/oscal/system-security-plans/%s/control-implementation/implemented-requirements/%s/statements/%s/suggest-components", sspID, implReqID, stmtID), + nil) + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusOK, rec.Code, rec.Body.String()) + + var resp handler.GenericDataListResponse[relational.SystemComponentSuggestion] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp)) + suite.Require().Len(resp.Data, 1) + suite.Equal("Suggested Component", resp.Data[0].Name) +} + func (suite *SystemComponentSuggestionsIntegrationSuite) TestSuggestComponents_InvalidSSPID() { rec, req := suite.req(http.MethodPost, fmt.Sprintf("/api/oscal/system-security-plans/not-a-uuid/control-implementation/implemented-requirements/%s/suggest-components", uuid.New()), @@ -497,6 +527,24 @@ func (suite *SystemComponentSuggestionsIntegrationSuite) TestApplySuggestion_NoS suite.Equal(int64(0), count) } +func (suite *SystemComponentSuggestionsIntegrationSuite) TestApplySuggestionForStatement_CreatesByComponentOnStatement() { + const controlID = "sc-9" + sspID, implReqID := suite.buildSSP(controlID) + stmtID := suite.addStatement(implReqID) + suite.buildFilterAndEvidence(controlID) + + rec, req := suite.req(http.MethodPost, + fmt.Sprintf("/api/oscal/system-security-plans/%s/control-implementation/implemented-requirements/%s/statements/%s/apply-suggestion", sspID, implReqID, stmtID), + nil) + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusNoContent, rec.Code, rec.Body.String()) + + stmtUUID := uuid.MustParse(stmtID) + parentType := "statements" + var byComponent relational.ByComponent + suite.Require().NoError(suite.DB.Where("parent_id = ? AND parent_type = ?", stmtUUID, parentType).First(&byComponent).Error) +} + func (suite *SystemComponentSuggestionsIntegrationSuite) TestApplySuggestion_InvalidSSPID() { rec, req := suite.req(http.MethodPost, fmt.Sprintf("/api/oscal/system-security-plans/not-a-uuid/control-implementation/implemented-requirements/%s/apply-suggestion", uuid.New()), diff --git a/internal/api/handler/oscal/system_security_plans.go b/internal/api/handler/oscal/system_security_plans.go index a77be4f1..e8803634 100644 --- a/internal/api/handler/oscal/system_security_plans.go +++ b/internal/api/handler/oscal/system_security_plans.go @@ -219,6 +219,8 @@ func (h *SystemSecurityPlanHandler) Register(api *echo.Group) { api.DELETE("/:id/control-implementation/implemented-requirements/:reqId", h.DeleteImplementedRequirement) api.POST("/:id/control-implementation/implemented-requirements/:reqId/suggest-components", h.SuggestComponents) api.POST("/:id/control-implementation/implemented-requirements/:reqId/apply-suggestion", h.ApplySuggestion) + api.POST("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/suggest-components", h.SuggestComponentsForStatement) + api.POST("/:id/control-implementation/implemented-requirements/:reqId/statements/:stmtId/apply-suggestion", h.ApplySuggestionForStatement) api.POST("/:id/bulk-apply-component-suggestions", h.BulkApplyComponentSuggestions) api.GET("/:id/back-matter", h.GetBackMatter) api.PUT("/:id/back-matter", h.UpdateBackMatter) @@ -4067,6 +4069,39 @@ func (h *SystemSecurityPlanHandler) SuggestComponents(ctx echo.Context) error { return ctx.JSON(http.StatusOK, handler.GenericDataListResponse[relational.SystemComponentSuggestion]{Data: suggestions}) } +// SuggestComponentsForStatement godoc +// +// @Summary Suggest system components for a statement +// @Description Returns DefinedComponents that implement the statement's parent control and are not yet present in the SSP. +// @Tags System Security Plans +// @Produce json +// @Param id path string true "SSP ID" +// @Param reqId path string true "Implemented Requirement ID" +// @Param stmtId path string true "Statement ID" +// @Success 200 {object} handler.GenericDataListResponse[relational.SystemComponentSuggestion] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +func (h *SystemSecurityPlanHandler) SuggestComponentsForStatement(ctx echo.Context) error { + sspID, reqID, stmtID, err := parseSSPReqStmtIDs(ctx) + if err != nil { + h.sugar.Warnw("Invalid statement suggestion path params", "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + suggestions, err := h.suggestionService.SuggestForStatement(sspID, reqID, stmtID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(err)) + } + h.sugar.Errorw("failed to get statement component suggestions", "sspID", sspID, "reqID", reqID, "stmtID", stmtID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.JSON(http.StatusOK, handler.GenericDataListResponse[relational.SystemComponentSuggestion]{Data: suggestions}) +} + // ApplySuggestion godoc // // @Summary Apply component suggestions for an implemented requirement @@ -4106,6 +4141,37 @@ func (h *SystemSecurityPlanHandler) ApplySuggestion(ctx echo.Context) error { return ctx.NoContent(http.StatusNoContent) } +// ApplySuggestionForStatement godoc +// +// @Summary Apply component suggestions for a statement +// @Description Creates SystemComponents from DefinedComponents that implement the statement's parent control and links them via ByComponent to the statement. +// @Tags System Security Plans +// @Param id path string true "SSP ID" +// @Param reqId path string true "Implemented Requirement ID" +// @Param stmtId path string true "Statement ID" +// @Success 204 "No Content" +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +func (h *SystemSecurityPlanHandler) ApplySuggestionForStatement(ctx echo.Context) error { + sspID, reqID, stmtID, err := parseSSPReqStmtIDs(ctx) + if err != nil { + h.sugar.Warnw("Invalid statement apply path params", "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + if err := h.suggestionService.ApplyForStatement(sspID, reqID, stmtID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(err)) + } + h.sugar.Errorw("failed to apply statement component suggestions", "sspID", sspID, "reqID", reqID, "stmtID", stmtID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.NoContent(http.StatusNoContent) +} + // BulkApplyComponentSuggestions godoc // // @Summary Bulk apply component suggestions for all implemented requirements in an SSP @@ -4136,3 +4202,19 @@ func (h *SystemSecurityPlanHandler) BulkApplyComponentSuggestions(ctx echo.Conte return ctx.NoContent(http.StatusNoContent) } + +func parseSSPReqStmtIDs(ctx echo.Context) (uuid.UUID, uuid.UUID, uuid.UUID, error) { + sspID, err := uuid.Parse(ctx.Param("id")) + if err != nil { + return uuid.Nil, uuid.Nil, uuid.Nil, err + } + reqID, err := uuid.Parse(ctx.Param("reqId")) + if err != nil { + return uuid.Nil, uuid.Nil, uuid.Nil, err + } + stmtID, err := uuid.Parse(ctx.Param("stmtId")) + if err != nil { + return uuid.Nil, uuid.Nil, uuid.Nil, err + } + return sspID, reqID, stmtID, nil +} diff --git a/internal/api/handler/risks.go b/internal/api/handler/risks.go index 08007276..0c48786b 100644 --- a/internal/api/handler/risks.go +++ b/internal/api/handler/risks.go @@ -59,6 +59,14 @@ func (h *RiskHandler) Register(api *echo.Group) { api.POST("/:id/subjects", h.AddSubjectLink) } +func (h *RiskHandler) RegisterSSPScoped(api *echo.Group) { + api.GET("", h.ListForSSP) + api.POST("", h.CreateForSSP) + api.GET("/:id", h.GetForSSP) + api.PUT("/:id", h.UpdateForSSP) + api.DELETE("/:id", h.DeleteForSSP) +} + type riskOwnerAssignmentRequest struct { OwnerKind string `json:"ownerKind"` OwnerRef string `json:"ownerRef"` @@ -236,6 +244,10 @@ func (h *RiskHandler) Create(ctx echo.Context) error { if err := ctx.Bind(&req); err != nil { return ctx.JSON(http.StatusBadRequest, api.NewError(err)) } + return h.createFromRequest(ctx, req) +} + +func (h *RiskHandler) createFromRequest(ctx echo.Context, req createRiskRequest) error { if req.Title == "" || req.Description == "" { return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("title and description are required"))) } @@ -324,6 +336,101 @@ func (h *RiskHandler) Create(ctx echo.Context) error { }) } +func (h *RiskHandler) ListForSSP(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) + } + + ctx.QueryParams().Set("sspId", sspID.String()) + return h.List(ctx) +} + +func (h *RiskHandler) CreateForSSP(ctx echo.Context) error { + sspID, err := parsePathUUID(ctx, "sspId") + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + var req createRiskRequest + if err := ctx.Bind(&req); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + req.SSPID = sspID + return h.createFromRequest(ctx, req) +} + +func (h *RiskHandler) GetForSSP(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.Get(ctx) +} + +func (h *RiskHandler) UpdateForSSP(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.Update(ctx) +} + +func (h *RiskHandler) DeleteForSSP(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.Delete(ctx) +} + +func (h *RiskHandler) ensureRiskBelongsToSSP(riskID, sspID uuid.UUID) error { + risk, err := h.riskService.GetByID(riskID) + if err != nil { + return err + } + if risk.SSPID != sspID { + return gorm.ErrRecordNotFound + } + return nil +} + // Get godoc // // @Summary Get risk diff --git a/internal/api/handler/risks_integration_test.go b/internal/api/handler/risks_integration_test.go index 3f0e86e7..b5f23842 100644 --- a/internal/api/handler/risks_integration_test.go +++ b/internal/api/handler/risks_integration_test.go @@ -234,6 +234,61 @@ func (suite *RiskApiIntegrationSuite) TestRiskStatusTransitions() { require.Equal(suite.T(), "Quarterly governance review", *reviews[len(reviews)-1].ReviewJustification) } +func (suite *RiskApiIntegrationSuite) TestSSPScopedRiskCRUD() { + sspID := suite.newSSPID() + otherSSPID := suite.newSSPID() + + createReq := map[string]any{ + "title": "Scoped risk", + "description": "created from scoped endpoint", + } + createRec, createHTTPReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/ssp/%s/risks", sspID), createReq) + suite.server.E().ServeHTTP(createRec, createHTTPReq) + require.Equal(suite.T(), http.StatusCreated, createRec.Code) + + var created GenericDataResponse[riskResponse] + require.NoError(suite.T(), json.Unmarshal(createRec.Body.Bytes(), &created)) + require.Equal(suite.T(), sspID, created.Data.SSPID.String()) + + listRec, listHTTPReq := suite.authedRequest(http.MethodGet, fmt.Sprintf("/api/ssp/%s/risks?page=1&limit=20", sspID), nil) + suite.server.E().ServeHTTP(listRec, listHTTPReq) + require.Equal(suite.T(), http.StatusOK, listRec.Code) + var listResp struct { + Data []riskResponse `json:"data"` + } + require.NoError(suite.T(), json.Unmarshal(listRec.Body.Bytes(), &listResp)) + require.Len(suite.T(), listResp.Data, 1) + require.Equal(suite.T(), created.Data.ID, listResp.Data[0].ID) + + getRec, getReq := suite.authedRequest(http.MethodGet, fmt.Sprintf("/api/ssp/%s/risks/%s", sspID, created.Data.ID), nil) + suite.server.E().ServeHTTP(getRec, getReq) + require.Equal(suite.T(), http.StatusOK, getRec.Code) + + getOtherRec, getOtherReq := suite.authedRequest(http.MethodGet, fmt.Sprintf("/api/ssp/%s/risks/%s", otherSSPID, created.Data.ID), nil) + suite.server.E().ServeHTTP(getOtherRec, getOtherReq) + require.Equal(suite.T(), http.StatusNotFound, getOtherRec.Code) + + updateScopedRec, updateScopedReq := suite.authedRequest(http.MethodPut, fmt.Sprintf("/api/ssp/%s/risks/%s", sspID, created.Data.ID), map[string]any{ + "title": "Scoped risk updated", + }) + suite.server.E().ServeHTTP(updateScopedRec, updateScopedReq) + require.Equal(suite.T(), http.StatusOK, updateScopedRec.Code) + + updateOtherRec, updateOtherReq := suite.authedRequest(http.MethodPut, fmt.Sprintf("/api/ssp/%s/risks/%s", otherSSPID, created.Data.ID), map[string]any{ + "title": "should fail", + }) + suite.server.E().ServeHTTP(updateOtherRec, updateOtherReq) + require.Equal(suite.T(), http.StatusNotFound, updateOtherRec.Code) + + deleteOtherRec, deleteOtherReq := suite.authedRequest(http.MethodDelete, fmt.Sprintf("/api/ssp/%s/risks/%s", otherSSPID, created.Data.ID), nil) + suite.server.E().ServeHTTP(deleteOtherRec, deleteOtherReq) + require.Equal(suite.T(), http.StatusNotFound, deleteOtherRec.Code) + + deleteRec, deleteReq := suite.authedRequest(http.MethodDelete, fmt.Sprintf("/api/ssp/%s/risks/%s", sspID, created.Data.ID), nil) + suite.server.E().ServeHTTP(deleteRec, deleteReq) + require.Equal(suite.T(), http.StatusNoContent, deleteRec.Code) +} + func (suite *RiskApiIntegrationSuite) TestEvidenceLinksAreIdempotent() { evidence := relational.Evidence{ UUID: uuid.New(), @@ -398,7 +453,7 @@ func (suite *RiskApiIntegrationSuite) TestRiskControlComponentSubjectEndpointsAn require.NoError(suite.T(), json.Unmarshal(filterByControlRec.Body.Bytes(), &controlFiltered)) require.NotEmpty(suite.T(), controlFiltered.Data) - filterByEvidenceRec, filterByEvidenceReq := suite.authedRequest(http.MethodGet, fmt.Sprintf("/api/risks?evidenceId=%s&page=1&limit=10", evidence.ID), nil) + filterByEvidenceRec, filterByEvidenceReq := suite.authedRequest(http.MethodGet, fmt.Sprintf("/api/risks?evidenceId=%s&page=1&limit=10", evidence.UUID), nil) suite.server.E().ServeHTTP(filterByEvidenceRec, filterByEvidenceReq) require.Equal(suite.T(), http.StatusOK, filterByEvidenceRec.Code) diff --git a/internal/service/relational/risks/links.go b/internal/service/relational/risks/links.go index fd7a8108..2da82677 100644 --- a/internal/service/relational/risks/links.go +++ b/internal/service/relational/risks/links.go @@ -7,7 +7,8 @@ import ( ) type RiskEvidenceLink struct { - RiskID uuid.UUID `json:"riskId" gorm:"type:uuid;primaryKey"` + RiskID uuid.UUID `json:"riskId" gorm:"type:uuid;primaryKey"` + // EvidenceID stores the evidence stream UUID (evidences.uuid), not a single evidence row ID. EvidenceID uuid.UUID `json:"evidenceId" gorm:"type:uuid;primaryKey;index"` CreatedAt time.Time `json:"createdAt"` CreatedByID *uuid.UUID `json:"createdById" gorm:"type:uuid;index"` diff --git a/internal/service/relational/risks/queries_test.go b/internal/service/relational/risks/queries_test.go index 3bc1506f..bef3802d 100644 --- a/internal/service/relational/risks/queries_test.go +++ b/internal/service/relational/risks/queries_test.go @@ -119,7 +119,7 @@ func TestFromOSCALDescriptionFallbackAndProps(t *testing.T) { func TestApplyRiskFilters(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&Risk{}, &RiskOwnerAssignment{}, &RiskControlLink{}, &RiskEvidenceLink{})) + require.NoError(t, db.AutoMigrate(&Risk{}, &RiskOwnerAssignment{}, &RiskControlLink{}, &RiskEvidenceLink{}, &testEvidenceQueryRow{})) require.NoError(t, EnsureIndexes(db)) sspA := uuid.New() @@ -300,3 +300,11 @@ func TestOwnerAssignmentUniqueness(t *testing.T) { } func ptrTime(v time.Time) *time.Time { return &v } + +type testEvidenceQueryRow struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + UUID uuid.UUID `gorm:"type:uuid;index"` + End time.Time +} + +func (testEvidenceQueryRow) TableName() string { return "evidences" } diff --git a/internal/service/relational/risks/service.go b/internal/service/relational/risks/service.go index 4ad384cc..12965b44 100644 --- a/internal/service/relational/risks/service.go +++ b/internal/service/relational/risks/service.go @@ -2,6 +2,7 @@ package risks import ( "encoding/json" + "errors" "fmt" "time" @@ -300,13 +301,13 @@ func (s *RiskService) AddEvidenceLink(riskID, evidenceID uuid.UUID, actorUserID return nil, err } - var evidence relational.Evidence - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&evidence, "id = ?", evidenceID).Error; err != nil { + evidenceStreamID, err := s.resolveEvidenceStreamID(tx, evidenceID) + if err != nil { tx.Rollback() return nil, err } - link := RiskEvidenceLink{RiskID: riskID, EvidenceID: evidenceID, CreatedByID: actorUserID} + link := RiskEvidenceLink{RiskID: riskID, EvidenceID: evidenceStreamID, CreatedByID: actorUserID} createResult := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&link) if createResult.Error != nil { tx.Rollback() @@ -314,12 +315,12 @@ func (s *RiskService) AddEvidenceLink(riskID, evidenceID uuid.UUID, actorUserID } if createResult.RowsAffected > 0 { - if err := s.logRiskEvent(tx, riskID, RiskEventTypeEvidenceLink, actorUserID, datatypes.JSONMap{"evidenceId": evidenceID.String()}); err != nil { + if err := s.logRiskEvent(tx, riskID, RiskEventTypeEvidenceLink, actorUserID, datatypes.JSONMap{"evidenceId": evidenceStreamID.String()}); err != nil { tx.Rollback() return nil, err } } else { - if err := tx.Where("risk_id = ? AND evidence_id = ?", riskID, evidenceID).First(&link).Error; err != nil { + if err := tx.Where("risk_id = ? AND evidence_id = ?", riskID, evidenceStreamID).First(&link).Error; err != nil { tx.Rollback() return nil, err } @@ -339,7 +340,16 @@ func (s *RiskService) DeleteEvidenceLink(riskID, evidenceID uuid.UUID, actorUser } defer rollbackTxOnPanic(tx) - result := tx.Delete(&RiskEvidenceLink{}, "risk_id = ? AND evidence_id = ?", riskID, evidenceID) + evidenceStreamID := evidenceID + resolvedStreamID, resolveErr := s.resolveEvidenceStreamID(tx, evidenceID) + if resolveErr == nil { + evidenceStreamID = resolvedStreamID + } else if !errors.Is(resolveErr, gorm.ErrRecordNotFound) { + tx.Rollback() + return false, resolveErr + } + + result := tx.Delete(&RiskEvidenceLink{}, "risk_id = ? AND evidence_id = ?", riskID, evidenceStreamID) if result.Error != nil { tx.Rollback() return false, result.Error @@ -348,7 +358,7 @@ func (s *RiskService) DeleteEvidenceLink(riskID, evidenceID uuid.UUID, actorUser tx.Rollback() return false, nil } - if err := s.logRiskEvent(tx, riskID, RiskEventTypeEvidenceUnlink, actorUserID, datatypes.JSONMap{"evidenceId": evidenceID.String()}); err != nil { + if err := s.logRiskEvent(tx, riskID, RiskEventTypeEvidenceUnlink, actorUserID, datatypes.JSONMap{"evidenceId": evidenceStreamID.String()}); err != nil { tx.Rollback() return false, err } @@ -358,6 +368,28 @@ func (s *RiskService) DeleteEvidenceLink(riskID, evidenceID uuid.UUID, actorUser return true, nil } +func (s *RiskService) resolveEvidenceStreamID(tx *gorm.DB, evidenceRef uuid.UUID) (uuid.UUID, error) { + var evidence relational.Evidence + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Select("id", "uuid"). + Where("id = ?", evidenceRef). + First(&evidence).Error; err == nil { + return evidence.UUID, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return uuid.Nil, err + } + + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Select("id", "uuid"). + Where("uuid = ?", evidenceRef). + Order(`"evidences"."end" DESC`). + First(&evidence).Error; err != nil { + return uuid.Nil, err + } + + return evidence.UUID, nil +} + func (s *RiskService) ListControlLinks(riskID uuid.UUID, limit, offset int) ([]RiskControlLink, int64, error) { q := s.db.Model(&RiskControlLink{}).Where("risk_id = ?", riskID) diff --git a/internal/service/relational/risks/service_test.go b/internal/service/relational/risks/service_test.go index d00e19dc..fe7f7678 100644 --- a/internal/service/relational/risks/service_test.go +++ b/internal/service/relational/risks/service_test.go @@ -134,10 +134,11 @@ func TestRiskServiceLinksAndAssociations(t *testing.T) { }).Error) evidenceID := uuid.New() + evidenceStreamID := uuid.New() catalogID := uuid.New() componentID := uuid.New() subjectID := uuid.New() - require.NoError(t, db.Create(&testEvidenceRow{ID: evidenceID}).Error) + require.NoError(t, db.Create(&testEvidenceRow{ID: evidenceID, UUID: evidenceStreamID, End: time.Now().UTC()}).Error) require.NoError(t, db.Create(&testControlRow{CatalogID: catalogID, ID: "AC-2"}).Error) require.NoError(t, db.Create(&testSystemComponentRow{ID: componentID}).Error) require.NoError(t, db.Create(&testAssessmentSubjectRow{ID: subjectID}).Error) @@ -155,7 +156,7 @@ func TestRiskServiceLinksAndAssociations(t *testing.T) { require.NoError(t, err) require.Equal(t, int64(1), evidenceTotal) require.Len(t, evidenceIDs, 1) - require.Equal(t, evidenceID, evidenceIDs[0]) + require.Equal(t, evidenceStreamID, evidenceIDs[0]) deleted, err := svc.DeleteEvidenceLink(riskID, evidenceID, &actorID) require.NoError(t, err) @@ -210,7 +211,7 @@ func TestRiskServiceLinksAndAssociations(t *testing.T) { associations, err := svc.GetAssociations(riskID) require.NoError(t, err) - require.Contains(t, associations.EvidenceIDs, evidenceID) + require.Contains(t, associations.EvidenceIDs, evidenceStreamID) require.Contains(t, associations.ComponentIDs, componentID) require.Contains(t, associations.SubjectIDs, subjectID) require.Len(t, associations.ControlLinks, 1) @@ -489,7 +490,9 @@ type testUserRow struct { func (testUserRow) TableName() string { return "ccf_users" } type testEvidenceRow struct { - ID uuid.UUID `gorm:"type:uuid;primaryKey"` + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + UUID uuid.UUID `gorm:"type:uuid;index"` + End time.Time } func (testEvidenceRow) TableName() string { return "evidences" } diff --git a/internal/service/relational/system_component_suggestions.go b/internal/service/relational/system_component_suggestions.go index 72016733..fcdb3606 100644 --- a/internal/service/relational/system_component_suggestions.go +++ b/internal/service/relational/system_component_suggestions.go @@ -169,11 +169,46 @@ func (s *SystemComponentSuggestionService) SuggestForImplementedRequirement( return suggestions, nil } +// SuggestForStatement returns the same candidate components as the parent ImplementedRequirement, +// after validating that the statement belongs to the given requirement and SSP. +func (s *SystemComponentSuggestionService) SuggestForStatement( + sspID uuid.UUID, + implReqID uuid.UUID, + stmtID uuid.UUID, +) ([]SystemComponentSuggestion, error) { + if err := s.validateStatementForImplementedRequirement(sspID, implReqID, stmtID); err != nil { + return nil, err + } + return s.SuggestForImplementedRequirement(sspID, implReqID) +} + // ApplyForImplementedRequirement creates missing SystemComponents for all suggestions related to the given // ImplementedRequirement and links each one via a ByComponent entry. Idempotent: re-running is safe. func (s *SystemComponentSuggestionService) ApplyForImplementedRequirement( sspID uuid.UUID, implReqID uuid.UUID, +) error { + return s.applyForParent(sspID, implReqID, implReqID, "implemented_requirements") +} + +// ApplyForStatement creates missing SystemComponents for all suggestions related to the +// parent ImplementedRequirement and links each one to the statement via ByComponent. +func (s *SystemComponentSuggestionService) ApplyForStatement( + sspID uuid.UUID, + implReqID uuid.UUID, + stmtID uuid.UUID, +) error { + if err := s.validateStatementForImplementedRequirement(sspID, implReqID, stmtID); err != nil { + return err + } + return s.applyForParent(sspID, implReqID, stmtID, "statements") +} + +func (s *SystemComponentSuggestionService) applyForParent( + sspID uuid.UUID, + implReqID uuid.UUID, + parentID uuid.UUID, + parentType string, ) error { // 1. Get the SystemImplementation for this SSP var systemImpl SystemImplementation @@ -221,8 +256,6 @@ func (s *SystemComponentSuggestionService) ApplyForImplementedRequirement( } // Create a ByComponent linking the SystemComponent to the ImplementedRequirement - parentID := implReqID - parentType := "implemented_requirements" implStatus := ImplementationStatus{State: "implemented"} // Generate deterministic UUID from the unique key (component_uuid, parent_id, parent_type) // This ensures concurrent requests generate the same UUID, making the operation idempotent @@ -251,6 +284,34 @@ func (s *SystemComponentSuggestionService) ApplyForImplementedRequirement( }) } +func (s *SystemComponentSuggestionService) validateStatementForImplementedRequirement( + sspID uuid.UUID, + implReqID uuid.UUID, + stmtID uuid.UUID, +) error { + var statement Statement + if err := s.db. + Table("statements"). + Joins("JOIN implemented_requirements ON implemented_requirements.id = statements.implemented_requirement_id"). + Joins("JOIN control_implementations ON control_implementations.id = implemented_requirements.control_implementation_id"). + Where( + "statements.id = ? AND implemented_requirements.id = ? AND control_implementations.system_security_plan_id = ?", + stmtID, + implReqID, + sspID, + ). + First(&statement).Error; err != nil { + return fmt.Errorf( + "statement %s not found for implemented requirement %s in SSP %s: %w", + stmtID, + implReqID, + sspID, + err, + ) + } + return nil +} + // ApplyForSSP iterates all ImplementedRequirements for the SSP and applies component suggestions for each. func (s *SystemComponentSuggestionService) ApplyForSSP(sspID uuid.UUID) error { var controlImpl ControlImplementation diff --git a/internal/service/relational/system_component_suggestions_test.go b/internal/service/relational/system_component_suggestions_test.go index 30bd136b..73928f20 100644 --- a/internal/service/relational/system_component_suggestions_test.go +++ b/internal/service/relational/system_component_suggestions_test.go @@ -51,6 +51,7 @@ func setupTestDB(t *testing.T) *gorm.DB { &DefinedComponent{}, &ControlImplementation{}, &ImplementedRequirement{}, + &Statement{}, &SystemImplementation{}, &SystemSecurityPlan{}, &SystemComponent{}, @@ -224,6 +225,16 @@ func seedSSPWithImplReq(t *testing.T, db *gorm.DB, controlID string) (sspID, imp return *ssp.ID, *ir.ID } +func seedStatementForImplReq(t *testing.T, db *gorm.DB, implReqID uuid.UUID, statementID string) uuid.UUID { + t.Helper() + stmt := Statement{ + StatementId: statementID, + ImplementedRequirementId: implReqID, + } + require.NoError(t, db.Create(&stmt).Error) + return *stmt.ID +} + // --------------------------------------------------------------------------- // SuggestForImplementedRequirement tests // --------------------------------------------------------------------------- @@ -371,6 +382,32 @@ func TestSuggestForImplementedRequirement_ImplReqNotFound(t *testing.T) { assert.Error(t, err) } +func TestSuggestForStatement_ReturnsMatchingComponent(t *testing.T) { + db := setupTestDB(t) + svc := NewSystemComponentSuggestionService(db, &mockEvidenceQuerier{db: db}) + + const labelKey, labelValue = "plugin", "sshd" + dc := seedDefinedComponentWithLabels(t, db, labelKey, labelValue) + seedFilterForControl(t, db, "ac-1", labelKey, labelValue) + seedEvidenceWithLabel(t, db, labelKey, labelValue) + sspID, implReqID := seedSSPWithImplReq(t, db, "ac-1") + stmtID := seedStatementForImplReq(t, db, implReqID, "ac-1_smt.a") + + suggestions, err := svc.SuggestForStatement(sspID, implReqID, stmtID) + require.NoError(t, err) + require.Len(t, suggestions, 1) + assert.Equal(t, *dc.ID, suggestions[0].DefinedComponentID) +} + +func TestSuggestForStatement_NotFound(t *testing.T) { + db := setupTestDB(t) + svc := NewSystemComponentSuggestionService(db, &mockEvidenceQuerier{db: db}) + + sspID, implReqID := seedSSPWithImplReq(t, db, "ac-1") + _, err := svc.SuggestForStatement(sspID, implReqID, uuid.New()) + assert.Error(t, err) +} + // --------------------------------------------------------------------------- // ApplyForImplementedRequirement tests // --------------------------------------------------------------------------- @@ -448,6 +485,34 @@ func TestApplyForImplementedRequirement_NoSuggestions(t *testing.T) { assert.Equal(t, int64(0), count) } +func TestApplyForStatement_CreatesByComponentLinkedToStatement(t *testing.T) { + db := setupTestDB(t) + svc := NewSystemComponentSuggestionService(db, &mockEvidenceQuerier{db: db}) + + const labelKey, labelValue = "plugin", "firewall" + dc := seedDefinedComponentWithLabels(t, db, labelKey, labelValue) + seedFilterForControl(t, db, "sc-7", labelKey, labelValue) + seedEvidenceWithLabel(t, db, labelKey, labelValue) + sspID, implReqID := seedSSPWithImplReq(t, db, "sc-7") + stmtID := seedStatementForImplReq(t, db, implReqID, "sc-7_smt.a") + + err := svc.ApplyForStatement(sspID, implReqID, stmtID) + require.NoError(t, err) + + var systemImpl SystemImplementation + require.NoError(t, db.Where("system_security_plan_id = ?", sspID).First(&systemImpl).Error) + + var comp SystemComponent + require.NoError(t, db.Where("system_implementation_id = ? AND defined_component_id = ?", systemImpl.ID, dc.ID).First(&comp).Error) + + var bc ByComponent + require.NoError(t, db.Where("component_uuid = ?", comp.ID).First(&bc).Error) + require.NotNil(t, bc.ParentID) + require.NotNil(t, bc.ParentType) + assert.Equal(t, stmtID, *bc.ParentID) + assert.Equal(t, "statements", *bc.ParentType) +} + // --------------------------------------------------------------------------- // ApplyForSSP tests // --------------------------------------------------------------------------- diff --git a/internal/service/relational/templates/subject_template_service.go b/internal/service/relational/templates/subject_template_service.go index 19692949..abdd8f2a 100644 --- a/internal/service/relational/templates/subject_template_service.go +++ b/internal/service/relational/templates/subject_template_service.go @@ -7,10 +7,12 @@ import ( "fmt" "sort" "strings" + "time" "unicode/utf8" "github.com/compliance-framework/api/internal/service/relational" riskrel "github.com/compliance-framework/api/internal/service/relational/risks" + "github.com/defenseunicorns/go-oscal/src/pkg/versioning" "github.com/google/uuid" "gorm.io/datatypes" "gorm.io/gorm" @@ -794,7 +796,7 @@ func (s *SubjectTemplateService) ResolveOrUpsertComponentDefinition(input Resolv identityHash := buildEntityIdentityHash(template.Type, identityPairs) - definedComponentID, err := s.resolveOrCreateComponentDefinition(template, identityPairs, schemaLabelPairs, identityHash) + definedComponentID, err := s.resolveOrCreateComponentDefinition(template, pluginValue, identityPairs, schemaLabelPairs, identityHash) if err != nil { return nil, err } @@ -811,7 +813,7 @@ func (s *SubjectTemplateService) ResolveOrUpsertComponentDefinition(input Resolv return result, nil } -func (s *SubjectTemplateService) resolveOrCreateComponentDefinition(template SubjectTemplate, identityPairs []identityLabelPair, schemaLabels []identityLabelPair, identityHash string) (*uuid.UUID, error) { +func (s *SubjectTemplateService) resolveOrCreateComponentDefinition(template SubjectTemplate, pluginValue string, identityPairs []identityLabelPair, schemaLabels []identityLabelPair, identityHash string) (*uuid.UUID, error) { // Check if identity already exists. var existingIdentity ComponentDefinitionIdentity if err := s.db.Where("entity_type = ? AND identity_hash = ?", subjectTemplateTypeComponent, identityHash).First(&existingIdentity).Error; err == nil { @@ -865,9 +867,17 @@ func (s *SubjectTemplateService) resolveOrCreateComponentDefinition(template Sub remarks = rendered } - // Generate deterministic IDs from the identity hash. - cdID := uuid.NewSHA1(componentDefinitionNamespace, []byte(identityHash)) - dcID := uuid.NewSHA1(cdID, []byte("defined-component")) + // Generate deterministic IDs: + // - ComponentDefinition groups by plugin + // - DefinedComponent is still identity-specific + normalizedPlugin := strings.ToLower(strings.TrimSpace(pluginValue)) + cdID := uuid.NewSHA1(componentDefinitionNamespace, []byte("plugin:"+normalizedPlugin)) + dcID := uuid.NewSHA1(cdID, []byte(identityHash)) + now := time.Now().UTC() + componentDefinitionTitle := template.Name + if normalizedPlugin != "" { + componentDefinitionTitle = fmt.Sprintf("%s components", pluginValue) + } tx := s.db.Begin() if tx.Error != nil { @@ -878,8 +888,14 @@ func (s *SubjectTemplateService) resolveOrCreateComponentDefinition(template Sub // Upsert ComponentDefinition. cd := relational.ComponentDefinition{ UUIDModel: relational.UUIDModel{ID: &cdID}, - } - if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Omit(clause.Associations).Create(&cd).Error; err != nil { + Metadata: relational.Metadata{ + Title: componentDefinitionTitle, + Version: "1.0.0", + OscalVersion: versioning.GetLatestSupportedVersion(), + LastModified: &now, + }, + } + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&cd).Error; err != nil { tx.Rollback() return nil, err } diff --git a/internal/service/relational/templates/subject_template_service_test.go b/internal/service/relational/templates/subject_template_service_test.go index b5db085c..b519334a 100644 --- a/internal/service/relational/templates/subject_template_service_test.go +++ b/internal/service/relational/templates/subject_template_service_test.go @@ -6,6 +6,7 @@ import ( "github.com/compliance-framework/api/internal/service/relational" riskrel "github.com/compliance-framework/api/internal/service/relational/risks" + "github.com/defenseunicorns/go-oscal/src/pkg/versioning" "github.com/google/uuid" "github.com/stretchr/testify/require" "gorm.io/datatypes" @@ -557,6 +558,7 @@ func newSubjectTemplateTestDB(t *testing.T) *gorm.DB { &subjectResolverAssessmentSubjectRow{}, &subjectResolverSystemComponentRow{}, &subjectResolverSystemImplementationRow{}, + &relational.Metadata{}, &relational.ComponentDefinition{}, &relational.DefinedComponent{}, &riskrel.AssessmentSubjectLabel{}, @@ -669,6 +671,13 @@ func TestSubjectTemplateService_ResolveOrUpsertComponentDefinitionHappyPath(t *t var labelCount int64 require.NoError(t, db.Model(&riskrel.ComponentDefinitionLabel{}).Count(&labelCount).Error) require.Equal(t, int64(2), labelCount) + + var cd relational.ComponentDefinition + require.NoError(t, db.Preload("Metadata").First(&cd).Error) + require.Equal(t, "github components", cd.Metadata.Title) + require.Equal(t, "1.0.0", cd.Metadata.Version) + require.Equal(t, versioning.GetLatestSupportedVersion(), cd.Metadata.OscalVersion) + require.NotNil(t, cd.Metadata.LastModified) } func TestSubjectTemplateService_ResolveOrUpsertComponentDefinitionIdempotent(t *testing.T) { @@ -770,6 +779,55 @@ func TestSubjectTemplateService_ResolveOrUpsertComponentDefinitionPluginPrefilte require.Equal(t, int64(1), cdCount) } +func TestSubjectTemplateService_ResolveOrUpsertComponentDefinitionGroupsByPlugin(t *testing.T) { + db := newSubjectTemplateTestDB(t) + svc := NewSubjectTemplateService(db) + + _, err := svc.Create(SubjectTemplatePayload{ + Name: "GitHub Component", + Type: "component", + IdentityLabelKeys: []string{"asset_id", "cluster"}, + SourceMode: "runtime-derived", + SelectorLabels: []SubjectTemplateSelectorLabelInput{ + {Key: "_plugin", Value: "github"}, + }, + LabelSchema: []SubjectTemplateLabelSchemaFieldInput{ + {Key: "_plugin"}, + {Key: "asset_id"}, + {Key: "cluster"}, + }, + }) + require.NoError(t, err) + + first, err := svc.ResolveOrUpsertComponentDefinition(ResolveOrUpsertComponentDefinitionInput{ + EvidenceLabels: []relational.Labels{ + {Name: "_plugin", Value: "github"}, + {Name: "asset_id", Value: "srv-123"}, + {Name: "cluster", Value: "prod-us"}, + }, + }) + require.NoError(t, err) + require.Len(t, first.DefinedComponentIDs, 1) + + second, err := svc.ResolveOrUpsertComponentDefinition(ResolveOrUpsertComponentDefinitionInput{ + EvidenceLabels: []relational.Labels{ + {Name: "_plugin", Value: "github"}, + {Name: "asset_id", Value: "srv-456"}, + {Name: "cluster", Value: "prod-us"}, + }, + }) + require.NoError(t, err) + require.Len(t, second.DefinedComponentIDs, 1) + + var cdCount int64 + require.NoError(t, db.Table("component_definitions").Count(&cdCount).Error) + require.Equal(t, int64(1), cdCount) + + var dcCount int64 + require.NoError(t, db.Table("defined_components").Count(&dcCount).Error) + require.Equal(t, int64(2), dcCount) +} + func TestSubjectTemplateService_ResolveOrUpsertComponentDefinitionNoPlugin(t *testing.T) { db := newSubjectTemplateTestDB(t) svc := NewSubjectTemplateService(db) diff --git a/internal/service/worker/risk_evidence_worker.go b/internal/service/worker/risk_evidence_worker.go index 4a630f20..78224a8e 100644 --- a/internal/service/worker/risk_evidence_worker.go +++ b/internal/service/worker/risk_evidence_worker.go @@ -194,8 +194,8 @@ func (w *RiskEvidenceWorker) extractViolationIDs(labels []relational.Labels) []s var violationIDs []string for _, label := range labels { - // Look for labels with name exactly "violation_id" - if label.Name == "_violation_id" && label.Value != "" { + // Accept both current and legacy violation label names. + if (label.Name == "_violation_id" || label.Name == "violation_id") && label.Value != "" { violationIDs = append(violationIDs, label.Value) } } @@ -265,7 +265,7 @@ func (w *RiskEvidenceWorker) createOrUpdateRisksForSSPs(ctx context.Context, ris if err != nil { w.logger.Errorw("Failed to create or update risk for SSP", "error", err, - "evidence_id", evidence.ID, + "evidence_id", evidence.UUID, "risk_template_id", riskTemplate.ID, "ssp_id", sspID) errs = append(errs, err) @@ -369,7 +369,7 @@ func (w *RiskEvidenceWorker) updateExistingRisk(ctx context.Context, existingRis // Emit a risk_event(last_seen) using the typed constant. if err := w.emitRiskEvent(ctx, tx, *existingRisk.ID, string(risks.RiskEventTypeLastSeen), map[string]interface{}{ - "evidence_id": evidence.ID, + "evidence_id": evidence.UUID, "previous_last_seen": previousLastSeen, "new_last_seen": now, }); err != nil { @@ -384,7 +384,7 @@ func (w *RiskEvidenceWorker) updateExistingRisk(ctx context.Context, existingRis w.logger.Infow("Updated existing risk", "risk_id", existingRisk.ID, - "evidence_id", evidence.ID, + "evidence_id", evidence.UUID, "dedupe_key", existingRisk.DedupeKey, ) @@ -426,7 +426,7 @@ func (w *RiskEvidenceWorker) createNewRiskForSSP(ctx context.Context, riskTempla } // Emit a risk_event(created) using the typed constant if err := w.emitRiskEvent(ctx, tx, *newRisk.ID, string(risks.RiskEventTypeCreated), map[string]interface{}{ - "evidence_id": evidence.ID, + "evidence_id": evidence.UUID, "template_id": riskTemplate.ID, "dedupe_key": dedupeKey, "ssp_id": sspID, @@ -441,7 +441,7 @@ func (w *RiskEvidenceWorker) createNewRiskForSSP(ctx context.Context, riskTempla w.logger.Infow("Created new risk", "risk_id", newRisk.ID, - "evidence_id", evidence.ID, + "evidence_id", evidence.UUID, "risk_template_id", riskTemplate.ID, "ssp_id", sspID, "dedupe_key", dedupeKey, @@ -459,7 +459,7 @@ func (w *RiskEvidenceWorker) createRiskLinks(ctx context.Context, db *gorm.DB, r // Link evidence evidenceLink := &risks.RiskEvidenceLink{ RiskID: riskID, - EvidenceID: *evidence.ID, + EvidenceID: evidence.UUID, CreatedAt: now, } if err := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(evidenceLink).Error; err != nil { diff --git a/internal/service/worker/risk_evidence_worker_test.go b/internal/service/worker/risk_evidence_worker_test.go index 2ed38a25..f4651460 100644 --- a/internal/service/worker/risk_evidence_worker_test.go +++ b/internal/service/worker/risk_evidence_worker_test.go @@ -189,7 +189,7 @@ func TestRiskEvidenceWorker_Work_Success(t *testing.T) { // Verify the evidence link was created var link risks.RiskEvidenceLink require.NoError(t, worker.db.WithContext(ctx). - Where("risk_id = ? AND evidence_id = ?", risk.ID, evidence.ID). + Where("risk_id = ? AND evidence_id = ?", risk.ID, evidence.UUID). First(&link).Error) // Verify a created event was emitted @@ -906,7 +906,7 @@ func TestRiskEvidenceWorker_createRiskLinks(t *testing.T) { // Verify evidence link var evidenceLink risks.RiskEvidenceLink err = worker.db.WithContext(ctx). - Where("risk_id = ? AND evidence_id = ?", riskID, *evidence.ID). + Where("risk_id = ? AND evidence_id = ?", riskID, evidence.UUID). First(&evidenceLink).Error assert.NoError(t, err) @@ -943,7 +943,7 @@ func TestRiskEvidenceWorker_createRiskLinks_NoSubjectsOrComponents(t *testing.T) // Verify only evidence link was created var evidenceLink risks.RiskEvidenceLink err = worker.db.WithContext(ctx). - Where("risk_id = ? AND evidence_id = ?", riskID, *evidence.ID). + Where("risk_id = ? AND evidence_id = ?", riskID, evidence.UUID). First(&evidenceLink).Error assert.NoError(t, err) From e260563814d968684c12da3eceaaffdab95218dc Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Tue, 10 Mar 2026 06:17:48 -0300 Subject: [PATCH 3/6] fix: addressing copilot issues Signed-off-by: Gustavo Carvalho --- internal/api/handler/risks.go | 4 +- .../api/handler/risks_integration_test.go | 7 +++ internal/service/relational/risks/service.go | 23 +++++++++- .../service/relational/risks/service_test.go | 34 ++++++++++++++ .../templates/subject_template_service.go | 45 +++++++++++++++---- .../subject_template_service_test.go | 6 +++ 6 files changed, 109 insertions(+), 10 deletions(-) diff --git a/internal/api/handler/risks.go b/internal/api/handler/risks.go index 0c48786b..b52f4587 100644 --- a/internal/api/handler/risks.go +++ b/internal/api/handler/risks.go @@ -348,7 +348,9 @@ func (h *RiskHandler) ListForSSP(ctx echo.Context) error { return h.internalServerError(ctx, "failed to validate ssp", err) } - ctx.QueryParams().Set("sspId", sspID.String()) + q := ctx.QueryParams() + q.Set("sspId", sspID.String()) + ctx.Request().URL.RawQuery = q.Encode() return h.List(ctx) } diff --git a/internal/api/handler/risks_integration_test.go b/internal/api/handler/risks_integration_test.go index b5f23842..3cc94d3f 100644 --- a/internal/api/handler/risks_integration_test.go +++ b/internal/api/handler/risks_integration_test.go @@ -250,6 +250,13 @@ func (suite *RiskApiIntegrationSuite) TestSSPScopedRiskCRUD() { require.NoError(suite.T(), json.Unmarshal(createRec.Body.Bytes(), &created)) require.Equal(suite.T(), sspID, created.Data.SSPID.String()) + otherCreateRec, otherCreateReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/ssp/%s/risks", otherSSPID), map[string]any{ + "title": "Other scoped risk", + "description": "should not appear in first scope list", + }) + suite.server.E().ServeHTTP(otherCreateRec, otherCreateReq) + require.Equal(suite.T(), http.StatusCreated, otherCreateRec.Code) + listRec, listHTTPReq := suite.authedRequest(http.MethodGet, fmt.Sprintf("/api/ssp/%s/risks?page=1&limit=20", sspID), nil) suite.server.E().ServeHTTP(listRec, listHTTPReq) require.Equal(suite.T(), http.StatusOK, listRec.Code) diff --git a/internal/service/relational/risks/service.go b/internal/service/relational/risks/service.go index 12965b44..79070d99 100644 --- a/internal/service/relational/risks/service.go +++ b/internal/service/relational/risks/service.go @@ -349,16 +349,37 @@ func (s *RiskService) DeleteEvidenceLink(riskID, evidenceID uuid.UUID, actorUser return false, resolveErr } + deletedEvidenceID := evidenceStreamID result := tx.Delete(&RiskEvidenceLink{}, "risk_id = ? AND evidence_id = ?", riskID, evidenceStreamID) if result.Error != nil { tx.Rollback() return false, result.Error } + + if result.RowsAffected > 0 && evidenceStreamID != evidenceID { + // Best-effort cleanup for legacy rows that may still store evidences.id. + if err := tx.Delete(&RiskEvidenceLink{}, "risk_id = ? AND evidence_id = ?", riskID, evidenceID).Error; err != nil { + tx.Rollback() + return false, err + } + } + + if result.RowsAffected == 0 && evidenceStreamID != evidenceID { + legacyDelete := tx.Delete(&RiskEvidenceLink{}, "risk_id = ? AND evidence_id = ?", riskID, evidenceID) + if legacyDelete.Error != nil { + tx.Rollback() + return false, legacyDelete.Error + } + result = legacyDelete + deletedEvidenceID = evidenceID + } + if result.RowsAffected == 0 { tx.Rollback() return false, nil } - if err := s.logRiskEvent(tx, riskID, RiskEventTypeEvidenceUnlink, actorUserID, datatypes.JSONMap{"evidenceId": evidenceStreamID.String()}); err != nil { + + if err := s.logRiskEvent(tx, riskID, RiskEventTypeEvidenceUnlink, actorUserID, datatypes.JSONMap{"evidenceId": deletedEvidenceID.String()}); err != nil { tx.Rollback() return false, err } diff --git a/internal/service/relational/risks/service_test.go b/internal/service/relational/risks/service_test.go index fe7f7678..82700177 100644 --- a/internal/service/relational/risks/service_test.go +++ b/internal/service/relational/risks/service_test.go @@ -456,6 +456,40 @@ func TestRiskServiceRejectsInvalidOwnerAssignments(t *testing.T) { require.ErrorContains(t, err, "ownerRef must be a valid UUID") } +func TestRiskServiceDeleteEvidenceLinkDeletesLegacyRowIDs(t *testing.T) { + db := newRiskServiceTestDB(t) + svc := NewRiskService(db) + + riskID := uuid.New() + require.NoError(t, db.Create(&Risk{ + UUIDModel: relational.UUIDModel{ID: &riskID}, + Title: "legacy-evidence-link", + Description: "desc", + Status: string(RiskStatusOpen), + SSPID: uuid.New(), + SourceType: string(RiskSourceTypeManual), + FirstSeenAt: time.Now().UTC(), + LastSeenAt: time.Now().UTC(), + }).Error) + + evidenceID := uuid.New() + evidenceStreamID := uuid.New() + require.NoError(t, db.Create(&testEvidenceRow{ID: evidenceID, UUID: evidenceStreamID, End: time.Now().UTC()}).Error) + + // Simulate migration overlap where both legacy and stream IDs may be linked. + require.NoError(t, db.Create(&RiskEvidenceLink{RiskID: riskID, EvidenceID: evidenceID}).Error) + require.NoError(t, db.Create(&RiskEvidenceLink{RiskID: riskID, EvidenceID: evidenceStreamID}).Error) + + actorID := uuid.New() + deleted, err := svc.DeleteEvidenceLink(riskID, evidenceID, &actorID) + require.NoError(t, err) + require.True(t, deleted) + + var remaining int64 + require.NoError(t, db.Model(&RiskEvidenceLink{}).Where("risk_id = ?", riskID).Count(&remaining).Error) + require.Zero(t, remaining) +} + func newRiskServiceTestDB(t *testing.T) *gorm.DB { t.Helper() diff --git a/internal/service/relational/templates/subject_template_service.go b/internal/service/relational/templates/subject_template_service.go index abdd8f2a..a544ff7d 100644 --- a/internal/service/relational/templates/subject_template_service.go +++ b/internal/service/relational/templates/subject_template_service.go @@ -888,18 +888,47 @@ func (s *SubjectTemplateService) resolveOrCreateComponentDefinition(template Sub // Upsert ComponentDefinition. cd := relational.ComponentDefinition{ UUIDModel: relational.UUIDModel{ID: &cdID}, - Metadata: relational.Metadata{ - Title: componentDefinitionTitle, - Version: "1.0.0", - OscalVersion: versioning.GetLatestSupportedVersion(), - LastModified: &now, - }, - } - if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&cd).Error; err != nil { + } + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Omit(clause.Associations).Create(&cd).Error; err != nil { tx.Rollback() return nil, err } + // Upsert metadata separately so repeated calls do not create duplicate polymorphic metadata rows. + parentID := cdID.String() + parentType := "component_definitions" + var existingMetadata relational.Metadata + metadataQuery := tx.Model(&relational.Metadata{}).Where("parent_id = ? AND parent_type = ?", parentID, parentType) + if err := metadataQuery.First(&existingMetadata).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + md := relational.Metadata{ + Title: componentDefinitionTitle, + Version: "1.0.0", + OscalVersion: versioning.GetLatestSupportedVersion(), + LastModified: &now, + ParentID: &parentID, + ParentType: &parentType, + } + if err := tx.Omit(clause.Associations).Create(&md).Error; err != nil { + tx.Rollback() + return nil, err + } + } else { + tx.Rollback() + return nil, err + } + } else { + if err := tx.Model(&relational.Metadata{}).Where("parent_id = ? AND parent_type = ?", parentID, parentType).Updates(map[string]interface{}{ + "title": componentDefinitionTitle, + "version": "1.0.0", + "oscal_version": versioning.GetLatestSupportedVersion(), + "last_modified": &now, + }).Error; err != nil { + tx.Rollback() + return nil, err + } + } + // Upsert DefinedComponent with rendered template values. dc := relational.DefinedComponent{ UUIDModel: relational.UUIDModel{ID: &dcID}, diff --git a/internal/service/relational/templates/subject_template_service_test.go b/internal/service/relational/templates/subject_template_service_test.go index b519334a..8e6ec01e 100644 --- a/internal/service/relational/templates/subject_template_service_test.go +++ b/internal/service/relational/templates/subject_template_service_test.go @@ -722,6 +722,12 @@ func TestSubjectTemplateService_ResolveOrUpsertComponentDefinitionIdempotent(t * var dcCount int64 require.NoError(t, db.Table("defined_components").Count(&dcCount).Error) require.Equal(t, int64(1), dcCount) + + var componentDefinition relational.ComponentDefinition + require.NoError(t, db.First(&componentDefinition).Error) + var metadataCount int64 + require.NoError(t, db.Model(&relational.Metadata{}).Where("parent_id = ?", componentDefinition.ID.String()).Count(&metadataCount).Error) + require.Equal(t, int64(1), metadataCount) } func TestSubjectTemplateService_ResolveOrUpsertComponentDefinitionPluginPrefilter(t *testing.T) { From 7c811a84c6aa156c6e3fe00278009709b6ad1f22 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Tue, 10 Mar 2026 06:44:04 -0300 Subject: [PATCH 4/6] fix: address copilot issues Signed-off-by: Gustavo Carvalho --- .gitignore | 1 + docs/docs.go | 491 ++++++++++++++++++ docs/swagger.json | 491 ++++++++++++++++++ docs/swagger.yaml | 321 ++++++++++++ ...urity_plan_suggestions_integration_test.go | 14 +- .../handler/oscal/system_security_plans.go | 2 + internal/api/handler/risks.go | 92 +++- internal/service/relational/risks/labels.go | 1 + internal/service/relational/risks/service.go | 11 + .../service/relational/risks/service_test.go | 24 + .../system_component_suggestions.go | 24 +- .../system_component_suggestions_test.go | 71 ++- .../templates/subject_template_service.go | 3 +- .../subject_template_service_test.go | 4 + 14 files changed, 1516 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index aec3ae8a..eb413610 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.codex cover.out .idea diff --git a/docs/docs.go b/docs/docs.go index 7352d9e6..25a5fa5f 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -14291,6 +14291,66 @@ const docTemplate = `{ } } }, + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/apply-suggestion": { + "post": { + "description": "Creates SystemComponents from DefinedComponents that implement the statement's parent control and links them via ByComponent to the statement.", + "tags": [ + "System Security Plans" + ], + "summary": "Apply component suggestions for a statement", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Implemented Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "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/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components": { "post": { "description": "Create a by-component within an existing statement within an implemented requirement for a given SSP.", @@ -14513,6 +14573,72 @@ const docTemplate = `{ } } }, + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/suggest-components": { + "post": { + "description": "Returns DefinedComponents that implement the statement's parent control and are not yet present in the SSP.", + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Suggest system components for a statement", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Implemented Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-relational_SystemComponentSuggestion" + } + }, + "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/{id}/control-implementation/implemented-requirements/{reqId}/suggest-components": { "post": { "description": "Returns DefinedComponents that implement the same control and are not yet present in the SSP.", @@ -18079,6 +18205,371 @@ const docTemplate = `{ ] } }, + "/ssp/{sspId}/risks": { + "get": { + "description": "Lists risk register entries scoped to an SSP.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "List risks for SSP", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "sspId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Risk status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Risk likelihood", + "name": "likelihood", + "in": "query" + }, + { + "type": "string", + "description": "Risk impact", + "name": "impact", + "in": "query" + }, + { + "type": "string", + "description": "Control ID", + "name": "controlId", + "in": "query" + }, + { + "type": "string", + "description": "Evidence ID", + "name": "evidenceId", + "in": "query" + }, + { + "type": "string", + "description": "Owner kind", + "name": "ownerKind", + "in": "query" + }, + { + "type": "string", + "description": "Owner reference", + "name": "ownerRef", + "in": "query" + }, + { + "type": "string", + "description": "Review deadline upper bound (RFC3339)", + "name": "reviewDeadlineBefore", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Sort field", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "description": "Sort order (asc|desc)", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/service.ListResponse-handler_riskResponse" + } + }, + "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": [] + } + ] + }, + "post": { + "description": "Creates a risk register entry scoped to an SSP.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Create risk for SSP", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "sspId", + "in": "path", + "required": true + }, + { + "description": "Risk payload", + "name": "risk", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.createRiskRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "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": [] + } + ] + } + }, + "/ssp/{sspId}/risks/{id}": { + "get": { + "description": "Retrieves a risk register entry by ID scoped to an SSP.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Get risk 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.GenericDataResponse-handler_riskResponse" + } + }, + "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": [] + } + ] + }, + "put": { + "description": "Updates a risk register entry by ID scoped to an SSP.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Update risk 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 + }, + { + "description": "Risk payload", + "name": "risk", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.updateRiskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "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": [] + } + ] + }, + "delete": { + "description": "Deletes a risk register entry by ID scoped to an SSP.", + "tags": [ + "Risks" + ], + "summary": "Delete risk 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": { + "204": { + "description": "No Content" + }, + "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": [] + } + ] + } + }, "/subject-templates": { "get": { "description": "List subject templates with optional filters and pagination.", diff --git a/docs/swagger.json b/docs/swagger.json index 30c97b35..889a23a6 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -14285,6 +14285,66 @@ } } }, + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/apply-suggestion": { + "post": { + "description": "Creates SystemComponents from DefinedComponents that implement the statement's parent control and links them via ByComponent to the statement.", + "tags": [ + "System Security Plans" + ], + "summary": "Apply component suggestions for a statement", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Implemented Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "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/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components": { "post": { "description": "Create a by-component within an existing statement within an implemented requirement for a given SSP.", @@ -14507,6 +14567,72 @@ } } }, + "/oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/suggest-components": { + "post": { + "description": "Returns DefinedComponents that implement the statement's parent control and are not yet present in the SSP.", + "produces": [ + "application/json" + ], + "tags": [ + "System Security Plans" + ], + "summary": "Suggest system components for a statement", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Implemented Requirement ID", + "name": "reqId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Statement ID", + "name": "stmtId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataListResponse-relational_SystemComponentSuggestion" + } + }, + "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/{id}/control-implementation/implemented-requirements/{reqId}/suggest-components": { "post": { "description": "Returns DefinedComponents that implement the same control and are not yet present in the SSP.", @@ -18073,6 +18199,371 @@ ] } }, + "/ssp/{sspId}/risks": { + "get": { + "description": "Lists risk register entries scoped to an SSP.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "List risks for SSP", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "sspId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Risk status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Risk likelihood", + "name": "likelihood", + "in": "query" + }, + { + "type": "string", + "description": "Risk impact", + "name": "impact", + "in": "query" + }, + { + "type": "string", + "description": "Control ID", + "name": "controlId", + "in": "query" + }, + { + "type": "string", + "description": "Evidence ID", + "name": "evidenceId", + "in": "query" + }, + { + "type": "string", + "description": "Owner kind", + "name": "ownerKind", + "in": "query" + }, + { + "type": "string", + "description": "Owner reference", + "name": "ownerRef", + "in": "query" + }, + { + "type": "string", + "description": "Review deadline upper bound (RFC3339)", + "name": "reviewDeadlineBefore", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Sort field", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "description": "Sort order (asc|desc)", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/service.ListResponse-handler_riskResponse" + } + }, + "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": [] + } + ] + }, + "post": { + "description": "Creates a risk register entry scoped to an SSP.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Create risk for SSP", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "sspId", + "in": "path", + "required": true + }, + { + "description": "Risk payload", + "name": "risk", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.createRiskRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "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": [] + } + ] + } + }, + "/ssp/{sspId}/risks/{id}": { + "get": { + "description": "Retrieves a risk register entry by ID scoped to an SSP.", + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Get risk 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.GenericDataResponse-handler_riskResponse" + } + }, + "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": [] + } + ] + }, + "put": { + "description": "Updates a risk register entry by ID scoped to an SSP.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Update risk 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 + }, + { + "description": "Risk payload", + "name": "risk", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.updateRiskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "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": [] + } + ] + }, + "delete": { + "description": "Deletes a risk register entry by ID scoped to an SSP.", + "tags": [ + "Risks" + ], + "summary": "Delete risk 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": { + "204": { + "description": "No Content" + }, + "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": [] + } + ] + } + }, "/subject-templates": { "get": { "description": "List subject templates with optional filters and pagination.", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 5c1f54a1..013b946e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -17397,6 +17397,46 @@ paths: summary: Update a statement within an implemented requirement tags: - System Security Plans + /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/apply-suggestion: + post: + description: Creates SystemComponents from DefinedComponents that implement + the statement's parent control and links them via ByComponent to the statement. + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Implemented Requirement ID + in: path + name: reqId + required: true + type: string + - description: Statement ID + in: path + name: stmtId + required: true + type: string + responses: + "204": + description: No Content + "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: Apply component suggestions for a statement + tags: + - System Security Plans /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/by-components: post: consumes: @@ -17550,6 +17590,50 @@ paths: summary: Update a by-component within a statement (within an implemented requirement) tags: - System Security Plans + /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/suggest-components: + post: + description: Returns DefinedComponents that implement the statement's parent + control and are not yet present in the SSP. + parameters: + - description: SSP ID + in: path + name: id + required: true + type: string + - description: Implemented Requirement ID + in: path + name: reqId + required: true + type: string + - description: Statement ID + in: path + name: stmtId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataListResponse-relational_SystemComponentSuggestion' + "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: Suggest system components for a statement + tags: + - System Security Plans /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/suggest-components: post: description: Returns DefinedComponents that implement the same control and are @@ -19890,6 +19974,243 @@ paths: summary: Link subject to risk tags: - Risks + /ssp/{sspId}/risks: + get: + description: Lists risk register entries scoped to an SSP. + parameters: + - description: SSP ID + in: path + name: sspId + required: true + type: string + - description: Risk status + in: query + name: status + type: string + - description: Risk likelihood + in: query + name: likelihood + type: string + - description: Risk impact + in: query + name: impact + type: string + - description: Control ID + in: query + name: controlId + type: string + - description: Evidence ID + in: query + name: evidenceId + type: string + - description: Owner kind + in: query + name: ownerKind + type: string + - description: Owner reference + in: query + name: ownerRef + type: string + - description: Review deadline upper bound (RFC3339) + in: query + name: reviewDeadlineBefore + type: string + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer + - description: Sort field + in: query + name: sort + type: string + - description: Sort order (asc|desc) + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/service.ListResponse-handler_riskResponse' + "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 risks for SSP + tags: + - Risks + post: + consumes: + - application/json + description: Creates a risk register entry scoped to an SSP. + parameters: + - description: SSP ID + in: path + name: sspId + required: true + type: string + - description: Risk payload + in: body + name: risk + required: true + schema: + $ref: '#/definitions/handler.createRiskRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_riskResponse' + "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: Create risk for SSP + tags: + - Risks + /ssp/{sspId}/risks/{id}: + delete: + description: Deletes a risk register entry by ID 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 + responses: + "204": + description: No Content + "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: Delete risk for SSP + tags: + - Risks + get: + description: Retrieves a risk register entry by ID 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.GenericDataResponse-handler_riskResponse' + "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 for SSP + tags: + - Risks + put: + consumes: + - application/json + description: Updates a risk register entry by ID 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 + - description: Risk payload + in: body + name: risk + required: true + schema: + $ref: '#/definitions/handler.updateRiskRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_riskResponse' + "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: Update risk for SSP + tags: + - Risks /subject-templates: get: description: List subject templates with optional filters and pagination. diff --git a/internal/api/handler/oscal/system_security_plan_suggestions_integration_test.go b/internal/api/handler/oscal/system_security_plan_suggestions_integration_test.go index 6504c0f3..c88f12a7 100644 --- a/internal/api/handler/oscal/system_security_plan_suggestions_integration_test.go +++ b/internal/api/handler/oscal/system_security_plan_suggestions_integration_test.go @@ -187,15 +187,9 @@ func (suite *SystemComponentSuggestionsIntegrationSuite) buildFilterAndEvidence( evidence.ID, labelKey, labelValue, ).Error) - // 4. Create a ComponentDefinition with matching component_definition_labels + // 4. Create a ComponentDefinition and a matching DefinedComponent compDef := relational.ComponentDefinition{} suite.Require().NoError(suite.DB.Create(&compDef).Error) - suite.Require().NoError(suite.DB.Exec( - `INSERT INTO component_definition_labels (component_definition_id, key, value) VALUES (?, ?, ?)`, - compDef.ID, labelKey, labelValue, - ).Error) - - // 5. Create a DefinedComponent linked to that ComponentDefinition dc := relational.DefinedComponent{ Type: "software", Title: "Suggested Component", @@ -204,6 +198,12 @@ func (suite *SystemComponentSuggestionsIntegrationSuite) buildFilterAndEvidence( } suite.Require().NoError(suite.DB.Create(&dc).Error) + // 5. Store label match scoped to the DefinedComponent. + suite.Require().NoError(suite.DB.Exec( + `INSERT INTO component_definition_labels (defined_component_id, component_definition_id, key, value) VALUES (?, ?, ?, ?)`, + dc.ID, compDef.ID, labelKey, labelValue, + ).Error) + return dc.ID.String() } diff --git a/internal/api/handler/oscal/system_security_plans.go b/internal/api/handler/oscal/system_security_plans.go index e8803634..7ac1638e 100644 --- a/internal/api/handler/oscal/system_security_plans.go +++ b/internal/api/handler/oscal/system_security_plans.go @@ -4083,6 +4083,7 @@ func (h *SystemSecurityPlanHandler) SuggestComponents(ctx echo.Context) error { // @Failure 404 {object} api.Error // @Failure 500 {object} api.Error // @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/suggest-components [post] func (h *SystemSecurityPlanHandler) SuggestComponentsForStatement(ctx echo.Context) error { sspID, reqID, stmtID, err := parseSSPReqStmtIDs(ctx) if err != nil { @@ -4154,6 +4155,7 @@ func (h *SystemSecurityPlanHandler) ApplySuggestion(ctx echo.Context) error { // @Failure 404 {object} api.Error // @Failure 500 {object} api.Error // @Security OAuth2Password +// @Router /oscal/system-security-plans/{id}/control-implementation/implemented-requirements/{reqId}/statements/{stmtId}/apply-suggestion [post] func (h *SystemSecurityPlanHandler) ApplySuggestionForStatement(ctx echo.Context) error { sspID, reqID, stmtID, err := parseSSPReqStmtIDs(ctx) if err != nil { diff --git a/internal/api/handler/risks.go b/internal/api/handler/risks.go index b52f4587..95114157 100644 --- a/internal/api/handler/risks.go +++ b/internal/api/handler/risks.go @@ -336,6 +336,31 @@ func (h *RiskHandler) createFromRequest(ctx echo.Context, req createRiskRequest) }) } +// ListForSSP godoc +// +// @Summary List risks for SSP +// @Description Lists risk register entries scoped to an SSP. +// @Tags Risks +// @Produce json +// @Param sspId path string true "SSP ID" +// @Param status query string false "Risk status" +// @Param likelihood query string false "Risk likelihood" +// @Param impact query string false "Risk impact" +// @Param controlId query string false "Control ID" +// @Param evidenceId query string false "Evidence ID" +// @Param ownerKind query string false "Owner kind" +// @Param ownerRef query string false "Owner reference" +// @Param reviewDeadlineBefore query string false "Review deadline upper bound (RFC3339)" +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Param sort query string false "Sort field" +// @Param order query string false "Sort order (asc|desc)" +// @Success 200 {object} svc.ListResponse[riskResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /ssp/{sspId}/risks [get] func (h *RiskHandler) ListForSSP(ctx echo.Context) error { sspID, err := parsePathUUID(ctx, "sspId") if err != nil { @@ -354,6 +379,21 @@ func (h *RiskHandler) ListForSSP(ctx echo.Context) error { return h.List(ctx) } +// CreateForSSP godoc +// +// @Summary Create risk for SSP +// @Description Creates a risk register entry scoped to an SSP. +// @Tags Risks +// @Accept json +// @Produce json +// @Param sspId path string true "SSP ID" +// @Param risk body createRiskRequest true "Risk payload" +// @Success 201 {object} GenericDataResponse[riskResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /ssp/{sspId}/risks [post] func (h *RiskHandler) CreateForSSP(ctx echo.Context) error { sspID, err := parsePathUUID(ctx, "sspId") if err != nil { @@ -368,6 +408,20 @@ func (h *RiskHandler) CreateForSSP(ctx echo.Context) error { return h.createFromRequest(ctx, req) } +// GetForSSP godoc +// +// @Summary Get risk for SSP +// @Description Retrieves a risk register entry by ID 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} GenericDataResponse[riskResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /ssp/{sspId}/risks/{id} [get] func (h *RiskHandler) GetForSSP(ctx echo.Context) error { sspID, err := parsePathUUID(ctx, "sspId") if err != nil { @@ -386,6 +440,22 @@ func (h *RiskHandler) GetForSSP(ctx echo.Context) error { return h.Get(ctx) } +// UpdateForSSP godoc +// +// @Summary Update risk for SSP +// @Description Updates a risk register entry by ID scoped to an SSP. +// @Tags Risks +// @Accept json +// @Produce json +// @Param sspId path string true "SSP ID" +// @Param id path string true "Risk ID" +// @Param risk body updateRiskRequest true "Risk payload" +// @Success 200 {object} GenericDataResponse[riskResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /ssp/{sspId}/risks/{id} [put] func (h *RiskHandler) UpdateForSSP(ctx echo.Context) error { sspID, err := parsePathUUID(ctx, "sspId") if err != nil { @@ -404,6 +474,19 @@ func (h *RiskHandler) UpdateForSSP(ctx echo.Context) error { return h.Update(ctx) } +// DeleteForSSP godoc +// +// @Summary Delete risk for SSP +// @Description Deletes a risk register entry by ID scoped to an SSP. +// @Tags Risks +// @Param sspId path string true "SSP ID" +// @Param id path string true "Risk ID" +// @Success 204 "No Content" +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /ssp/{sspId}/risks/{id} [delete] func (h *RiskHandler) DeleteForSSP(ctx echo.Context) error { sspID, err := parsePathUUID(ctx, "sspId") if err != nil { @@ -423,14 +506,7 @@ func (h *RiskHandler) DeleteForSSP(ctx echo.Context) error { } func (h *RiskHandler) ensureRiskBelongsToSSP(riskID, sspID uuid.UUID) error { - risk, err := h.riskService.GetByID(riskID) - if err != nil { - return err - } - if risk.SSPID != sspID { - return gorm.ErrRecordNotFound - } - return nil + return h.riskService.EnsureRiskInSSP(riskID, sspID) } // Get godoc diff --git a/internal/service/relational/risks/labels.go b/internal/service/relational/risks/labels.go index c82b8d1a..5d4fdd2e 100644 --- a/internal/service/relational/risks/labels.go +++ b/internal/service/relational/risks/labels.go @@ -34,6 +34,7 @@ func (SystemComponentLabel) TableName() string { } type ComponentDefinitionLabel struct { + DefinedComponentID uuid.UUID `json:"definedComponentId" gorm:"type:uuid;primaryKey;index"` ComponentDefinitionID uuid.UUID `json:"componentDefinitionId" gorm:"type:uuid;primaryKey"` Key string `json:"key" gorm:"type:text;primaryKey;index:idx_component_definition_label_key_value,priority:1"` Value string `json:"value" gorm:"type:text;primaryKey;index:idx_component_definition_label_key_value,priority:2"` diff --git a/internal/service/relational/risks/service.go b/internal/service/relational/risks/service.go index 79070d99..4f8f8f04 100644 --- a/internal/service/relational/risks/service.go +++ b/internal/service/relational/risks/service.go @@ -262,6 +262,11 @@ func (s *RiskService) EnsureRiskExists(riskID uuid.UUID) error { return s.db.Select("id").First(&risk, "id = ?", riskID).Error } +func (s *RiskService) EnsureRiskInSSP(riskID, sspID uuid.UUID) error { + var risk Risk + return s.db.Select("id").First(&risk, "id = ? AND ssp_id = ?", riskID, sspID).Error +} + func (s *RiskService) EnsureSSPExists(sspID uuid.UUID) error { var ssp relational.SystemSecurityPlan return s.db.Select("id").First(&ssp, "id = ?", sspID).Error @@ -395,6 +400,9 @@ func (s *RiskService) resolveEvidenceStreamID(tx *gorm.DB, evidenceRef uuid.UUID Select("id", "uuid"). Where("id = ?", evidenceRef). First(&evidence).Error; err == nil { + if evidence.UUID == uuid.Nil { + return uuid.Nil, fmt.Errorf("evidence %s is missing stream uuid", evidence.ID) + } return evidence.UUID, nil } else if !errors.Is(err, gorm.ErrRecordNotFound) { return uuid.Nil, err @@ -407,6 +415,9 @@ func (s *RiskService) resolveEvidenceStreamID(tx *gorm.DB, evidenceRef uuid.UUID First(&evidence).Error; err != nil { return uuid.Nil, err } + if evidence.UUID == uuid.Nil { + return uuid.Nil, fmt.Errorf("evidence %s is missing stream uuid", evidence.ID) + } return evidence.UUID, nil } diff --git a/internal/service/relational/risks/service_test.go b/internal/service/relational/risks/service_test.go index 82700177..0c660c63 100644 --- a/internal/service/relational/risks/service_test.go +++ b/internal/service/relational/risks/service_test.go @@ -490,6 +490,30 @@ func TestRiskServiceDeleteEvidenceLinkDeletesLegacyRowIDs(t *testing.T) { require.Zero(t, remaining) } +func TestRiskServiceAddEvidenceLinkRejectsEvidenceWithoutStreamUUID(t *testing.T) { + db := newRiskServiceTestDB(t) + svc := NewRiskService(db) + + riskID := uuid.New() + require.NoError(t, db.Create(&Risk{ + UUIDModel: relational.UUIDModel{ID: &riskID}, + Title: "missing-stream-uuid", + Description: "desc", + Status: string(RiskStatusOpen), + SSPID: uuid.New(), + SourceType: string(RiskSourceTypeManual), + FirstSeenAt: time.Now().UTC(), + LastSeenAt: time.Now().UTC(), + }).Error) + + evidenceID := uuid.New() + require.NoError(t, db.Create(&testEvidenceRow{ID: evidenceID, UUID: uuid.Nil, End: time.Now().UTC()}).Error) + + _, err := svc.AddEvidenceLink(riskID, evidenceID, nil) + require.Error(t, err) + require.ErrorContains(t, err, "missing stream uuid") +} + func newRiskServiceTestDB(t *testing.T) *gorm.DB { t.Helper() diff --git a/internal/service/relational/system_component_suggestions.go b/internal/service/relational/system_component_suggestions.go index fcdb3606..6edd63b6 100644 --- a/internal/service/relational/system_component_suggestions.go +++ b/internal/service/relational/system_component_suggestions.go @@ -106,17 +106,27 @@ func (s *SystemComponentSuggestionService) SuggestForImplementedRequirement( evidenceIDs[i] = *e.ID } - // 5. Find DefinedComponents whose ComponentDefinition has labels that overlap with - // the evidence labels. The component_definition_labels table is populated by - // SubjectTemplateService when ComponentDefinitions are auto-created from evidence. - subQ := s.db.Table("component_definition_labels cdl"). - Select("cdl.component_definition_id"). + // 5. Find candidate DefinedComponents whose identity labels overlap with evidence labels. + // component_definition_labels must be scoped to defined_component_id. + // Legacy rows without defined_component_id are intentionally unsupported. + matchedDefinedComponentIDs := make([]uuid.UUID, 0) + if err := s.db.Table("component_definition_labels cdl"). + Distinct(). + Select("cdl.defined_component_id"). Joins("JOIN evidence_labels el ON LOWER(el.labels_name) = LOWER(cdl.key) AND LOWER(el.labels_value) = LOWER(cdl.value)"). - Where("el.evidence_id IN ?", evidenceIDs) + Where("el.evidence_id IN ?", evidenceIDs). + Where("cdl.defined_component_id IS NOT NULL"). + Pluck("cdl.defined_component_id", &matchedDefinedComponentIDs).Error; err != nil { + return nil, fmt.Errorf("failed to query defined component label matches: %w", err) + } + + if len(matchedDefinedComponentIDs) == 0 { + return []SystemComponentSuggestion{}, nil + } var candidates []DefinedComponent if err := s.db. - Where("component_definition_id IN (?)", subQ). + Where("id IN ?", matchedDefinedComponentIDs). Find(&candidates).Error; err != nil { return nil, fmt.Errorf("failed to query defined components: %w", err) } diff --git a/internal/service/relational/system_component_suggestions_test.go b/internal/service/relational/system_component_suggestions_test.go index 73928f20..67e4cc86 100644 --- a/internal/service/relational/system_component_suggestions_test.go +++ b/internal/service/relational/system_component_suggestions_test.go @@ -110,10 +110,11 @@ func setupTestDB(t *testing.T) *gorm.DB { )`).Error) require.NoError(t, db.Exec(`CREATE TABLE IF NOT EXISTS component_definition_labels ( - component_definition_id TEXT, - key TEXT, - value TEXT - )`).Error) + defined_component_id TEXT, + component_definition_id TEXT, + key TEXT, + value TEXT + )`).Error) return db } @@ -174,19 +175,15 @@ func seedEvidenceWithLabel(t *testing.T, db *gorm.DB, labelKey, labelValue strin return evidenceID } -// seedDefinedComponentWithLabels inserts a ComponentDefinition, component_definition_labels row, -// and a DefinedComponent linked to that ComponentDefinition. Returns the DefinedComponent. +// seedDefinedComponentWithLabels inserts a ComponentDefinition, a DefinedComponent linked +// to that ComponentDefinition, and a component_definition_labels row for that DefinedComponent. +// Returns the DefinedComponent. func seedDefinedComponentWithLabels(t *testing.T, db *gorm.DB, labelKey, labelValue string) DefinedComponent { t.Helper() compDef := ComponentDefinition{} require.NoError(t, db.Create(&compDef).Error) - require.NoError(t, db.Exec( - `INSERT INTO component_definition_labels (component_definition_id, key, value) VALUES (?, ?, ?)`, - compDef.ID, labelKey, labelValue, - ).Error) - dc := DefinedComponent{ Type: "software", Title: "Test Component", @@ -196,6 +193,11 @@ func seedDefinedComponentWithLabels(t *testing.T, db *gorm.DB, labelKey, labelVa } require.NoError(t, db.Create(&dc).Error) + require.NoError(t, db.Exec( + `INSERT INTO component_definition_labels (defined_component_id, component_definition_id, key, value) VALUES (?, ?, ?, ?)`, + dc.ID, compDef.ID, labelKey, labelValue, + ).Error) + return dc } @@ -269,6 +271,53 @@ func TestSuggestForImplementedRequirement_ReturnsMatchingComponent(t *testing.T) assert.Equal(t, dc.Description, suggestions[0].Description) } +func TestSuggestForImplementedRequirement_DoesNotReturnNonMatchingSiblingDefinedComponent(t *testing.T) { + db := setupTestDB(t) + svc := NewSystemComponentSuggestionService(db, &mockEvidenceQuerier{db: db}) + + const labelKey = "plugin" + const matchingValue = "sshd" + const nonMatchingValue = "nginx" + + compDef := ComponentDefinition{} + require.NoError(t, db.Create(&compDef).Error) + + matching := DefinedComponent{ + Type: "software", + Title: "Matching", + Description: "matches label", + Purpose: "testing", + ComponentDefinitionID: compDef.ID, + } + require.NoError(t, db.Create(&matching).Error) + require.NoError(t, db.Exec( + `INSERT INTO component_definition_labels (defined_component_id, component_definition_id, key, value) VALUES (?, ?, ?, ?)`, + matching.ID, compDef.ID, labelKey, matchingValue, + ).Error) + + nonMatching := DefinedComponent{ + Type: "software", + Title: "Non-Matching", + Description: "does not match label", + Purpose: "testing", + ComponentDefinitionID: compDef.ID, + } + require.NoError(t, db.Create(&nonMatching).Error) + require.NoError(t, db.Exec( + `INSERT INTO component_definition_labels (defined_component_id, component_definition_id, key, value) VALUES (?, ?, ?, ?)`, + nonMatching.ID, compDef.ID, labelKey, nonMatchingValue, + ).Error) + + seedFilterForControl(t, db, "ac-1", labelKey, matchingValue) + seedEvidenceWithLabel(t, db, labelKey, matchingValue) + sspID, implReqID := seedSSPWithImplReq(t, db, "ac-1") + + suggestions, err := svc.SuggestForImplementedRequirement(sspID, implReqID) + require.NoError(t, err) + require.Len(t, suggestions, 1) + assert.Equal(t, *matching.ID, suggestions[0].DefinedComponentID) +} + func TestSuggestForImplementedRequirement_DifferentControlFiltered(t *testing.T) { db := setupTestDB(t) svc := NewSystemComponentSuggestionService(db, &mockEvidenceQuerier{db: db}) diff --git a/internal/service/relational/templates/subject_template_service.go b/internal/service/relational/templates/subject_template_service.go index a544ff7d..94c8c3d2 100644 --- a/internal/service/relational/templates/subject_template_service.go +++ b/internal/service/relational/templates/subject_template_service.go @@ -876,7 +876,7 @@ func (s *SubjectTemplateService) resolveOrCreateComponentDefinition(template Sub now := time.Now().UTC() componentDefinitionTitle := template.Name if normalizedPlugin != "" { - componentDefinitionTitle = fmt.Sprintf("%s components", pluginValue) + componentDefinitionTitle = fmt.Sprintf("%s components", normalizedPlugin) } tx := s.db.Begin() @@ -948,6 +948,7 @@ func (s *SubjectTemplateService) resolveOrCreateComponentDefinition(template Sub labels := make([]riskrel.ComponentDefinitionLabel, 0, len(identityPairs)) for _, pair := range identityPairs { labels = append(labels, riskrel.ComponentDefinitionLabel{ + DefinedComponentID: dcID, ComponentDefinitionID: cdID, Key: pair.Key, Value: pair.Value, diff --git a/internal/service/relational/templates/subject_template_service_test.go b/internal/service/relational/templates/subject_template_service_test.go index 8e6ec01e..ec04c637 100644 --- a/internal/service/relational/templates/subject_template_service_test.go +++ b/internal/service/relational/templates/subject_template_service_test.go @@ -832,6 +832,10 @@ func TestSubjectTemplateService_ResolveOrUpsertComponentDefinitionGroupsByPlugin var dcCount int64 require.NoError(t, db.Table("defined_components").Count(&dcCount).Error) require.Equal(t, int64(2), dcCount) + + var cd relational.ComponentDefinition + require.NoError(t, db.Preload("Metadata").First(&cd).Error) + require.Equal(t, "github components", cd.Metadata.Title) } func TestSubjectTemplateService_ResolveOrUpsertComponentDefinitionNoPlugin(t *testing.T) { From 19a28b35e92bb546b4d6b6b20e6f47ab4a35220d Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Tue, 10 Mar 2026 06:58:39 -0300 Subject: [PATCH 5/6] fix: copilot comments Signed-off-by: Gustavo Carvalho --- .../service/worker/risk_evidence_worker.go | 13 +++++-- .../worker/risk_evidence_worker_test.go | 36 +++++++++++++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/internal/service/worker/risk_evidence_worker.go b/internal/service/worker/risk_evidence_worker.go index 78224a8e..876a561d 100644 --- a/internal/service/worker/risk_evidence_worker.go +++ b/internal/service/worker/risk_evidence_worker.go @@ -194,9 +194,11 @@ func (w *RiskEvidenceWorker) extractViolationIDs(labels []relational.Labels) []s var violationIDs []string for _, label := range labels { + labelName := strings.ToLower(strings.TrimSpace(label.Name)) + labelValue := strings.TrimSpace(label.Value) // Accept both current and legacy violation label names. - if (label.Name == "_violation_id" || label.Name == "violation_id") && label.Value != "" { - violationIDs = append(violationIDs, label.Value) + if (labelName == "_violation_id" || labelName == "violation_id") && labelValue != "" { + violationIDs = append(violationIDs, labelValue) } } @@ -455,6 +457,13 @@ func (w *RiskEvidenceWorker) createNewRiskForSSP(ctx context.Context, riskTempla // Uses OnConflict{DoNothing} throughout so retries are idempotent. func (w *RiskEvidenceWorker) createRiskLinks(ctx context.Context, db *gorm.DB, riskID uuid.UUID, evidence *relational.Evidence) error { now := time.Now().UTC() + if evidence.UUID == uuid.Nil { + evidenceID := uuid.Nil + if evidence.ID != nil { + evidenceID = *evidence.ID + } + return fmt.Errorf("evidence %s is missing stream uuid", evidenceID) + } // Link evidence evidenceLink := &risks.RiskEvidenceLink{ diff --git a/internal/service/worker/risk_evidence_worker_test.go b/internal/service/worker/risk_evidence_worker_test.go index f4651460..42d1b781 100644 --- a/internal/service/worker/risk_evidence_worker_test.go +++ b/internal/service/worker/risk_evidence_worker_test.go @@ -730,15 +730,18 @@ func TestRiskEvidenceWorker_extractViolationIDs(t *testing.T) { labels := []relational.Labels{ {Name: "violation_id", Value: "VIOL-001"}, + {Name: " Violation_ID ", Value: "VIOL-002"}, + {Name: " _VIOLATION_ID ", Value: "VIOL-003"}, {Name: "other_label", Value: "value"}, - {Name: "violation_id", Value: "VIOL-002"}, + {Name: "violation_id", Value: " "}, } violationIDs := worker.extractViolationIDs(labels) - assert.Len(t, violationIDs, 2) + assert.Len(t, violationIDs, 3) assert.Contains(t, violationIDs, "VIOL-001") assert.Contains(t, violationIDs, "VIOL-002") + assert.Contains(t, violationIDs, "VIOL-003") } func TestRiskEvidenceWorker_violationMatches(t *testing.T) { @@ -957,6 +960,35 @@ func TestRiskEvidenceWorker_createRiskLinks_NoSubjectsOrComponents(t *testing.T) assert.Equal(t, int64(0), subjectLinkCount) } +func TestRiskEvidenceWorker_createRiskLinks_MissingEvidenceStreamUUID(t *testing.T) { + t.Parallel() + + worker := createTestRiskEvidenceWorker(t) + ctx := context.Background() + + evidenceID := uuid.New() + evidence := &relational.Evidence{ + UUIDModel: relational.UUIDModel{ID: &evidenceID}, + UUID: uuid.Nil, + Title: "invalid evidence", + Start: time.Now().Add(-1 * time.Hour), + End: time.Now(), + } + require.NoError(t, worker.db.Create(evidence).Error) + + riskID := uuid.New() + err := worker.createRiskLinks(ctx, worker.db, riskID, evidence) + require.Error(t, err) + require.ErrorContains(t, err, "missing stream uuid") + + var evidenceLinkCount int64 + require.NoError(t, worker.db.WithContext(ctx). + Model(&risks.RiskEvidenceLink{}). + Where("risk_id = ?", riskID). + Count(&evidenceLinkCount).Error) + assert.Zero(t, evidenceLinkCount) +} + func TestRiskEvidenceWorker_emitRiskEvent(t *testing.T) { t.Parallel() From 2be59a4aef7a2b1ff47405a5f3855ec800c6a02f Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Tue, 10 Mar 2026 07:15:37 -0300 Subject: [PATCH 6/6] fix: copilot comments Signed-off-by: Gustavo Carvalho --- .../relational/templates/subject_template_service.go | 9 +++++++++ internal/service/worker/risk_evidence_worker.go | 4 ++-- internal/service/worker/risk_evidence_worker_test.go | 3 +-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/internal/service/relational/templates/subject_template_service.go b/internal/service/relational/templates/subject_template_service.go index 94c8c3d2..6cee990b 100644 --- a/internal/service/relational/templates/subject_template_service.go +++ b/internal/service/relational/templates/subject_template_service.go @@ -897,6 +897,15 @@ func (s *SubjectTemplateService) resolveOrCreateComponentDefinition(template Sub // Upsert metadata separately so repeated calls do not create duplicate polymorphic metadata rows. parentID := cdID.String() parentType := "component_definitions" + + // Serialize metadata upsert per ComponentDefinition to avoid duplicate metadata rows + // when concurrent requests resolve the same plugin-scoped ComponentDefinition. + var lockedCD relational.ComponentDefinition + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&lockedCD, "id = ?", cdID).Error; err != nil { + tx.Rollback() + return nil, err + } + var existingMetadata relational.Metadata metadataQuery := tx.Model(&relational.Metadata{}).Where("parent_id = ? AND parent_type = ?", parentID, parentType) if err := metadataQuery.First(&existingMetadata).Error; err != nil { diff --git a/internal/service/worker/risk_evidence_worker.go b/internal/service/worker/risk_evidence_worker.go index 876a561d..f0c59f9f 100644 --- a/internal/service/worker/risk_evidence_worker.go +++ b/internal/service/worker/risk_evidence_worker.go @@ -65,7 +65,7 @@ func (w *RiskEvidenceWorker) Work(ctx context.Context, job *river.Job[RiskProces } // 3. Violation Filtering: Filter the risk templates by checking the fired violation.id against risk_template.violation_ids - filteredRiskTemplates, err := w.filterRiskTemplatesByViolations(ctx, riskTemplates, evidence.Labels) + filteredRiskTemplates, err := w.filterRiskTemplatesByViolations(riskTemplates, evidence.Labels) if err != nil { w.logger.Errorw("Failed to filter risk templates by violations", "error", err, "evidence_id", args.EvidenceID) return err @@ -167,7 +167,7 @@ func (w *RiskEvidenceWorker) loadRiskTemplates(ctx context.Context, evidenceLabe } // filterRiskTemplatesByViolations filters risk templates based on violation IDs in evidence labels -func (w *RiskEvidenceWorker) filterRiskTemplatesByViolations(ctx context.Context, riskTemplates []templates.RiskTemplate, evidenceLabels []relational.Labels) ([]templates.RiskTemplate, error) { +func (w *RiskEvidenceWorker) filterRiskTemplatesByViolations(riskTemplates []templates.RiskTemplate, evidenceLabels []relational.Labels) ([]templates.RiskTemplate, error) { // Extract violation IDs from evidence labels violationIDs := w.extractViolationIDs(evidenceLabels) diff --git a/internal/service/worker/risk_evidence_worker_test.go b/internal/service/worker/risk_evidence_worker_test.go index 42d1b781..0a75efeb 100644 --- a/internal/service/worker/risk_evidence_worker_test.go +++ b/internal/service/worker/risk_evidence_worker_test.go @@ -688,7 +688,6 @@ func TestRiskEvidenceWorker_filterRiskTemplatesByViolations(t *testing.T) { t.Parallel() worker := createTestRiskEvidenceWorker(t) - ctx := context.Background() // Create risk templates template1 := &templates.RiskTemplate{ @@ -717,7 +716,7 @@ func TestRiskEvidenceWorker_filterRiskTemplatesByViolations(t *testing.T) { } // Filter templates - filtered, err := worker.filterRiskTemplatesByViolations(ctx, riskTemplates, evidenceLabels) + filtered, err := worker.filterRiskTemplatesByViolations(riskTemplates, evidenceLabels) assert.NoError(t, err) assert.Len(t, filtered, 2) // template1 and template3 should match