diff --git a/cmd/seed/lineage.go b/cmd/seed/lineage.go new file mode 100644 index 00000000..f67f6e4e --- /dev/null +++ b/cmd/seed/lineage.go @@ -0,0 +1,211 @@ +package seed + +import ( + "context" + "log" + "time" + + "github.com/compliance-framework/api/internal/config" + "github.com/compliance-framework/api/internal/service" + "github.com/compliance-framework/api/internal/service/relational" + "github.com/google/uuid" + "github.com/spf13/cobra" + "go.uber.org/zap" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// Fixed ids keep the seed idempotent: re-running never duplicates the demo catalogs. +var ( + demoPolicyCatalogID = uuid.MustParse("aaaa0000-0000-4000-8000-000000000001") + demoProcedureCatalogID = uuid.MustParse("aaaa0000-0000-4000-8000-000000000002") +) + +func newLineageCMD() *cobra.Command { + return &cobra.Command{ + Use: "lineage", + Short: "Seeds demo Policy/Procedure catalogs and control links for the Compliance Lineage PoC", + Run: seedLineage, + } +} + +func seedLineage(_ *cobra.Command, _ []string) { + zapLogger, err := zap.NewProduction() + if err != nil { + log.Fatalf("Can't initialize zap logger: %v", err) + } + sugar := zapLogger.Sugar() + defer func() { _ = zapLogger.Sync() }() + + cmdConfig := config.NewConfig(sugar) + db, err := service.ConnectSQLDb(context.Background(), cmdConfig, sugar) + if err != nil { + log.Fatalf("failed to connect database: %v", err) + } + + // Pick the standard catalog local-dev loads: the first standard-type catalog + // that has controls to anchor the demo policies to. + standardCatID, standardControls, err := firstStandardCatalogWithControls(db) + if err != nil { + log.Fatalf("failed to locate a standard catalog with controls: %v", err) + } + if len(standardControls) < 2 { + log.Fatalf("standard catalog %s has fewer than 2 controls; cannot build a meaningful demo", standardCatID) + } + sugar.Infow("seeding lineage against standard catalog", "catalogId", standardCatID, "controls", len(standardControls)) + + now := time.Now().UTC() + + // 1) Policy catalog with three policy controls. + policyCatalog := demoCatalog(demoPolicyCatalogID, relational.CatalogTypePolicy, "Access Control Policy", now, []demoControl{ + {ID: "pol-ac", Title: "Access Control Policy Statement"}, + {ID: "pol-ac-accounts", Title: "Account Management Policy"}, + {ID: "pol-ac-least-priv", Title: "Least Privilege Policy"}, + }) + // 2) Procedure catalog with two procedure controls. + procedureCatalog := demoCatalog(demoProcedureCatalogID, relational.CatalogTypeProcedure, "Access Control Procedures", now, []demoControl{ + {ID: "proc-ac-onboard", Title: "Account Onboarding Procedure"}, + {ID: "proc-ac-review", Title: "Access Review Procedure"}, + }) + + if err := createCatalogIfAbsent(db, &policyCatalog); err != nil { + log.Fatalf("failed to create policy catalog: %v", err) + } + if err := createCatalogIfAbsent(db, &procedureCatalog); err != nil { + log.Fatalf("failed to create procedure catalog: %v", err) + } + + polAC := relational.ControlRef{CatalogID: demoPolicyCatalogID, ControlID: "pol-ac"} + polAccounts := relational.ControlRef{CatalogID: demoPolicyCatalogID, ControlID: "pol-ac-accounts"} + procOnboard := relational.ControlRef{CatalogID: demoProcedureCatalogID, ControlID: "proc-ac-onboard"} + + links := []relational.ControlLink{ + // Policy controls implement a couple of standard controls (policy -> standard). + implementsEdge(polAC, standardControls[0]), + implementsEdge(polAccounts, standardControls[1]), + // A procedure documents a policy control (procedure -> policy). + documentsEdge(procOnboard, polAC), + } + + // Operational controls implement the policy controls (operational -> policy), so + // evidence/risk on those standard controls rolls UP into the policy. Prefer a + // control that already carries an open risk so at least one policy shows heat. + if riskyControl, ok := controlWithOpenRisk(db); ok { + links = append(links, implementsEdge(riskyControl, polAC)) + sugar.Infow("linked an open-risk operational control to a policy for demo heat", "control", riskyControl) + } + // Add a second operational implementer from the standard catalog for structure. + if len(standardControls) >= 3 { + links = append(links, implementsEdge(standardControls[2], polAccounts)) + } + + res := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&links) + if res.Error != nil { + log.Fatalf("failed to create control links: %v", res.Error) + } + sugar.Infow("lineage seed complete", "linksSubmitted", len(links), "linksCreated", res.RowsAffected) +} + +type demoControl struct { + ID string + Title string +} + +func demoCatalog(id uuid.UUID, catalogType, title string, now time.Time, controls []demoControl) relational.Catalog { + relControls := make([]relational.Control, 0, len(controls)) + for _, c := range controls { + relControls = append(relControls, relational.Control{ + CatalogID: id, + ID: c.ID, + Title: c.Title, + }) + } + return relational.Catalog{ + UUIDModel: relational.UUIDModel{ID: &id}, + CatalogType: catalogType, + Metadata: relational.Metadata{ + Title: title, + Version: "1.0.0", + OscalVersion: "1.1.3", + LastModified: &now, + }, + Controls: relControls, + } +} + +func createCatalogIfAbsent(db *gorm.DB, catalog *relational.Catalog) error { + var count int64 + if err := db.Model(&relational.Catalog{}).Where("id = ?", catalog.ID).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return nil + } + return db.Create(catalog).Error +} + +func firstStandardCatalogWithControls(db *gorm.DB) (uuid.UUID, []relational.ControlRef, error) { + var catalogs []relational.Catalog + if err := db. + Where("catalog_type = ? OR catalog_type IS NULL OR catalog_type = ''", relational.CatalogTypeStandard). + Preload("Controls"). + Order("id"). + Find(&catalogs).Error; err != nil { + return uuid.Nil, nil, err + } + for _, cat := range catalogs { + if cat.ID == nil || len(cat.Controls) == 0 { + continue + } + // Skip the demo catalogs themselves. + if *cat.ID == demoPolicyCatalogID || *cat.ID == demoProcedureCatalogID { + continue + } + refs := make([]relational.ControlRef, 0, len(cat.Controls)) + for _, c := range cat.Controls { + refs = append(refs, relational.ControlRef{CatalogID: *cat.ID, ControlID: c.ID}) + } + return *cat.ID, refs, nil + } + return uuid.Nil, nil, gorm.ErrRecordNotFound +} + +// controlWithOpenRisk returns a control that has an open (heat-bearing) risk linked, +// so the demo policy it implements shows a non-zero risk score sum. +func controlWithOpenRisk(db *gorm.DB) (relational.ControlRef, bool) { + var row struct { + CatalogID uuid.UUID `gorm:"column:catalog_id"` + ControlID string `gorm:"column:control_id"` + } + err := db. + Table("risk_control_links rcl"). + Select("rcl.catalog_id, rcl.control_id"). + Joins("JOIN risk_register_risks r ON r.id = rcl.risk_id"). + Where("r.status IN ?", []string{"open", "investigating", "mitigating-planned"}). + Limit(1). + Scan(&row).Error + if err != nil || row.ControlID == "" { + return relational.ControlRef{}, false + } + return relational.ControlRef{CatalogID: row.CatalogID, ControlID: row.ControlID}, true +} + +func implementsEdge(source, target relational.ControlRef) relational.ControlLink { + return relational.ControlLink{ + SourceCatalogID: source.CatalogID, + SourceControlID: source.ControlID, + TargetCatalogID: target.CatalogID, + TargetControlID: target.ControlID, + RelationshipType: relational.RelationshipImplements, + } +} + +func documentsEdge(source, target relational.ControlRef) relational.ControlLink { + return relational.ControlLink{ + SourceCatalogID: source.CatalogID, + SourceControlID: source.ControlID, + TargetCatalogID: target.CatalogID, + TargetControlID: target.ControlID, + RelationshipType: relational.RelationshipDocuments, + } +} diff --git a/cmd/seed/seed.go b/cmd/seed/seed.go index f71d25fe..b0b3cf3c 100644 --- a/cmd/seed/seed.go +++ b/cmd/seed/seed.go @@ -15,4 +15,5 @@ func init() { RootCmd.AddCommand(newHeartbeatCMD()) RootCmd.AddCommand(newEvidenceCMD()) RootCmd.AddCommand(newWorkflowsCMD()) + RootCmd.AddCommand(newLineageCMD()) } diff --git a/docs/docs.go b/docs/docs.go index 832543f5..d750f156 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -2916,6 +2916,241 @@ const docTemplate = `{ } } }, + "/control-links": { + "get": { + "description": "Lists typed control-to-control links, filterable by either endpoint, paginated.", + "produces": [ + "application/json" + ], + "tags": [ + "ControlLink" + ], + "summary": "List control links", + "parameters": [ + { + "type": "string", + "description": "Filter by source catalog id", + "name": "sourceCatalogId", + "in": "query" + }, + { + "type": "string", + "description": "Filter by source control id", + "name": "sourceControlId", + "in": "query" + }, + { + "type": "string", + "description": "Filter by target catalog id", + "name": "targetCatalogId", + "in": "query" + }, + { + "type": "string", + "description": "Filter by target control id", + "name": "targetControlId", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/service.ListResponse-relational_ControlLink" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "post": { + "description": "Creates one typed control-to-control link after validating endpoint existence, the relationship vocabulary matrix, and acyclicity.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ControlLink" + ], + "summary": "Create a control link", + "parameters": [ + { + "description": "Control link", + "name": "link", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.createControlLinkRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-relational_ControlLink" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "delete": { + "description": "Deletes the control link identified by its full composite key (all query params required).", + "tags": [ + "ControlLink" + ], + "summary": "Delete a control link", + "parameters": [ + { + "type": "string", + "description": "Source catalog id", + "name": "sourceCatalogId", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Source control id", + "name": "sourceControlId", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Target catalog id", + "name": "targetCatalogId", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Target control id", + "name": "targetControlId", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Relationship type", + "name": "relationshipType", + "in": "query", + "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" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/control-links/bulk": { + "post": { + "description": "Idempotently upserts many control links (ON CONFLICT DO NOTHING). Invalid vocabulary/existence rejects the batch; cycles and duplicates are skipped.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ControlLink" + ], + "summary": "Bulk create control links", + "parameters": [ + { + "description": "Control links", + "name": "links", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.bulkControlLinkRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_bulkControlLinkResponse" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/dashboard-suggestions/config": { "get": { "description": "Returns whether AI dashboard suggestions are enabled.", @@ -3897,6 +4132,139 @@ const docTemplate = `{ } } }, + "/lineage/nodes/{key}/children": { + "get": { + "description": "Returns one level of children for a node. Every node carries full-subtree rollup metrics. Key is a composite like catalog:\u003cuuid\u003e, group:\u003ccatalogId\u003e/\u003cgroupId\u003e, control:\u003ccatalogId\u003e/\u003ccontrolId\u003e.", + "produces": [ + "application/json" + ], + "tags": [ + "Lineage" + ], + "summary": "List lineage node children", + "parameters": [ + { + "type": "string", + "description": "URL-encoded node key", + "name": "key", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Scope metrics to a System Security Plan", + "name": "sspId", + "in": "query" + }, + { + "type": "string", + "description": "Scope metrics to a system component", + "name": "componentId", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (default 100)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/service.ListResponse-handler_LineageNode" + } + }, + "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": [] + } + ] + } + }, + "/lineage/roots": { + "get": { + "description": "Returns catalog roots (standard/policy/procedure) with full-subtree compliance and risk rollups. Rootness is catalog_type, never link presence.", + "produces": [ + "application/json" + ], + "tags": [ + "Lineage" + ], + "summary": "List lineage roots", + "parameters": [ + { + "type": "string", + "description": "Scope metrics to a System Security Plan", + "name": "sspId", + "in": "query" + }, + { + "type": "string", + "description": "Scope metrics to a system component", + "name": "componentId", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated catalog types to include (standard,policy,procedure)", + "name": "types", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/service.ListResponse-handler_LineageNode" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/notifications/providers": { "get": { "description": "Returns notification provider availability for authenticated users", @@ -8848,7 +9216,7 @@ const docTemplate = `{ }, "/oscal/catalogs": { "get": { - "description": "Retrieves all catalogs.", + "description": "Retrieves all catalogs, optionally filtered by catalog type.", "produces": [ "application/json" ], @@ -8856,6 +9224,14 @@ const docTemplate = `{ "Catalog" ], "summary": "List catalogs", + "parameters": [ + { + "type": "string", + "description": "Filter by catalog type (standard|policy|procedure)", + "name": "type", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -8883,7 +9259,7 @@ const docTemplate = `{ ] }, "post": { - "description": "Creates a new OSCAL Catalog.", + "description": "Creates a new OSCAL Catalog. The catalog type may be supplied via the ?type= query param or a catalog-type metadata prop; it defaults to standard.", "consumes": [ "application/json" ], @@ -8903,6 +9279,12 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/oscalTypes_1_1_3.Catalog" } + }, + { + "type": "string", + "description": "Catalog type (standard|policy|procedure); overrides any metadata prop", + "name": "type", + "in": "query" } ], "responses": { @@ -30780,6 +31162,19 @@ const docTemplate = `{ } } }, + "handler.GenericDataResponse-handler_bulkControlLinkResponse": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/handler.bulkControlLinkResponse" + } + ] + } + } + }, "handler.GenericDataResponse-handler_configuredSystemDestinationResponse": { "type": "object", "properties": { @@ -31820,6 +32215,19 @@ const docTemplate = `{ } } }, + "handler.GenericDataResponse-relational_ControlLink": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/relational.ControlLink" + } + ] + } + } + }, "handler.GenericDataResponse-relational_Filter": { "type": "object", "properties": { @@ -31951,6 +32359,121 @@ const docTemplate = `{ } } }, + "handler.LineageCompliance": { + "type": "object", + "properties": { + "assessedPercent": { + "type": "number" + }, + "compliancePercent": { + "type": "number" + }, + "notSatisfied": { + "type": "integer" + }, + "satisfied": { + "type": "integer" + }, + "totalControls": { + "type": "integer" + }, + "unknown": { + "type": "integer" + } + } + }, + "handler.LineageLinkage": { + "type": "object", + "properties": { + "operationalControls": { + "type": "integer" + }, + "policies": { + "type": "integer" + }, + "procedures": { + "type": "integer" + }, + "unanchored": { + "type": "boolean" + }, + "unmapped": { + "type": "boolean" + } + } + }, + "handler.LineageNode": { + "type": "object", + "properties": { + "catalogId": { + "type": "string" + }, + "childrenCount": { + "type": "integer" + }, + "compliance": { + "$ref": "#/definitions/handler.LineageCompliance" + }, + "controlId": { + "type": "string" + }, + "groupId": { + "type": "string" + }, + "hasChildren": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "linkage": { + "$ref": "#/definitions/handler.LineageLinkage" + }, + "nodeType": { + "type": "string" + }, + "risk": { + "$ref": "#/definitions/handler.LineageRisk" + }, + "title": { + "type": "string" + } + } + }, + "handler.LineageRisk": { + "type": "object", + "properties": { + "counts": { + "$ref": "#/definitions/handler.LineageRiskCounts" + }, + "mutedScoreSum": { + "type": "integer" + }, + "openScoreSum": { + "type": "integer" + } + } + }, + "handler.LineageRiskCounts": { + "type": "object", + "properties": { + "investigating": { + "type": "integer" + }, + "mitigatingImplemented": { + "type": "integer" + }, + "mitigatingPlanned": { + "type": "integer" + }, + "open": { + "type": "integer" + }, + "riskAccepted": { + "type": "integer" + } + } + }, "handler.OverTime.HeartbeatInterval": { "type": "object", "properties": { @@ -32175,6 +32698,28 @@ const docTemplate = `{ } } }, + "handler.bulkControlLinkRequest": { + "type": "object", + "properties": { + "links": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.createControlLinkRequest" + } + } + } + }, + "handler.bulkControlLinkResponse": { + "type": "object", + "properties": { + "created": { + "type": "integer" + }, + "skipped": { + "type": "integer" + } + } + }, "handler.configuredSystemDestinationResponse": { "type": "object", "properties": { @@ -32203,6 +32748,31 @@ const docTemplate = `{ } } }, + "handler.controlRefRequest": { + "type": "object", + "properties": { + "catalogId": { + "type": "string" + }, + "controlId": { + "type": "string" + } + } + }, + "handler.createControlLinkRequest": { + "type": "object", + "properties": { + "relationshipType": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/handler.controlRefRequest" + }, + "target": { + "$ref": "#/definitions/handler.controlRefRequest" + } + } + }, "handler.createFilterRequest": { "type": "object", "required": [ @@ -40085,6 +40655,32 @@ const docTemplate = `{ } } }, + "relational.ControlLink": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "createdById": { + "type": "string" + }, + "relationshipType": { + "type": "string" + }, + "sourceCatalogId": { + "type": "string" + }, + "sourceControlId": { + "type": "string" + }, + "targetCatalogId": { + "type": "string" + }, + "targetControlId": { + "type": "string" + } + } + }, "relational.ControlObjectiveSelection": { "type": "object", "properties": { @@ -42687,6 +43283,29 @@ const docTemplate = `{ } } }, + "service.ListResponse-handler_LineageNode": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.LineageNode" + } + }, + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, "service.ListResponse-handler_PublicEvidenceResponse": { "type": "object", "properties": { @@ -42779,6 +43398,29 @@ const docTemplate = `{ } } }, + "service.ListResponse-relational_ControlLink": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/relational.ControlLink" + } + }, + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, "service.ListResponse-risks_RiskComponentLink": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 43bfd4cf..d7789f86 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2910,6 +2910,241 @@ } } }, + "/control-links": { + "get": { + "description": "Lists typed control-to-control links, filterable by either endpoint, paginated.", + "produces": [ + "application/json" + ], + "tags": [ + "ControlLink" + ], + "summary": "List control links", + "parameters": [ + { + "type": "string", + "description": "Filter by source catalog id", + "name": "sourceCatalogId", + "in": "query" + }, + { + "type": "string", + "description": "Filter by source control id", + "name": "sourceControlId", + "in": "query" + }, + { + "type": "string", + "description": "Filter by target catalog id", + "name": "targetCatalogId", + "in": "query" + }, + { + "type": "string", + "description": "Filter by target control id", + "name": "targetControlId", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/service.ListResponse-relational_ControlLink" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "post": { + "description": "Creates one typed control-to-control link after validating endpoint existence, the relationship vocabulary matrix, and acyclicity.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ControlLink" + ], + "summary": "Create a control link", + "parameters": [ + { + "description": "Control link", + "name": "link", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.createControlLinkRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-relational_ControlLink" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + }, + "delete": { + "description": "Deletes the control link identified by its full composite key (all query params required).", + "tags": [ + "ControlLink" + ], + "summary": "Delete a control link", + "parameters": [ + { + "type": "string", + "description": "Source catalog id", + "name": "sourceCatalogId", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Source control id", + "name": "sourceControlId", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Target catalog id", + "name": "targetCatalogId", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Target control id", + "name": "targetControlId", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Relationship type", + "name": "relationshipType", + "in": "query", + "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" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/control-links/bulk": { + "post": { + "description": "Idempotently upserts many control links (ON CONFLICT DO NOTHING). Invalid vocabulary/existence rejects the batch; cycles and duplicates are skipped.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ControlLink" + ], + "summary": "Bulk create control links", + "parameters": [ + { + "description": "Control links", + "name": "links", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.bulkControlLinkRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_bulkControlLinkResponse" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/dashboard-suggestions/config": { "get": { "description": "Returns whether AI dashboard suggestions are enabled.", @@ -3891,6 +4126,139 @@ } } }, + "/lineage/nodes/{key}/children": { + "get": { + "description": "Returns one level of children for a node. Every node carries full-subtree rollup metrics. Key is a composite like catalog:\u003cuuid\u003e, group:\u003ccatalogId\u003e/\u003cgroupId\u003e, control:\u003ccatalogId\u003e/\u003ccontrolId\u003e.", + "produces": [ + "application/json" + ], + "tags": [ + "Lineage" + ], + "summary": "List lineage node children", + "parameters": [ + { + "type": "string", + "description": "URL-encoded node key", + "name": "key", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Scope metrics to a System Security Plan", + "name": "sspId", + "in": "query" + }, + { + "type": "string", + "description": "Scope metrics to a system component", + "name": "componentId", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (default 100)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/service.ListResponse-handler_LineageNode" + } + }, + "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": [] + } + ] + } + }, + "/lineage/roots": { + "get": { + "description": "Returns catalog roots (standard/policy/procedure) with full-subtree compliance and risk rollups. Rootness is catalog_type, never link presence.", + "produces": [ + "application/json" + ], + "tags": [ + "Lineage" + ], + "summary": "List lineage roots", + "parameters": [ + { + "type": "string", + "description": "Scope metrics to a System Security Plan", + "name": "sspId", + "in": "query" + }, + { + "type": "string", + "description": "Scope metrics to a system component", + "name": "componentId", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated catalog types to include (standard,policy,procedure)", + "name": "types", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/service.ListResponse-handler_LineageNode" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/notifications/providers": { "get": { "description": "Returns notification provider availability for authenticated users", @@ -8842,7 +9210,7 @@ }, "/oscal/catalogs": { "get": { - "description": "Retrieves all catalogs.", + "description": "Retrieves all catalogs, optionally filtered by catalog type.", "produces": [ "application/json" ], @@ -8850,6 +9218,14 @@ "Catalog" ], "summary": "List catalogs", + "parameters": [ + { + "type": "string", + "description": "Filter by catalog type (standard|policy|procedure)", + "name": "type", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -8877,7 +9253,7 @@ ] }, "post": { - "description": "Creates a new OSCAL Catalog.", + "description": "Creates a new OSCAL Catalog. The catalog type may be supplied via the ?type= query param or a catalog-type metadata prop; it defaults to standard.", "consumes": [ "application/json" ], @@ -8897,6 +9273,12 @@ "schema": { "$ref": "#/definitions/oscalTypes_1_1_3.Catalog" } + }, + { + "type": "string", + "description": "Catalog type (standard|policy|procedure); overrides any metadata prop", + "name": "type", + "in": "query" } ], "responses": { @@ -30774,6 +31156,19 @@ } } }, + "handler.GenericDataResponse-handler_bulkControlLinkResponse": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/handler.bulkControlLinkResponse" + } + ] + } + } + }, "handler.GenericDataResponse-handler_configuredSystemDestinationResponse": { "type": "object", "properties": { @@ -31814,6 +32209,19 @@ } } }, + "handler.GenericDataResponse-relational_ControlLink": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/relational.ControlLink" + } + ] + } + } + }, "handler.GenericDataResponse-relational_Filter": { "type": "object", "properties": { @@ -31945,6 +32353,121 @@ } } }, + "handler.LineageCompliance": { + "type": "object", + "properties": { + "assessedPercent": { + "type": "number" + }, + "compliancePercent": { + "type": "number" + }, + "notSatisfied": { + "type": "integer" + }, + "satisfied": { + "type": "integer" + }, + "totalControls": { + "type": "integer" + }, + "unknown": { + "type": "integer" + } + } + }, + "handler.LineageLinkage": { + "type": "object", + "properties": { + "operationalControls": { + "type": "integer" + }, + "policies": { + "type": "integer" + }, + "procedures": { + "type": "integer" + }, + "unanchored": { + "type": "boolean" + }, + "unmapped": { + "type": "boolean" + } + } + }, + "handler.LineageNode": { + "type": "object", + "properties": { + "catalogId": { + "type": "string" + }, + "childrenCount": { + "type": "integer" + }, + "compliance": { + "$ref": "#/definitions/handler.LineageCompliance" + }, + "controlId": { + "type": "string" + }, + "groupId": { + "type": "string" + }, + "hasChildren": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "linkage": { + "$ref": "#/definitions/handler.LineageLinkage" + }, + "nodeType": { + "type": "string" + }, + "risk": { + "$ref": "#/definitions/handler.LineageRisk" + }, + "title": { + "type": "string" + } + } + }, + "handler.LineageRisk": { + "type": "object", + "properties": { + "counts": { + "$ref": "#/definitions/handler.LineageRiskCounts" + }, + "mutedScoreSum": { + "type": "integer" + }, + "openScoreSum": { + "type": "integer" + } + } + }, + "handler.LineageRiskCounts": { + "type": "object", + "properties": { + "investigating": { + "type": "integer" + }, + "mitigatingImplemented": { + "type": "integer" + }, + "mitigatingPlanned": { + "type": "integer" + }, + "open": { + "type": "integer" + }, + "riskAccepted": { + "type": "integer" + } + } + }, "handler.OverTime.HeartbeatInterval": { "type": "object", "properties": { @@ -32169,6 +32692,28 @@ } } }, + "handler.bulkControlLinkRequest": { + "type": "object", + "properties": { + "links": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.createControlLinkRequest" + } + } + } + }, + "handler.bulkControlLinkResponse": { + "type": "object", + "properties": { + "created": { + "type": "integer" + }, + "skipped": { + "type": "integer" + } + } + }, "handler.configuredSystemDestinationResponse": { "type": "object", "properties": { @@ -32197,6 +32742,31 @@ } } }, + "handler.controlRefRequest": { + "type": "object", + "properties": { + "catalogId": { + "type": "string" + }, + "controlId": { + "type": "string" + } + } + }, + "handler.createControlLinkRequest": { + "type": "object", + "properties": { + "relationshipType": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/handler.controlRefRequest" + }, + "target": { + "$ref": "#/definitions/handler.controlRefRequest" + } + } + }, "handler.createFilterRequest": { "type": "object", "required": [ @@ -40079,6 +40649,32 @@ } } }, + "relational.ControlLink": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "createdById": { + "type": "string" + }, + "relationshipType": { + "type": "string" + }, + "sourceCatalogId": { + "type": "string" + }, + "sourceControlId": { + "type": "string" + }, + "targetCatalogId": { + "type": "string" + }, + "targetControlId": { + "type": "string" + } + } + }, "relational.ControlObjectiveSelection": { "type": "object", "properties": { @@ -42681,6 +43277,29 @@ } } }, + "service.ListResponse-handler_LineageNode": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.LineageNode" + } + }, + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, "service.ListResponse-handler_PublicEvidenceResponse": { "type": "object", "properties": { @@ -42773,6 +43392,29 @@ } } }, + "service.ListResponse-relational_ControlLink": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/relational.ControlLink" + } + }, + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, "service.ListResponse-risks_RiskComponentLink": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 5c0f3b67..9b0cc088 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1184,6 +1184,13 @@ definitions: - $ref: '#/definitions/handler.SubscriptionsResponse' description: Wrapped response data type: object + handler.GenericDataResponse-handler_bulkControlLinkResponse: + properties: + data: + allOf: + - $ref: '#/definitions/handler.bulkControlLinkResponse' + description: Wrapped response data + type: object handler.GenericDataResponse-handler_configuredSystemDestinationResponse: properties: data: @@ -1744,6 +1751,13 @@ definitions: - $ref: '#/definitions/relational.CCFRoleAssignment' description: Wrapped response data type: object + handler.GenericDataResponse-relational_ControlLink: + properties: + data: + allOf: + - $ref: '#/definitions/relational.ControlLink' + description: Wrapped response data + type: object handler.GenericDataResponse-relational_Filter: properties: data: @@ -1818,6 +1832,81 @@ definitions: - created_at - uuid type: object + handler.LineageCompliance: + properties: + assessedPercent: + type: number + compliancePercent: + type: number + notSatisfied: + type: integer + satisfied: + type: integer + totalControls: + type: integer + unknown: + type: integer + type: object + handler.LineageLinkage: + properties: + operationalControls: + type: integer + policies: + type: integer + procedures: + type: integer + unanchored: + type: boolean + unmapped: + type: boolean + type: object + handler.LineageNode: + properties: + catalogId: + type: string + childrenCount: + type: integer + compliance: + $ref: '#/definitions/handler.LineageCompliance' + controlId: + type: string + groupId: + type: string + hasChildren: + type: boolean + key: + type: string + linkage: + $ref: '#/definitions/handler.LineageLinkage' + nodeType: + type: string + risk: + $ref: '#/definitions/handler.LineageRisk' + title: + type: string + type: object + handler.LineageRisk: + properties: + counts: + $ref: '#/definitions/handler.LineageRiskCounts' + mutedScoreSum: + type: integer + openScoreSum: + type: integer + type: object + handler.LineageRiskCounts: + properties: + investigating: + type: integer + mitigatingImplemented: + type: integer + mitigatingPlanned: + type: integer + open: + type: integer + riskAccepted: + type: integer + type: object handler.OverTime.HeartbeatInterval: properties: interval: @@ -1968,6 +2057,20 @@ definitions: providerType: type: string type: object + handler.bulkControlLinkRequest: + properties: + links: + items: + $ref: '#/definitions/handler.createControlLinkRequest' + type: array + type: object + handler.bulkControlLinkResponse: + properties: + created: + type: integer + skipped: + type: integer + type: object handler.configuredSystemDestinationResponse: properties: destinationTarget: @@ -1986,6 +2089,22 @@ definitions: poam-item-id: type: string type: object + handler.controlRefRequest: + properties: + catalogId: + type: string + controlId: + type: string + type: object + handler.createControlLinkRequest: + properties: + relationshipType: + type: string + source: + $ref: '#/definitions/handler.controlRefRequest' + target: + $ref: '#/definitions/handler.controlRefRequest' + type: object handler.createFilterRequest: properties: components: @@ -7223,6 +7342,23 @@ definitions: description: required type: string type: object + relational.ControlLink: + properties: + createdAt: + type: string + createdById: + type: string + relationshipType: + type: string + sourceCatalogId: + type: string + sourceControlId: + type: string + targetCatalogId: + type: string + targetControlId: + type: string + type: object relational.ControlObjectiveSelection: properties: description: @@ -8978,6 +9114,21 @@ definitions: subjectId: type: string type: object + service.ListResponse-handler_LineageNode: + properties: + data: + items: + $ref: '#/definitions/handler.LineageNode' + type: array + limit: + type: integer + page: + type: integer + total: + type: integer + totalPages: + type: integer + type: object service.ListResponse-handler_PublicEvidenceResponse: properties: data: @@ -9038,6 +9189,21 @@ definitions: totalPages: type: integer type: object + service.ListResponse-relational_ControlLink: + properties: + data: + items: + $ref: '#/definitions/relational.ControlLink' + type: array + limit: + type: integer + page: + type: integer + total: + type: integer + totalPages: + type: integer + type: object service.ListResponse-risks_RiskComponentLink: properties: data: @@ -12599,6 +12765,162 @@ paths: summary: Get OAuth2 token tags: - Auth + /control-links: + delete: + description: Deletes the control link identified by its full composite key (all + query params required). + parameters: + - description: Source catalog id + in: query + name: sourceCatalogId + required: true + type: string + - description: Source control id + in: query + name: sourceControlId + required: true + type: string + - description: Target catalog id + in: query + name: targetCatalogId + required: true + type: string + - description: Target control id + in: query + name: targetControlId + required: true + type: string + - description: Relationship type + in: query + name: relationshipType + 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' + security: + - OAuth2Password: [] + summary: Delete a control link + tags: + - ControlLink + get: + description: Lists typed control-to-control links, filterable by either endpoint, + paginated. + parameters: + - description: Filter by source catalog id + in: query + name: sourceCatalogId + type: string + - description: Filter by source control id + in: query + name: sourceControlId + type: string + - description: Filter by target catalog id + in: query + name: targetCatalogId + type: string + - description: Filter by target control id + in: query + name: targetControlId + type: string + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/service.ListResponse-relational_ControlLink' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List control links + tags: + - ControlLink + post: + consumes: + - application/json + description: Creates one typed control-to-control link after validating endpoint + existence, the relationship vocabulary matrix, and acyclicity. + parameters: + - description: Control link + in: body + name: link + required: true + schema: + $ref: '#/definitions/handler.createControlLinkRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handler.GenericDataResponse-relational_ControlLink' + "409": + description: Conflict + schema: + $ref: '#/definitions/api.Error' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Create a control link + tags: + - ControlLink + /control-links/bulk: + post: + consumes: + - application/json + description: Idempotently upserts many control links (ON CONFLICT DO NOTHING). + Invalid vocabulary/existence rejects the batch; cycles and duplicates are + skipped. + parameters: + - description: Control links + in: body + name: links + required: true + schema: + $ref: '#/definitions/handler.bulkControlLinkRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_bulkControlLinkResponse' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Bulk create control links + tags: + - ControlLink /dashboard-suggestions/config: get: description: Returns whether AI dashboard suggestions are enabled. @@ -13253,6 +13575,94 @@ paths: summary: Import dashboard filters tags: - Filters + /lineage/nodes/{key}/children: + get: + description: Returns one level of children for a node. Every node carries full-subtree + rollup metrics. Key is a composite like catalog:, group:/, + control:/. + parameters: + - description: URL-encoded node key + in: path + name: key + required: true + type: string + - description: Scope metrics to a System Security Plan + in: query + name: sspId + type: string + - description: Scope metrics to a system component + in: query + name: componentId + type: string + - description: Page number + in: query + name: page + type: integer + - description: Page size (default 100) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/service.ListResponse-handler_LineageNode' + "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 lineage node children + tags: + - Lineage + /lineage/roots: + get: + description: Returns catalog roots (standard/policy/procedure) with full-subtree + compliance and risk rollups. Rootness is catalog_type, never link presence. + parameters: + - description: Scope metrics to a System Security Plan + in: query + name: sspId + type: string + - description: Scope metrics to a system component + in: query + name: componentId + type: string + - description: Comma-separated catalog types to include (standard,policy,procedure) + in: query + name: types + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/service.ListResponse-handler_LineageNode' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List lineage roots + tags: + - Lineage /notifications/providers: get: description: Returns notification provider availability for authenticated users @@ -16460,7 +16870,12 @@ paths: - Assessment Results /oscal/catalogs: get: - description: Retrieves all catalogs. + description: Retrieves all catalogs, optionally filtered by catalog type. + parameters: + - description: Filter by catalog type (standard|policy|procedure) + in: query + name: type + type: string produces: - application/json responses: @@ -16484,7 +16899,8 @@ paths: post: consumes: - application/json - description: Creates a new OSCAL Catalog. + description: Creates a new OSCAL Catalog. The catalog type may be supplied via + the ?type= query param or a catalog-type metadata prop; it defaults to standard. parameters: - description: Catalog object in: body @@ -16492,6 +16908,11 @@ paths: required: true schema: $ref: '#/definitions/oscalTypes_1_1_3.Catalog' + - description: Catalog type (standard|policy|procedure); overrides any metadata + prop + in: query + name: type + type: string produces: - application/json responses: diff --git a/internal/api/handler/api.go b/internal/api/handler/api.go index 7aaf17a9..3c780744 100644 --- a/internal/api/handler/api.go +++ b/internal/api/handler/api.go @@ -82,6 +82,17 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB filterGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) filterHandler.Register(filterGroup, pep.For(authz.ResourceFilter)) + // Policies & Procedures + Compliance Lineage. + controlLinkHandler := NewControlLinkHandler(logger, db) + controlLinkGroup := server.API().Group("/control-links") + controlLinkGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) + controlLinkHandler.Register(controlLinkGroup, pep.For(authz.ResourceControlLink)) + + lineageHandler := NewLineageHandler(logger, db) + lineageGroup := server.API().Group("/lineage") + lineageGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) + lineageHandler.Register(lineageGroup, pep.For(authz.ResourceLineage)) + heartbeatHandler := NewHeartbeatHandler(logger, db) heartbeatGuard := pep.For(authz.ResourceHeartbeat) agentIngestMiddleware := middleware.AgentJWTOrPublicMiddleware(db, config.JWTPublicKey, !config.StrictDisablePublicAgentEndpoints) diff --git a/internal/api/handler/control_links.go b/internal/api/handler/control_links.go new file mode 100644 index 00000000..d65a5232 --- /dev/null +++ b/internal/api/handler/control_links.go @@ -0,0 +1,373 @@ +package handler + +import ( + "errors" + "net/http" + + "github.com/compliance-framework/api/internal/api" + "github.com/compliance-framework/api/internal/api/middleware" + "github.com/compliance-framework/api/internal/authn" + svc "github.com/compliance-framework/api/internal/service" + "github.com/compliance-framework/api/internal/service/relational" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "go.uber.org/zap" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// ControlLinkHandler exposes CRUD over the control_links edge table: the typed +// links (implements/documents) that the lineage API walks. +type ControlLinkHandler struct { + sugar *zap.SugaredLogger + db *gorm.DB + pagination *svc.PaginationConfig +} + +func NewControlLinkHandler(l *zap.SugaredLogger, db *gorm.DB) *ControlLinkHandler { + return &ControlLinkHandler{ + sugar: l, + db: db, + pagination: svc.NewPaginationConfig(), + } +} + +func (h *ControlLinkHandler) Register(api *echo.Group, guard middleware.ResourceGuard) { + api.GET("", h.List, guard.Read()) + api.POST("", h.Create, guard.Create()) + api.POST("/bulk", h.BulkCreate, guard.Create()) + api.DELETE("", h.Delete, guard.Delete()) +} + +type controlRefRequest struct { + CatalogID uuid.UUID `json:"catalogId"` + ControlID string `json:"controlId"` +} + +func (r controlRefRequest) ref() relational.ControlRef { + return relational.ControlRef{CatalogID: r.CatalogID, ControlID: r.ControlID} +} + +type createControlLinkRequest struct { + Source controlRefRequest `json:"source"` + Target controlRefRequest `json:"target"` + RelationshipType string `json:"relationshipType"` +} + +type bulkControlLinkRequest struct { + Links []createControlLinkRequest `json:"links"` +} + +type bulkControlLinkResponse struct { + Created int `json:"created"` + Skipped int `json:"skipped"` +} + +// List godoc + +// @Summary List control links +// @Description Lists typed control-to-control links, filterable by either endpoint, paginated. +// @Tags ControlLink +// @Produce json +// @Param sourceCatalogId query string false "Filter by source catalog id" +// @Param sourceControlId query string false "Filter by source control id" +// @Param targetCatalogId query string false "Filter by target catalog id" +// @Param targetControlId query string false "Filter by target control id" +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} svc.ListResponse[relational.ControlLink] +// @Failure 400 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /control-links [get] +func (h *ControlLinkHandler) List(ctx echo.Context) error { + pagination, err := h.pagination.ParseParams(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + query := h.db.Model(&relational.ControlLink{}) + if v := ctx.QueryParam("sourceCatalogId"); v != "" { + id, parseErr := uuid.Parse(v) + if parseErr != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(parseErr)) + } + query = query.Where("source_catalog_id = ?", id) + } + if v := ctx.QueryParam("sourceControlId"); v != "" { + query = query.Where("source_control_id = ?", v) + } + if v := ctx.QueryParam("targetCatalogId"); v != "" { + id, parseErr := uuid.Parse(v) + if parseErr != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(parseErr)) + } + query = query.Where("target_catalog_id = ?", id) + } + if v := ctx.QueryParam("targetControlId"); v != "" { + query = query.Where("target_control_id = ?", v) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + h.sugar.Errorw("failed to count control links", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + links := []relational.ControlLink{} + if err := query. + Order("source_catalog_id, source_control_id, target_catalog_id, target_control_id, relationship_type"). + Limit(pagination.Limit). + Offset(pagination.Offset). + Find(&links).Error; err != nil { + h.sugar.Errorw("failed to list control links", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.JSON(http.StatusOK, svc.NewListResponse(links, total, pagination.Page, pagination.Limit)) +} + +// Create godoc + +// @Summary Create a control link +// @Description Creates one typed control-to-control link after validating endpoint existence, the relationship vocabulary matrix, and acyclicity. +// @Tags ControlLink +// @Accept json +// @Produce json +// @Param link body createControlLinkRequest true "Control link" +// @Success 201 {object} handler.GenericDataResponse[relational.ControlLink] +// @Failure 409 {object} api.Error +// @Failure 422 {object} api.Error +// @Security OAuth2Password +// @Router /control-links [post] +func (h *ControlLinkHandler) Create(ctx echo.Context) error { + var req createControlLinkRequest + if err := ctx.Bind(&req); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + // Vocabulary + existence validation (422 on any violation). + if err := h.validateLink(req); err != nil { + return ctx.JSON(http.StatusUnprocessableEntity, api.NewError(err)) + } + + source := req.Source.ref() + target := req.Target.ref() + + // Acyclicity: reject if target already reaches source (409). + edges, err := h.loadEdges() + if err != nil { + h.sugar.Errorw("failed to load control links for cycle check", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + if relational.NewControlLinkGraph(edges).WouldCreateCycle(source, target) { + return ctx.JSON(http.StatusConflict, api.NewError(errors.New("link would introduce a cycle in the control graph"))) + } + + // Duplicate composite key => 409. + var existing int64 + if err := h.db.Model(&relational.ControlLink{}). + Where("source_catalog_id = ? AND source_control_id = ? AND target_catalog_id = ? AND target_control_id = ? AND relationship_type = ?", + source.CatalogID, source.ControlID, target.CatalogID, target.ControlID, req.RelationshipType). + Count(&existing).Error; err != nil { + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + if existing > 0 { + return ctx.JSON(http.StatusConflict, api.NewError(errors.New("control link already exists"))) + } + + link := relational.ControlLink{ + SourceCatalogID: source.CatalogID, + SourceControlID: source.ControlID, + TargetCatalogID: target.CatalogID, + TargetControlID: target.ControlID, + RelationshipType: req.RelationshipType, + CreatedByID: actorUserID(ctx), + } + if err := h.db.Create(&link).Error; err != nil { + h.sugar.Errorw("failed to create control link", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.JSON(http.StatusCreated, GenericDataResponse[relational.ControlLink]{Data: link}) +} + +// BulkCreate godoc + +// @Summary Bulk create control links +// @Description Idempotently upserts many control links (ON CONFLICT DO NOTHING). Invalid vocabulary/existence rejects the batch; cycles and duplicates are skipped. +// @Tags ControlLink +// @Accept json +// @Produce json +// @Param links body bulkControlLinkRequest true "Control links" +// @Success 200 {object} handler.GenericDataResponse[bulkControlLinkResponse] +// @Failure 422 {object} api.Error +// @Security OAuth2Password +// @Router /control-links/bulk [post] +func (h *ControlLinkHandler) BulkCreate(ctx echo.Context) error { + var req bulkControlLinkRequest + if err := ctx.Bind(&req); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if len(req.Links) == 0 { + return ctx.JSON(http.StatusOK, GenericDataResponse[bulkControlLinkResponse]{Data: bulkControlLinkResponse{}}) + } + + // Validate every link up front; a bad vocabulary/existence fails the whole batch. + for _, l := range req.Links { + if err := h.validateLink(l); err != nil { + return ctx.JSON(http.StatusUnprocessableEntity, api.NewError(err)) + } + } + + edges, err := h.loadEdges() + if err != nil { + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + actor := actorUserID(ctx) + skipped := 0 + accepted := make([]relational.ControlLink, 0, len(req.Links)) + working := append([]relational.ControlLink(nil), edges...) + for _, l := range req.Links { + source := l.Source.ref() + target := l.Target.ref() + // Cycle check against existing edges plus everything accepted so far. + if relational.NewControlLinkGraph(working).WouldCreateCycle(source, target) { + skipped++ + continue + } + link := relational.ControlLink{ + SourceCatalogID: source.CatalogID, + SourceControlID: source.ControlID, + TargetCatalogID: target.CatalogID, + TargetControlID: target.ControlID, + RelationshipType: l.RelationshipType, + CreatedByID: actor, + } + accepted = append(accepted, link) + working = append(working, link) + } + + created := 0 + if len(accepted) > 0 { + res := h.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&accepted) + if res.Error != nil { + h.sugar.Errorw("failed to bulk create control links", "error", res.Error) + return ctx.JSON(http.StatusInternalServerError, api.NewError(res.Error)) + } + created = int(res.RowsAffected) + } + // Anything accepted but not newly inserted was an existing duplicate. + skipped += len(accepted) - created + + return ctx.JSON(http.StatusOK, GenericDataResponse[bulkControlLinkResponse]{ + Data: bulkControlLinkResponse{Created: created, Skipped: skipped}, + }) +} + +// Delete godoc + +// @Summary Delete a control link +// @Description Deletes the control link identified by its full composite key (all query params required). +// @Tags ControlLink +// @Param sourceCatalogId query string true "Source catalog id" +// @Param sourceControlId query string true "Source control id" +// @Param targetCatalogId query string true "Target catalog id" +// @Param targetControlId query string true "Target control id" +// @Param relationshipType query string true "Relationship type" +// @Success 204 +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Security OAuth2Password +// @Router /control-links [delete] +func (h *ControlLinkHandler) Delete(ctx echo.Context) error { + sourceCatalogID, err := uuid.Parse(ctx.QueryParam("sourceCatalogId")) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("valid sourceCatalogId is required"))) + } + targetCatalogID, err := uuid.Parse(ctx.QueryParam("targetCatalogId")) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("valid targetCatalogId is required"))) + } + sourceControlID := ctx.QueryParam("sourceControlId") + targetControlID := ctx.QueryParam("targetControlId") + relationshipType := ctx.QueryParam("relationshipType") + if sourceControlID == "" || targetControlID == "" || relationshipType == "" { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("sourceControlId, targetControlId and relationshipType are required"))) + } + + res := h.db. + Where("source_catalog_id = ? AND source_control_id = ? AND target_catalog_id = ? AND target_control_id = ? AND relationship_type = ?", + sourceCatalogID, sourceControlID, targetCatalogID, targetControlID, relationshipType). + Delete(&relational.ControlLink{}) + if res.Error != nil { + h.sugar.Errorw("failed to delete control link", "error", res.Error) + return ctx.JSON(http.StatusInternalServerError, api.NewError(res.Error)) + } + if res.RowsAffected == 0 { + return ctx.JSON(http.StatusNotFound, api.NewError(errors.New("control link not found"))) + } + return ctx.NoContent(http.StatusNoContent) +} + +// validateLink checks endpoint existence and the vocabulary matrix. A returned +// error is a validation failure the caller maps to 422. +func (h *ControlLinkHandler) validateLink(req createControlLinkRequest) error { + sourceType, err := h.resolveControlType(req.Source.ref()) + if err != nil { + return err + } + targetType, err := h.resolveControlType(req.Target.ref()) + if err != nil { + return err + } + return relational.ValidateRelationship(req.RelationshipType, sourceType, targetType) +} + +// resolveControlType verifies the control exists and returns its catalog's type. +func (h *ControlLinkHandler) resolveControlType(ref relational.ControlRef) (string, error) { + var cat relational.Catalog + if err := h.db.Select("id", "catalog_type").First(&cat, "id = ?", ref.CatalogID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", errors.New("catalog " + ref.CatalogID.String() + " does not exist") + } + return "", err + } + var count int64 + if err := h.db.Model(&relational.Control{}). + Where("catalog_id = ? AND id = ?", ref.CatalogID, ref.ControlID). + Count(&count).Error; err != nil { + return "", err + } + if count == 0 { + return "", errors.New("control " + ref.ControlID + " does not exist in catalog " + ref.CatalogID.String()) + } + catalogType := cat.CatalogType + if catalogType == "" { + catalogType = relational.CatalogTypeStandard + } + return catalogType, nil +} + +func (h *ControlLinkHandler) loadEdges() ([]relational.ControlLink, error) { + edges := []relational.ControlLink{} + if err := h.db.Find(&edges).Error; err != nil { + return nil, err + } + return edges, nil +} + +// actorUserID extracts the authenticated user's primary-key UUID from the JWT +// claims for CreatedByID attribution; nil when unavailable. +func actorUserID(ctx echo.Context) *uuid.UUID { + claims, ok := ctx.Get("user").(*authn.UserClaims) + if !ok || claims == nil || claims.UserUUID == "" { + return nil + } + id, err := uuid.Parse(claims.UserUUID) + if err != nil { + return nil + } + return &id +} diff --git a/internal/api/handler/lineage.go b/internal/api/handler/lineage.go new file mode 100644 index 00000000..7585e88b --- /dev/null +++ b/internal/api/handler/lineage.go @@ -0,0 +1,863 @@ +package handler + +import ( + "errors" + "math" + "net/http" + "net/url" + "sort" + "strings" + + "github.com/compliance-framework/api/internal/api" + "github.com/compliance-framework/api/internal/api/middleware" + "github.com/compliance-framework/api/internal/converters/labelfilter" + svc "github.com/compliance-framework/api/internal/service" + "github.com/compliance-framework/api/internal/service/relational" + riskrel "github.com/compliance-framework/api/internal/service/relational/risks" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// LineageHandler serves the read-only lineage API: it walks +// Standard -> Policy -> Controls -> Evidence (with Risks attached) and returns +// per-node compliance % and risk score sums, filterable by SSP and Component. +type LineageHandler struct { + sugar *zap.SugaredLogger + db *gorm.DB + pagination *svc.PaginationConfig +} + +func NewLineageHandler(l *zap.SugaredLogger, db *gorm.DB) *LineageHandler { + return &LineageHandler{ + sugar: l, + db: db, + pagination: svc.NewPaginationConfig(), + } +} + +func (h *LineageHandler) Register(api *echo.Group, guard middleware.ResourceGuard) { + api.GET("/roots", h.Roots, guard.Read()) + api.GET("/nodes/:key/children", h.Children, guard.Read()) +} + +// ── Response shapes ──────────────────────────────────────────────────────────── + +type LineageCompliance struct { + TotalControls int `json:"totalControls"` + Satisfied int `json:"satisfied"` + NotSatisfied int `json:"notSatisfied"` + Unknown int `json:"unknown"` + CompliancePercent float64 `json:"compliancePercent"` + AssessedPercent float64 `json:"assessedPercent"` +} + +type LineageRiskCounts struct { + Open int `json:"open"` + Investigating int `json:"investigating"` + MitigatingPlanned int `json:"mitigatingPlanned"` + RiskAccepted int `json:"riskAccepted"` + MitigatingImplemented int `json:"mitigatingImplemented"` +} + +type LineageRisk struct { + OpenScoreSum int `json:"openScoreSum"` + MutedScoreSum int `json:"mutedScoreSum"` + Counts LineageRiskCounts `json:"counts"` +} + +type LineageLinkage struct { + Policies int `json:"policies"` + Procedures int `json:"procedures"` + OperationalControls int `json:"operationalControls"` + Unmapped bool `json:"unmapped"` + Unanchored bool `json:"unanchored"` +} + +type LineageNode struct { + Key string `json:"key"` + NodeType string `json:"nodeType"` + CatalogID string `json:"catalogId,omitempty"` + ControlID string `json:"controlId,omitempty"` + GroupID string `json:"groupId,omitempty"` + Title string `json:"title"` + Compliance LineageCompliance `json:"compliance"` + Risk LineageRisk `json:"risk"` + Linkage LineageLinkage `json:"linkage"` + HasChildren bool `json:"hasChildren"` + ChildrenCount int `json:"childrenCount"` +} + +// ── Endpoints ────────────────────────────────────────────────────────────────── + +// Roots godoc + +// @Summary List lineage roots +// @Description Returns catalog roots (standard/policy/procedure) with full-subtree compliance and risk rollups. Rootness is catalog_type, never link presence. +// @Tags Lineage +// @Produce json +// @Param sspId query string false "Scope metrics to a System Security Plan" +// @Param componentId query string false "Scope metrics to a system component" +// @Param types query string false "Comma-separated catalog types to include (standard,policy,procedure)" +// @Success 200 {object} svc.ListResponse[LineageNode] +// @Failure 400 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /lineage/roots [get] +func (h *LineageHandler) Roots(ctx echo.Context) error { + sspID, componentID, err := parseScope(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + types, err := parseTypes(ctx.QueryParam("types")) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + engine, err := h.buildEngine(sspID, componentID) + if err != nil { + h.sugar.Errorw("failed to build lineage engine", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + catalogIDs := make([]uuid.UUID, 0, len(engine.catalogs)) + for id, info := range engine.catalogs { + if _, ok := types[info.ctype]; ok { + catalogIDs = append(catalogIDs, id) + } + } + + nodes := make([]LineageNode, 0, len(catalogIDs)) + for _, id := range catalogIDs { + nodes = append(nodes, engine.catalogNode(id)) + } + sortNodes(nodes) + + // Roots aren't paginated, but return the same ListResponse envelope as + // /children so a UI consumer handles one shape across the lineage family. + // Guard the limit against 0 (empty roots) since NewListResponse divides by it. + limit := len(nodes) + if limit < 1 { + limit = 1 + } + return ctx.JSON(http.StatusOK, svc.NewListResponse(nodes, int64(len(nodes)), 1, limit)) +} + +// Children godoc + +// @Summary List lineage node children +// @Description Returns one level of children for a node. Every node carries full-subtree rollup metrics. Key is a composite like catalog:, group:/, control:/. +// @Tags Lineage +// @Produce json +// @Param key path string true "URL-encoded node key" +// @Param sspId query string false "Scope metrics to a System Security Plan" +// @Param componentId query string false "Scope metrics to a system component" +// @Param page query int false "Page number" +// @Param limit query int false "Page size (default 100)" +// @Success 200 {object} svc.ListResponse[LineageNode] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /lineage/nodes/{key}/children [get] +func (h *LineageHandler) Children(ctx echo.Context) error { + sspID, componentID, err := parseScope(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + // Default the page size to 100 per the lineage contract. + limitCfg := *h.pagination + limitCfg.DefaultLimit = 100 + pagination, err := limitCfg.ParseParams(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + kind, catalogID, subID, err := parseNodeKey(ctx.Param("key")) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + engine, err := h.buildEngine(sspID, componentID) + if err != nil { + h.sugar.Errorw("failed to build lineage engine", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + children, err := engine.childrenOf(kind, catalogID, subID) + if err != nil { + return ctx.JSON(http.StatusNotFound, api.NewError(err)) + } + sortNodes(children) + + total := int64(len(children)) + start := pagination.Offset + if start > len(children) { + start = len(children) + } + end := start + pagination.Limit + if end > len(children) { + end = len(children) + } + page := children[start:end] + + return ctx.JSON(http.StatusOK, svc.NewListResponse(page, total, pagination.Page, pagination.Limit)) +} + +// ── Engine ───────────────────────────────────────────────────────────────────── + +type catalogInfo struct { + ctype string + title string +} + +type groupMeta struct { + ID string + Title string +} + +type riskEntry struct { + riskID uuid.UUID + status string + score int +} + +// lineageEngine holds everything loaded once per request to compute rollups +// without per-node N+1 queries. +type lineageEngine struct { + sspID *uuid.UUID + componentID *uuid.UUID + + graph *relational.ControlLinkGraph + + catalogs map[uuid.UUID]catalogInfo + controlTitle map[relational.ControlRef]string + controlCatalogType map[relational.ControlRef]string + standardCatalogs map[uuid.UUID]struct{} + + catalogAllControls map[uuid.UUID][]relational.ControlRef + catalogTopControls map[uuid.UUID][]relational.ControlRef + catalogTopGroups map[uuid.UUID][]groupMeta + groupControls map[string][]relational.ControlRef + groupTitle map[string]string + + // rollup inputs + filtersByControl map[relational.ControlRef][]uuid.UUID + statusByFilter map[uuid.UUID][]relational.StatusCount + controlStatus map[relational.ControlRef]string + risksByControl map[relational.ControlRef][]riskEntry + + // SSP scope: standard controls resolved by the SSP's profiles. + profileControlSet map[string]struct{} +} + +func (h *LineageHandler) buildEngine(sspID, componentID *uuid.UUID) (*lineageEngine, error) { + e := &lineageEngine{ + sspID: sspID, + componentID: componentID, + catalogs: map[uuid.UUID]catalogInfo{}, + controlTitle: map[relational.ControlRef]string{}, + controlCatalogType: map[relational.ControlRef]string{}, + standardCatalogs: map[uuid.UUID]struct{}{}, + catalogAllControls: map[uuid.UUID][]relational.ControlRef{}, + catalogTopControls: map[uuid.UUID][]relational.ControlRef{}, + catalogTopGroups: map[uuid.UUID][]groupMeta{}, + groupControls: map[string][]relational.ControlRef{}, + groupTitle: map[string]string{}, + filtersByControl: map[relational.ControlRef][]uuid.UUID{}, + controlStatus: map[relational.ControlRef]string{}, + risksByControl: map[relational.ControlRef][]riskEntry{}, + profileControlSet: map[string]struct{}{}, + } + + if err := e.loadCatalogs(h.db); err != nil { + return nil, err + } + if err := e.loadEdges(h.db); err != nil { + return nil, err + } + if err := e.loadCompliance(h.db); err != nil { + return nil, err + } + if err := e.loadRisks(h.db); err != nil { + return nil, err + } + if sspID != nil { + if err := e.loadProfileScope(h.db, *sspID); err != nil { + return nil, err + } + } + return e, nil +} + +func (e *lineageEngine) loadCatalogs(db *gorm.DB) error { + var catalogs []relational.Catalog + if err := db. + Preload("Metadata"). + Preload("Controls"). + Preload("Groups"). + Preload("Groups.Controls"). + Preload("Groups.Groups"). + Preload("Groups.Groups.Controls"). + Find(&catalogs).Error; err != nil { + return err + } + + for i := range catalogs { + cat := catalogs[i] + if cat.ID == nil { + continue + } + catID := *cat.ID + ctype := cat.CatalogType + if ctype == "" { + ctype = relational.CatalogTypeStandard + } + e.catalogs[catID] = catalogInfo{ctype: ctype, title: cat.Metadata.Title} + if ctype == relational.CatalogTypeStandard { + e.standardCatalogs[catID] = struct{}{} + } + + register := func(ref relational.ControlRef, title string) { + e.controlTitle[ref] = title + e.controlCatalogType[ref] = ctype + } + + var topControls []relational.ControlRef + flattenControls(cat.Controls, catID, &topControls, register) + e.catalogTopControls[catID] = topControls + + allControls := append([]relational.ControlRef(nil), topControls...) + for _, g := range cat.Groups { + var gc []relational.ControlRef + flattenGroup(g, catID, &gc, register) + gkey := groupKey(catID, g.ID) + e.groupControls[gkey] = gc + e.groupTitle[gkey] = g.Title + e.catalogTopGroups[catID] = append(e.catalogTopGroups[catID], groupMeta{ID: g.ID, Title: g.Title}) + allControls = append(allControls, gc...) + } + e.catalogAllControls[catID] = allControls + } + return nil +} + +func (e *lineageEngine) loadEdges(db *gorm.DB) error { + edges := []relational.ControlLink{} + if err := db.Find(&edges).Error; err != nil { + return err + } + e.graph = relational.NewControlLinkGraph(edges) + return nil +} + +func (e *lineageEngine) loadCompliance(db *gorm.DB) error { + var filters []relational.Filter + q := db.Preload("Controls") + if e.sspID != nil { + q = q.Where("ssp_id IS NULL OR ssp_id = ?", *e.sspID) + } + if err := q.Find(&filters).Error; err != nil { + return err + } + + filterMap := make(map[uuid.UUID]labelfilter.Filter, len(filters)) + for i := range filters { + f := filters[i] + if f.ID == nil { + continue + } + filterMap[*f.ID] = f.Filter.Data() + for _, c := range f.Controls { + ref := relational.ControlRef{CatalogID: c.CatalogID, ControlID: c.ID} + e.filtersByControl[ref] = append(e.filtersByControl[ref], *f.ID) + } + } + + statusByFilter, err := relational.GetEvidenceStatusCountsByFilters(db, filterMap, e.componentID) + if err != nil { + return err + } + e.statusByFilter = statusByFilter + + for ref, ids := range e.filtersByControl { + merged := make([]relational.StatusCount, 0) + for _, id := range ids { + merged = append(merged, statusByFilter[id]...) + } + e.controlStatus[ref] = computeLineageStatus(merged) + } + return nil +} + +type riskScanRow struct { + CatalogID uuid.UUID `gorm:"column:catalog_id"` + ControlID string `gorm:"column:control_id"` + RiskID uuid.UUID `gorm:"column:risk_id"` + Status string `gorm:"column:status"` + Likelihood *string `gorm:"column:likelihood"` + Impact *string `gorm:"column:impact"` +} + +func (e *lineageEngine) loadRisks(db *gorm.DB) error { + q := db.Table("risk_control_links rcl"). + Select("rcl.catalog_id, rcl.control_id, r.id as risk_id, r.status, r.likelihood, r.impact"). + Joins("JOIN risk_register_risks r ON r.id = rcl.risk_id") + if e.sspID != nil { + q = q.Where("r.ssp_id = ?", *e.sspID) + } + if e.componentID != nil { + q = q.Joins("JOIN risk_component_links rcomp ON rcomp.risk_id = r.id"). + Where("rcomp.component_id = ?", *e.componentID) + } + + rows := []riskScanRow{} + if err := q.Scan(&rows).Error; err != nil { + return err + } + for _, row := range rows { + score, _ := riskrel.NumericalRiskScore(row.Likelihood, row.Impact) + ref := relational.ControlRef{CatalogID: row.CatalogID, ControlID: row.ControlID} + e.risksByControl[ref] = append(e.risksByControl[ref], riskEntry{ + riskID: row.RiskID, + status: row.Status, + score: score, + }) + } + return nil +} + +func (e *lineageEngine) loadProfileScope(db *gorm.DB, sspID uuid.UUID) error { + type profileControlRow struct { + ControlCatalogID uuid.UUID `gorm:"column:control_catalog_id"` + ControlID string `gorm:"column:control_id"` + } + rows := []profileControlRow{} + if err := db. + Table("profile_controls"). + Select("profile_controls.control_catalog_id, profile_controls.control_id"). + Joins("JOIN ssp_profiles ON ssp_profiles.profile_id = profile_controls.profile_id"). + Where("ssp_profiles.system_security_plan_id = ?", sspID). + Scan(&rows).Error; err != nil { + return err + } + for _, row := range rows { + e.profileControlSet[scopeKey(relational.ControlRef{CatalogID: row.ControlCatalogID, ControlID: row.ControlID})] = struct{}{} + } + return nil +} + +// inScope reports whether a control contributes to the current metric scope. +// Global scope includes everything; SSP scope restricts standard controls to the +// SSP's resolved profile controls while always including policy/procedure controls. +func (e *lineageEngine) inScope(ref relational.ControlRef) bool { + if e.sspID == nil { + return true + } + switch e.controlCatalogType[ref] { + case relational.CatalogTypePolicy, relational.CatalogTypeProcedure: + return true + default: + _, ok := e.profileControlSet[scopeKey(ref)] + return ok + } +} + +// evidenceSet returns the distinct in-scope controls whose evidence/risk rolls up +// into the given seed controls: each seed plus its implements-closure. +func (e *lineageEngine) evidenceSet(seeds []relational.ControlRef) map[relational.ControlRef]struct{} { + set := map[relational.ControlRef]struct{}{} + for _, s := range seeds { + for m := range e.graph.EvidenceClosure(s) { + if e.inScope(m) { + set[m] = struct{}{} + } + } + } + return set +} + +func (e *lineageEngine) compliance(set map[relational.ControlRef]struct{}) LineageCompliance { + total, sat, not, unk := 0, 0, 0, 0 + for ref := range set { + total++ + switch e.controlStatus[ref] { + case relational.EvidenceStatusSatisfied: + sat++ + case relational.EvidenceStatusNotSatisfied: + not++ + default: + unk++ + } + } + return LineageCompliance{ + TotalControls: total, + Satisfied: sat, + NotSatisfied: not, + Unknown: unk, + CompliancePercent: pct1(sat, total), + AssessedPercent: pct1(sat+not, total), + } +} + +func (e *lineageEngine) risk(set map[relational.ControlRef]struct{}) LineageRisk { + entries := make([]riskEntry, 0) + for ref := range set { + entries = append(entries, e.risksByControl[ref]...) + } + return bucketRisks(entries) +} + +func (e *lineageEngine) linkageFor(seeds []relational.ControlRef, isPolicyNode bool) LineageLinkage { + seedSet := map[relational.ControlRef]struct{}{} + for _, s := range seeds { + seedSet[s] = struct{}{} + } + closure := map[relational.ControlRef]struct{}{} + for _, s := range seeds { + for m := range e.graph.StructuralClosure(s) { + closure[m] = struct{}{} + } + } + + policies, procedures, operational := 0, 0, 0 + for m := range closure { + if _, isSeed := seedSet[m]; isSeed { + continue + } + switch e.controlCatalogType[m] { + case relational.CatalogTypePolicy: + policies++ + case relational.CatalogTypeProcedure: + procedures++ + default: + operational++ + } + } + + anchored := false + for _, s := range seeds { + if e.graph.HasOutgoingImplements(s, e.standardCatalogs) { + anchored = true + break + } + } + + return LineageLinkage{ + Policies: policies, + Procedures: procedures, + OperationalControls: operational, + Unanchored: isPolicyNode && !anchored, + } +} + +func (e *lineageEngine) catalogNode(catID uuid.UUID) LineageNode { + info := e.catalogs[catID] + seeds := e.catalogAllControls[catID] + set := e.evidenceSet(seeds) + childCount := len(e.catalogTopGroups[catID]) + len(e.catalogTopControls[catID]) + return LineageNode{ + Key: "catalog:" + catID.String(), + NodeType: catalogNodeType(info.ctype), + CatalogID: catID.String(), + Title: info.title, + Compliance: e.compliance(set), + Risk: e.risk(set), + Linkage: e.linkageFor(seeds, info.ctype == relational.CatalogTypePolicy), + HasChildren: childCount > 0, + ChildrenCount: childCount, + } +} + +func (e *lineageEngine) groupNode(catID uuid.UUID, groupID string) LineageNode { + seeds := e.groupControls[groupKey(catID, groupID)] + set := e.evidenceSet(seeds) + return LineageNode{ + Key: "group:" + catID.String() + "/" + groupID, + NodeType: "group", + CatalogID: catID.String(), + GroupID: groupID, + Title: e.groupTitle[groupKey(catID, groupID)], + Compliance: e.compliance(set), + Risk: e.risk(set), + Linkage: e.linkageFor(seeds, false), + HasChildren: len(seeds) > 0, + ChildrenCount: len(seeds), + } +} + +func (e *lineageEngine) controlNode(ref relational.ControlRef) LineageNode { + ctype := e.controlCatalogType[ref] + set := e.evidenceSet([]relational.ControlRef{ref}) + children := e.graph.Children(ref) + linkage := e.linkageFor([]relational.ControlRef{ref}, ctype == relational.CatalogTypePolicy) + if ctype == relational.CatalogTypeStandard { + linkage.Unmapped = len(e.graph.ImplementsChildren(ref)) == 0 && len(e.filtersByControl[ref]) == 0 + } + return LineageNode{ + Key: "control:" + ref.CatalogID.String() + "/" + ref.ControlID, + NodeType: controlNodeType(ctype), + CatalogID: ref.CatalogID.String(), + ControlID: ref.ControlID, + Title: e.controlTitle[ref], + Compliance: e.compliance(set), + Risk: e.risk(set), + Linkage: linkage, + HasChildren: len(children) > 0, + ChildrenCount: len(children), + } +} + +func (e *lineageEngine) childrenOf(kind string, catalogID uuid.UUID, subID string) ([]LineageNode, error) { + switch kind { + case "catalog": + if _, ok := e.catalogs[catalogID]; !ok { + return nil, errors.New("catalog not found") + } + nodes := []LineageNode{} + for _, g := range e.catalogTopGroups[catalogID] { + nodes = append(nodes, e.groupNode(catalogID, g.ID)) + } + for _, ref := range e.catalogTopControls[catalogID] { + nodes = append(nodes, e.controlNode(ref)) + } + return nodes, nil + case "group": + refs, ok := e.groupControls[groupKey(catalogID, subID)] + if !ok { + return nil, errors.New("group not found") + } + nodes := make([]LineageNode, 0, len(refs)) + for _, ref := range refs { + nodes = append(nodes, e.controlNode(ref)) + } + return nodes, nil + case "control": + ref := relational.ControlRef{CatalogID: catalogID, ControlID: subID} + if _, ok := e.controlCatalogType[ref]; !ok { + return nil, errors.New("control not found") + } + children := e.graph.Children(ref) + nodes := make([]LineageNode, 0, len(children)) + for _, child := range children { + nodes = append(nodes, e.controlNode(child)) + } + return nodes, nil + default: + return nil, errors.New("unknown node kind: " + kind) + } +} + +// ── Pure helpers (unit-testable) ──────────────────────────────────────────────── + +// computeLineageStatus collapses a control's evidence status counts into one of +// satisfied/not-satisfied/unknown, identical to the profile compliance semantics: +// any not-satisfied wins; else any satisfied; else unknown. +func computeLineageStatus(rows []relational.StatusCount) string { + hasSatisfied := false + for _, row := range rows { + if row.Count <= 0 { + continue + } + switch strings.ToLower(strings.TrimSpace(row.Status)) { + case relational.EvidenceStatusNotSatisfied: + return relational.EvidenceStatusNotSatisfied + case relational.EvidenceStatusSatisfied: + hasSatisfied = true + } + } + if hasSatisfied { + return relational.EvidenceStatusSatisfied + } + return "unknown" +} + +// bucketRisks dedups risks by id and buckets them by status into open (heat) and +// muted score sums, excluding remediated/closed risks entirely. +func bucketRisks(entries []riskEntry) LineageRisk { + seen := map[uuid.UUID]struct{}{} + var out LineageRisk + for _, re := range entries { + if _, ok := seen[re.riskID]; ok { + continue + } + seen[re.riskID] = struct{}{} + switch re.status { + case string(riskrel.RiskStatusOpen): + out.OpenScoreSum += re.score + out.Counts.Open++ + case string(riskrel.RiskStatusInvestigating): + out.OpenScoreSum += re.score + out.Counts.Investigating++ + case string(riskrel.RiskStatusMitigatingPlanned): + out.OpenScoreSum += re.score + out.Counts.MitigatingPlanned++ + case string(riskrel.RiskStatusRiskAccepted): + out.MutedScoreSum += re.score + out.Counts.RiskAccepted++ + case string(riskrel.RiskStatusMitigatingImplemented): + out.MutedScoreSum += re.score + out.Counts.MitigatingImplemented++ + default: + // remediated / closed / unknown: excluded from both sums. + } + } + return out +} + +func catalogNodeType(ctype string) string { + switch ctype { + case relational.CatalogTypePolicy: + return "policy-catalog" + case relational.CatalogTypeProcedure: + return "procedure-catalog" + default: + return "standard-catalog" + } +} + +func controlNodeType(ctype string) string { + switch ctype { + case relational.CatalogTypePolicy: + return "policy-control" + case relational.CatalogTypeProcedure: + return "procedure-control" + default: + return "control" + } +} + +func flattenControls(controls []relational.Control, catID uuid.UUID, out *[]relational.ControlRef, register func(relational.ControlRef, string)) { + for _, c := range controls { + ref := relational.ControlRef{CatalogID: catID, ControlID: c.ID} + *out = append(*out, ref) + register(ref, c.Title) + if len(c.Controls) > 0 { + flattenControls(c.Controls, catID, out, register) + } + } +} + +func flattenGroup(g relational.Group, catID uuid.UUID, out *[]relational.ControlRef, register func(relational.ControlRef, string)) { + flattenControls(g.Controls, catID, out, register) + for _, sub := range g.Groups { + flattenGroup(sub, catID, out, register) + } +} + +func groupKey(catID uuid.UUID, groupID string) string { + return catID.String() + "/" + groupID +} + +func scopeKey(ref relational.ControlRef) string { + return ref.CatalogID.String() + "\x00" + strings.ToUpper(ref.ControlID) +} + +func pct1(part, total int) float64 { + if total == 0 { + return 0 + } + return math.Round(float64(part)/float64(total)*1000) / 10 +} + +func sortNodes(nodes []LineageNode) { + sort.Slice(nodes, func(i, j int) bool { + if nodes[i].Title == nodes[j].Title { + return nodes[i].Key < nodes[j].Key + } + return nodes[i].Title < nodes[j].Title + }) +} + +// parseScope parses the shared sspId/componentId query params. +func parseScope(ctx echo.Context) (*uuid.UUID, *uuid.UUID, error) { + var sspID, componentID *uuid.UUID + if v := strings.TrimSpace(ctx.QueryParam("sspId")); v != "" { + id, err := uuid.Parse(v) + if err != nil { + return nil, nil, errors.New("invalid sspId") + } + sspID = &id + } + if v := strings.TrimSpace(ctx.QueryParam("componentId")); v != "" { + id, err := uuid.Parse(v) + if err != nil { + return nil, nil, errors.New("invalid componentId") + } + componentID = &id + } + return sspID, componentID, nil +} + +// parseTypes parses the roots ?types= filter, defaulting to all catalog types. +func parseTypes(raw string) (map[string]struct{}, error) { + types := map[string]struct{}{} + raw = strings.TrimSpace(raw) + if raw == "" { + types[relational.CatalogTypeStandard] = struct{}{} + types[relational.CatalogTypePolicy] = struct{}{} + types[relational.CatalogTypeProcedure] = struct{}{} + return types, nil + } + for _, part := range strings.Split(raw, ",") { + t := strings.TrimSpace(part) + if t == "" { + continue + } + if !relational.IsValidCatalogType(t) { + return nil, errors.New("invalid catalog type in types filter: " + t) + } + types[t] = struct{}{} + } + if len(types) == 0 { + return nil, errors.New("types filter is empty") + } + return types, nil +} + +// parseNodeKey decodes a composite node key into its kind, catalog id, and the +// trailing sub-id (group id or control id). +// +// The key travels as a single URL-encoded path segment (the ':' and the '/' +// separator arrive as %3A/%2F). Echo does not unescape path params, so we decode +// here before splitting. A raw, unencoded key contains no '%' and passes through +// url.PathUnescape unchanged, so direct callers keep working. +func parseNodeKey(raw string) (kind string, catalogID uuid.UUID, subID string, err error) { + if decoded, derr := url.PathUnescape(raw); derr == nil { + raw = decoded + } + colon := strings.IndexByte(raw, ':') + if colon < 0 { + return "", uuid.Nil, "", errors.New("malformed node key") + } + kind = raw[:colon] + rest := raw[colon+1:] + + switch kind { + case "catalog": + catalogID, err = uuid.Parse(rest) + if err != nil { + return "", uuid.Nil, "", errors.New("malformed catalog key") + } + return kind, catalogID, "", nil + case "group", "control": + slash := strings.IndexByte(rest, '/') + if slash < 0 { + return "", uuid.Nil, "", errors.New("malformed " + kind + " key") + } + catalogID, err = uuid.Parse(rest[:slash]) + if err != nil { + return "", uuid.Nil, "", errors.New("malformed catalog id in key") + } + subID = rest[slash+1:] + if subID == "" { + return "", uuid.Nil, "", errors.New("missing sub-id in key") + } + return kind, catalogID, subID, nil + default: + return "", uuid.Nil, "", errors.New("unknown node kind: " + kind) + } +} diff --git a/internal/api/handler/lineage_perf_integration_test.go b/internal/api/handler/lineage_perf_integration_test.go new file mode 100644 index 00000000..20c57d65 --- /dev/null +++ b/internal/api/handler/lineage_perf_integration_test.go @@ -0,0 +1,163 @@ +//go:build integration + +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/compliance-framework/api/internal/converters/labelfilter" + "github.com/compliance-framework/api/internal/service/relational" + "github.com/compliance-framework/api/internal/tests" + oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/suite" + "go.uber.org/zap" + "gorm.io/datatypes" +) + +func TestLineagePerf(t *testing.T) { + suite.Run(t, new(LineagePerfSuite)) +} + +type LineagePerfSuite struct { + tests.IntegrationTestSuite +} + +// callRoots invokes the Roots handler directly against the real DB and returns +// the status code + wall-clock duration, failing hard if it hangs. +func (suite *LineagePerfSuite) callRoots() (int, time.Duration) { + code, dur, _ := suite.callRootsBody() + return code, dur +} + +func (suite *LineagePerfSuite) callRootsBody() (int, time.Duration, []byte) { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/api/lineage/roots", nil) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + h := NewLineageHandler(zap.NewNop().Sugar(), suite.DB) + + done := make(chan error, 1) + start := time.Now() + go func() { done <- h.Roots(ctx) }() + select { + case err := <-done: + suite.Require().NoError(err) + return rec.Code, time.Since(start), rec.Body.Bytes() + case <-time.After(90 * time.Second): + suite.FailNow("Roots hung > 90s") + return 0, 0, nil + } +} + +func (suite *LineagePerfSuite) TestRootsEmpty() { + suite.Require().NoError(suite.Migrator.Refresh()) + code, dur := suite.callRoots() + suite.Equal(http.StatusOK, code) + suite.T().Logf("EMPTY roots: %s", dur) +} + +func (suite *LineagePerfSuite) TestRootsWithCatalogAndEvidence() { + suite.Require().NoError(suite.Migrator.Refresh()) + + const ( + nControls = 150 + nEvidence = 5000 + ) + + catID := uuid.New() + now := time.Now().UTC() + controls := make([]relational.Control, 0, nControls) + for i := 0; i < nControls; i++ { + controls = append(controls, relational.Control{ + CatalogID: catID, + ID: fmt.Sprintf("ac-%d", i), + Title: fmt.Sprintf("Control %d", i), + }) + } + catalog := relational.Catalog{ + UUIDModel: relational.UUIDModel{ID: &catID}, + CatalogType: relational.CatalogTypeStandard, + Metadata: relational.Metadata{Title: "Big Standard", Version: "1.0.0", OscalVersion: "1.1.3", LastModified: &now}, + Groups: []relational.Group{{ + CatalogID: catID, + ID: "grp", + Title: "Group", + Controls: controls, + }}, + } + suite.Require().NoError(suite.DB.Create(&catalog).Error) + + // One filter per control (a label condition scope), attached via filter_controls. + for i := 0; i < nControls; i++ { + f := relational.Filter{ + Name: fmt.Sprintf("f-%d", i), + Filter: datatypes.NewJSONType(labelfilter.Filter{ + Scope: &labelfilter.Scope{ + Condition: &labelfilter.Condition{Label: "svc", Operator: "=", Value: "ec2"}, + }, + }), + } + suite.Require().NoError(suite.DB.Create(&f).Error) + suite.Require().NoError(suite.DB.Exec( + "INSERT INTO filter_controls (filter_id, control_catalog_id, control_id) VALUES (?, ?, ?)", + f.ID, catID, fmt.Sprintf("ac-%d", i), + ).Error) + } + + // A label + a pile of latest-evidence streams that match it. + suite.Require().NoError(suite.DB.Exec( + "INSERT INTO labels (name, value) VALUES ('svc','ec2') ON CONFLICT DO NOTHING").Error) + + evidences := make([]relational.Evidence, 0, nEvidence) + for i := 0; i < nEvidence; i++ { + id := uuid.New() + evidences = append(evidences, relational.Evidence{ + UUIDModel: relational.UUIDModel{ID: &id}, + UUID: uuid.New(), + Title: "e", + Start: now, + End: now, + Status: datatypes.NewJSONType(oscalTypes_1_1_3.ObjectiveStatus{State: "satisfied"}), + }) + } + suite.Require().NoError(suite.DB.CreateInBatches(&evidences, 500).Error) + // Join every evidence row to the matching label. + suite.Require().NoError(suite.DB.Exec( + "INSERT INTO evidence_labels (evidence_id, labels_name, labels_value) SELECT id, 'svc', 'ec2' FROM evidences").Error) + + code, dur, body := suite.callRootsBody() + suite.Equal(http.StatusOK, code) + suite.T().Logf("POPULATED roots (%d controls, %d filters, %d evidence): %s", nControls, nControls, nEvidence, dur) + + // Correctness: every control has a filter matched by 5000 satisfied streams, + // so the catalog root must roll up as fully satisfied. + var resp struct { + Data []struct { + NodeType string `json:"nodeType"` + Compliance struct { + TotalControls int `json:"totalControls"` + Satisfied int `json:"satisfied"` + NotSatisfied int `json:"notSatisfied"` + CompliancePercent float64 `json:"compliancePercent"` + } `json:"compliance"` + } `json:"data"` + } + suite.Require().NoError(json.Unmarshal(body, &resp)) + suite.Require().Len(resp.Data, 1) + root := resp.Data[0] + suite.Equal("standard-catalog", root.NodeType) + suite.Equal(nControls, root.Compliance.TotalControls, "all controls in scope") + suite.Equal(nControls, root.Compliance.Satisfied, "every control satisfied") + suite.Equal(0, root.Compliance.NotSatisfied) + suite.InDelta(100.0, root.Compliance.CompliancePercent, 0.001) + + // Regression guard: this used to take ~29s (per-filter N+1); keep it well under. + suite.Less(dur, 5*time.Second, "roots rollup must not regress into an N+1") +} diff --git a/internal/api/handler/lineage_test.go b/internal/api/handler/lineage_test.go new file mode 100644 index 00000000..d98bc611 --- /dev/null +++ b/internal/api/handler/lineage_test.go @@ -0,0 +1,117 @@ +package handler + +import ( + "testing" + + "github.com/compliance-framework/api/internal/service/relational" + riskrel "github.com/compliance-framework/api/internal/service/relational/risks" + "github.com/google/uuid" +) + +func TestBucketRisks(t *testing.T) { + r1 := uuid.New() + r2 := uuid.New() + r3 := uuid.New() + r4 := uuid.New() + r5 := uuid.New() + r6 := uuid.New() + r7 := uuid.New() + + entries := []riskEntry{ + {riskID: r1, status: string(riskrel.RiskStatusOpen), score: 20}, + {riskID: r2, status: string(riskrel.RiskStatusInvestigating), score: 5}, + {riskID: r3, status: string(riskrel.RiskStatusMitigatingPlanned), score: 12}, + {riskID: r4, status: string(riskrel.RiskStatusRiskAccepted), score: 9}, + {riskID: r5, status: string(riskrel.RiskStatusMitigatingImplemented), score: 3}, + {riskID: r6, status: string(riskrel.RiskStatusRemediated), score: 25}, // excluded + {riskID: r7, status: string(riskrel.RiskStatusClosed), score: 25}, // excluded + // Duplicate of r1 (same risk linked to two controls in the closure) must not double count. + {riskID: r1, status: string(riskrel.RiskStatusOpen), score: 20}, + } + + got := bucketRisks(entries) + + if got.OpenScoreSum != 20+5+12 { + t.Errorf("OpenScoreSum = %d, want %d", got.OpenScoreSum, 20+5+12) + } + if got.MutedScoreSum != 9+3 { + t.Errorf("MutedScoreSum = %d, want %d", got.MutedScoreSum, 9+3) + } + if got.Counts.Open != 1 || got.Counts.Investigating != 1 || got.Counts.MitigatingPlanned != 1 { + t.Errorf("open-bucket counts wrong: %+v", got.Counts) + } + if got.Counts.RiskAccepted != 1 || got.Counts.MitigatingImplemented != 1 { + t.Errorf("muted-bucket counts wrong: %+v", got.Counts) + } +} + +func TestBucketRisksEmpty(t *testing.T) { + got := bucketRisks(nil) + if got.OpenScoreSum != 0 || got.MutedScoreSum != 0 { + t.Errorf("empty risk bucket should be zero, got %+v", got) + } +} + +func TestComputeLineageStatus(t *testing.T) { + cases := []struct { + name string + rows []relational.StatusCount + want string + }{ + {"no evidence is unknown", nil, "unknown"}, + {"any not-satisfied wins", []relational.StatusCount{ + {Status: "satisfied", Count: 10}, + {Status: "not-satisfied", Count: 1}, + }, relational.EvidenceStatusNotSatisfied}, + {"satisfied without failures", []relational.StatusCount{ + {Status: "satisfied", Count: 3}, + }, relational.EvidenceStatusSatisfied}, + {"zero counts ignored", []relational.StatusCount{ + {Status: "not-satisfied", Count: 0}, + {Status: "satisfied", Count: 0}, + }, "unknown"}, + {"case-insensitive states", []relational.StatusCount{ + {Status: "SATISFIED", Count: 2}, + }, relational.EvidenceStatusSatisfied}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := computeLineageStatus(tc.rows); got != tc.want { + t.Errorf("computeLineageStatus = %q, want %q", got, tc.want) + } + }) + } +} + +func TestParseNodeKey(t *testing.T) { + catID := uuid.New() + + kind, gotCat, sub, err := parseNodeKey("catalog:" + catID.String()) + if err != nil || kind != "catalog" || gotCat != catID || sub != "" { + t.Errorf("catalog key parse failed: kind=%q cat=%v sub=%q err=%v", kind, gotCat, sub, err) + } + + kind, gotCat, sub, err = parseNodeKey("control:" + catID.String() + "/ac-1") + if err != nil || kind != "control" || gotCat != catID || sub != "ac-1" { + t.Errorf("control key parse failed: kind=%q cat=%v sub=%q err=%v", kind, gotCat, sub, err) + } + + kind, gotCat, sub, err = parseNodeKey("group:" + catID.String() + "/ac") + if err != nil || kind != "group" || gotCat != catID || sub != "ac" { + t.Errorf("group key parse failed: kind=%q cat=%v sub=%q err=%v", kind, gotCat, sub, err) + } + + if _, _, _, err := parseNodeKey("bogus"); err == nil { + t.Error("malformed key should error") + } + if _, _, _, err := parseNodeKey("control:not-a-uuid/ac-1"); err == nil { + t.Error("bad catalog uuid should error") + } + + // Echo delivers path params still percent-encoded (the UI sends + // encodeURIComponent(key)); parseNodeKey must decode %3A/%2F before splitting. + kind, gotCat, sub, err = parseNodeKey("control%3A" + catID.String() + "%2Fac-1") + if err != nil || kind != "control" || gotCat != catID || sub != "ac-1" { + t.Errorf("URL-encoded control key parse failed: kind=%q cat=%v sub=%q err=%v", kind, gotCat, sub, err) + } +} diff --git a/internal/api/handler/oscal/catalogs.go b/internal/api/handler/oscal/catalogs.go index 7a27c532..0a571afb 100644 --- a/internal/api/handler/oscal/catalogs.go +++ b/internal/api/handler/oscal/catalogs.go @@ -2,6 +2,7 @@ package oscal import ( "errors" + "fmt" "net/http" "time" @@ -61,20 +62,29 @@ func (h *CatalogHandler) Register(api *echo.Group, guard middleware.ResourceGuar // List godoc // @Summary List catalogs -// @Description Retrieves all catalogs. +// @Description Retrieves all catalogs, optionally filtered by catalog type. // @Tags Catalog // @Produce json -// @Success 200 {object} handler.GenericDataListResponse[oscalTypes_1_1_3.Catalog] -// @Failure 400 {object} api.Error -// @Failure 500 {object} api.Error +// @Param type query string false "Filter by catalog type (standard|policy|procedure)" +// @Success 200 {object} handler.GenericDataListResponse[oscalTypes_1_1_3.Catalog] +// @Failure 400 {object} api.Error +// @Failure 500 {object} api.Error // @Security OAuth2Password // @Router /oscal/catalogs [get] func (h *CatalogHandler) List(ctx echo.Context) error { - var catalogs []relational.Catalog - if err := h.db. + query := h.db. Preload("Metadata"). - Preload("Metadata.Revisions"). - Find(&catalogs).Error; err != nil { + Preload("Metadata.Revisions") + + if catalogType := ctx.QueryParam("type"); catalogType != "" { + if !relational.IsValidCatalogType(catalogType) { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("invalid catalog type %q: must be one of standard|policy|procedure", catalogType))) + } + query = query.Where("catalog_type = ?", catalogType) + } + + var catalogs []relational.Catalog + if err := query.Find(&catalogs).Error; err != nil { h.sugar.Warnw("Failed to load catalogs", "error", err) return ctx.JSON(http.StatusBadRequest, api.NewError(err)) } @@ -445,11 +455,12 @@ func (h *CatalogHandler) DeleteControl(ctx echo.Context) error { // Create godoc // @Summary Create a new Catalog -// @Description Creates a new OSCAL Catalog. +// @Description Creates a new OSCAL Catalog. The catalog type may be supplied via the ?type= query param or a catalog-type metadata prop; it defaults to standard. // @Tags Catalog // @Accept json // @Produce json // @Param catalog body oscalTypes_1_1_3.Catalog true "Catalog object" +// @Param type query string false "Catalog type (standard|policy|procedure); overrides any metadata prop" // @Success 201 {object} handler.GenericDataResponse[oscalTypes_1_1_3.Catalog] // @Failure 400 {object} api.Error // @Failure 500 {object} api.Error @@ -465,6 +476,14 @@ func (h *CatalogHandler) Create(ctx echo.Context) error { } relCat := &relational.Catalog{} relCat.UnmarshalOscal(oscalCat) + // An explicit ?type= query param overrides whatever the OSCAL metadata prop + // carried (UnmarshalOscal already defaulted it to standard when absent). + if catalogType := ctx.QueryParam("type"); catalogType != "" { + if !relational.IsValidCatalogType(catalogType) { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("invalid catalog type %q: must be one of standard|policy|procedure", catalogType))) + } + relCat.CatalogType = catalogType + } relCat.Metadata.LastModified = &now relCat.Metadata.OscalVersion = versioning.GetLatestSupportedVersion() if err := h.db.Create(relCat).Error; err != nil { diff --git a/internal/authz/manifest.yaml b/internal/authz/manifest.yaml index 34369618..e2dc96d7 100644 --- a/internal/authz/manifest.yaml +++ b/internal/authz/manifest.yaml @@ -204,6 +204,16 @@ resources: actions: [read, create, update, delete] attributes: parent_id: uuid + # Typed edges between controls (Policies & Procedures + Compliance Lineage). + control-link: + actions: [read, create, delete] + attributes: + props: set + # Read-only lineage rollups walking Standard -> Policy -> Controls -> Evidence. + lineage: + actions: [read] + attributes: + props: set # ── Archetype F: platform / admin ── # The Phase-1 `admin` umbrella is retained as the resource the admin enforcement gates on @@ -292,6 +302,8 @@ roles: step-execution: [read, create, update, delete] role-assignment: [read, create, update, delete] control-relationship: [read, create, update, delete] + control-link: [read, create, delete] + lineage: [read] # Service accounts: ingest telemetry and register, and reconcile their own plugin templates # (the /agent/{risk,subject}-templates batch upsert — plugin v2). The batch routes enforce the diff --git a/internal/authz/pdp.go b/internal/authz/pdp.go index 4050c719..9ea956b7 100644 --- a/internal/authz/pdp.go +++ b/internal/authz/pdp.go @@ -110,6 +110,10 @@ const ( ResourceRoleAssignment = "role-assignment" ResourceControlRelationship = "control-relationship" + // Policies & Procedures + Compliance Lineage. + ResourceControlLink = "control-link" + ResourceLineage = "lineage" + // Actions. read/create/update/delete are the CRUD verbs; the rest are resource-specific // (promote → risk; ingest → heartbeat/agent; register → agent; trigger → digest; // execute → import). ActionManage is the admin umbrella. diff --git a/internal/service/migrator.go b/internal/service/migrator.go index 45a00cc2..fcf3d0ef 100644 --- a/internal/service/migrator.go +++ b/internal/service/migrator.go @@ -51,6 +51,7 @@ func MigrateUpWithConfig(db *gorm.DB, cfg *config.Config) error { &relational.Group{}, &relational.Control{}, &relational.Catalog{}, + &relational.ControlLink{}, &relational.ControlStatementImplementation{}, &relational.ImplementedRequirementControlImplementation{}, &relational.ControlImplementationSet{}, @@ -223,6 +224,9 @@ func MigrateUpWithConfig(db *gorm.DB, cfg *config.Config) error { if err := migrateSSPProfileIDToJoinTable(db); err != nil { return err } + if err := migrateBackfillCatalogType(db); err != nil { + return err + } // Create functional index for case-insensitive control_id lookups in filter_controls join table // This improves performance of UPPER(control_id) queries in the suggestion service @@ -617,6 +621,19 @@ func backfillLegacyNotificationSubscriptions(db *gorm.DB, legacyColumn string, n }).Error } +// migrateBackfillCatalogType ensures every existing catalog row carries a concrete +// catalog_type. AutoMigrate adds the column with a 'standard' default (Postgres +// backfills existing rows on ADD COLUMN), but this keeps the invariant explicit and +// idempotent across drivers, following the established data-migration step pattern. +func migrateBackfillCatalogType(db *gorm.DB) error { + if !db.Migrator().HasColumn(&relational.Catalog{}, "catalog_type") { + return nil + } + return db.Model(&relational.Catalog{}). + Where("catalog_type IS NULL OR catalog_type = ''"). + Update("catalog_type", relational.CatalogTypeStandard).Error +} + // migrateSSPProfileIDToJoinTable copies the legacy single profile_id FK from // system_security_plans into the new ssp_profiles join table. Rows that already // exist (ON CONFLICT DO NOTHING) are skipped, making the migration idempotent. @@ -644,6 +661,7 @@ func MigrateDown(db *gorm.DB) error { &relational.Role{}, &relational.Revision{}, &relational.Control{}, + &relational.ControlLink{}, &relational.Group{}, &relational.ResponsibleParty{}, &relational.Action{}, diff --git a/internal/service/relational/catalog.go b/internal/service/relational/catalog.go index bbecf953..c4aae962 100644 --- a/internal/service/relational/catalog.go +++ b/internal/service/relational/catalog.go @@ -6,13 +6,38 @@ import ( "gorm.io/datatypes" ) +// Catalog type vocabulary. Catalogs default to "standard"; policy/procedure +// catalogs model organizational Policies & Procedures (see Compliance Lineage). +const ( + CatalogTypeStandard = "standard" + CatalogTypePolicy = "policy" + CatalogTypeProcedure = "procedure" + + // catalogTypePropName / CCFPropNamespace are how catalog_type survives an + // OSCAL round-trip: it rides as a metadata prop since OSCAL has no native slot. + catalogTypePropName = "catalog-type" + CCFPropNamespace = "https://compliance-framework.github.io/ns" +) + +// IsValidCatalogType reports whether t is one of the known catalog types. +func IsValidCatalogType(t string) bool { + switch t { + case CatalogTypeStandard, CatalogTypePolicy, CatalogTypeProcedure: + return true + } + return false +} + type Catalog struct { UUIDModel - Metadata Metadata `json:"metadata" gorm:"polymorphic:Parent;"` - Params datatypes.JSONSlice[Parameter] `json:"params"` - Groups []Group `json:"groups"` - Controls []Control `json:"controls"` - BackMatter *BackMatter `json:"back-matter,omitempty" gorm:"polymorphic:Parent;"` + Metadata Metadata `json:"metadata" gorm:"polymorphic:Parent;"` + // CatalogType is one of standard|policy|procedure. It is the canonical source + // of truth for rootness in the lineage API and is never derived from links. + CatalogType string `json:"catalogType" gorm:"type:text;not null;default:'standard';index"` + Params datatypes.JSONSlice[Parameter] `json:"params"` + Groups []Group `json:"groups"` + Controls []Control `json:"controls"` + BackMatter *BackMatter `json:"back-matter,omitempty" gorm:"polymorphic:Parent;"` /** "required": [ "uuid", @@ -30,7 +55,8 @@ func (c *Catalog) UnmarshalOscal(ocatalog oscalTypes_1_1_3.Catalog) *Catalog { UUIDModel: UUIDModel{ ID: &id, }, - Metadata: *metadata, + Metadata: *metadata, + CatalogType: catalogTypeFromOscalProps(ocatalog.Metadata.Props), } if ocatalog.BackMatter != nil { @@ -72,6 +98,27 @@ func (c *Catalog) MarshalOscal() *oscalTypes_1_1_3.Catalog { UUID: c.ID.String(), Metadata: *c.Metadata.MarshalOscal(), } + // Emit catalog-type as a metadata prop only when it deviates from the default, + // so standard catalogs round-trip byte-identically to how they arrived. Strip + // any pre-existing catalog-type prop first: a catalog imported WITH the prop in + // its body carries it in Metadata.Props, and re-emitting would duplicate it. + if c.CatalogType != "" && c.CatalogType != CatalogTypeStandard { + props := []oscalTypes_1_1_3.Property{} + if cat.Metadata.Props != nil { + for _, p := range *cat.Metadata.Props { + if p.Name == catalogTypePropName && p.Ns == CCFPropNamespace { + continue + } + props = append(props, p) + } + } + props = append(props, oscalTypes_1_1_3.Property{ + Name: catalogTypePropName, + Ns: CCFPropNamespace, + Value: c.CatalogType, + }) + cat.Metadata.Props = &props + } if len(c.Params) > 0 { params := make([]oscalTypes_1_1_3.Parameter, len(c.Params)) for i, p := range c.Params { @@ -99,6 +146,19 @@ func (c *Catalog) MarshalOscal() *oscalTypes_1_1_3.Catalog { return cat } +// catalogTypeFromOscalProps extracts the ccf catalog-type prop from an OSCAL +// metadata prop list, defaulting to "standard" when absent or unrecognised. +func catalogTypeFromOscalProps(props *[]oscalTypes_1_1_3.Property) string { + if props != nil { + for _, p := range *props { + if p.Name == catalogTypePropName && p.Ns == CCFPropNamespace && IsValidCatalogType(p.Value) { + return p.Value + } + } + } + return CatalogTypeStandard +} + type Group struct { CatalogID uuid.UUID `gorm:"primary_key"` ID string `json:"id" gorm:"primary_key"` // required diff --git a/internal/service/relational/catalog_test.go b/internal/service/relational/catalog_test.go index d43cb6e4..802b95a1 100644 --- a/internal/service/relational/catalog_test.go +++ b/internal/service/relational/catalog_test.go @@ -361,3 +361,97 @@ func TestCatalog_OscalMarshalling(t *testing.T) { assert.JSONEq(t, string(inputJson), string(outputJson)) }) } + +func TestCatalog_CatalogTypeOscalRoundTrip(t *testing.T) { + id := uuid.New() + + // A policy catalog marshals its type as a ccf metadata prop... + cat := Catalog{ + UUIDModel: UUIDModel{ID: &id}, + CatalogType: CatalogTypePolicy, + Metadata: Metadata{Title: "Access Control Policy", Version: "1.0.0", OscalVersion: "1.1.3"}, + } + oscal := cat.MarshalOscal() + if oscal.Metadata.Props == nil { + t.Fatal("expected catalog-type prop to be emitted for a non-standard catalog") + } + found := false + for _, p := range *oscal.Metadata.Props { + if p.Name == "catalog-type" && p.Ns == CCFPropNamespace && p.Value == CatalogTypePolicy { + found = true + } + } + if !found { + t.Fatalf("catalog-type prop not present in marshalled metadata: %+v", oscal.Metadata.Props) + } + + // ...and unmarshalling reads it straight back into the column. + var back Catalog + back.UnmarshalOscal(*oscal) + if back.CatalogType != CatalogTypePolicy { + t.Errorf("round-trip catalog type = %q, want %q", back.CatalogType, CatalogTypePolicy) + } +} + +func TestCatalog_StandardTypeOmitsProp(t *testing.T) { + id := uuid.New() + cat := Catalog{ + UUIDModel: UUIDModel{ID: &id}, + CatalogType: CatalogTypeStandard, + Metadata: Metadata{Title: "NIST 800-53", Version: "1.0.0", OscalVersion: "1.1.3"}, + } + oscal := cat.MarshalOscal() + if oscal.Metadata.Props != nil { + for _, p := range *oscal.Metadata.Props { + if p.Name == "catalog-type" { + t.Error("standard catalog must not emit a catalog-type prop") + } + } + } + // Absent prop unmarshals back to the standard default. + var back Catalog + back.UnmarshalOscal(*oscal) + if back.CatalogType != CatalogTypeStandard { + t.Errorf("default catalog type = %q, want %q", back.CatalogType, CatalogTypeStandard) + } +} + +func TestCatalog_CatalogTypePropNotDuplicatedOnRoundTrip(t *testing.T) { + id := uuid.New() + + // A catalog whose SOURCE metadata already carries the catalog-type prop (the + // documented round-trip path) must not accumulate a second copy on marshal. + cat := Catalog{ + UUIDModel: UUIDModel{ID: &id}, + CatalogType: CatalogTypePolicy, + Metadata: Metadata{ + Title: "Access Control Policy", + Version: "1.0.0", + OscalVersion: "1.1.3", + Props: []Prop{ + {Name: catalogTypePropName, Ns: CCFPropNamespace, Value: CatalogTypePolicy}, + }, + }, + } + + oscal := cat.MarshalOscal() + if oscal.Metadata.Props == nil { + t.Fatal("expected catalog-type prop in marshalled metadata") + } + count := 0 + for _, p := range *oscal.Metadata.Props { + if p.Name == catalogTypePropName && p.Ns == CCFPropNamespace { + count++ + } + } + if count != 1 { + t.Fatalf("expected exactly one catalog-type prop after round-trip, got %d", count) + } + + // And it still unmarshals cleanly back to the column. + var back Catalog + back.UnmarshalOscal(*oscal) + if back.CatalogType != CatalogTypePolicy { + t.Errorf("round-trip catalog type = %q, want %q", back.CatalogType, CatalogTypePolicy) + } +} diff --git a/internal/service/relational/control_link.go b/internal/service/relational/control_link.go new file mode 100644 index 00000000..b554aef6 --- /dev/null +++ b/internal/service/relational/control_link.go @@ -0,0 +1,251 @@ +package relational + +import ( + "fmt" + "time" + + "github.com/google/uuid" +) + +// ControlLink is a typed directed edge between two controls. Direction is always +// concrete -> abstract: the source "satisfies/refines" the target. It mirrors the +// (catalog_id uuid, control_id text) key shape of risk_control_links deliberately; +// do NOT copy the text catalog-id inconsistency that filter_controls carries. +type ControlLink struct { + SourceCatalogID uuid.UUID `json:"sourceCatalogId" gorm:"type:uuid;primaryKey"` + SourceControlID string `json:"sourceControlId" gorm:"type:text;primaryKey"` + TargetCatalogID uuid.UUID `json:"targetCatalogId" gorm:"type:uuid;primaryKey;index:idx_control_links_target"` + TargetControlID string `json:"targetControlId" gorm:"type:text;primaryKey;index:idx_control_links_target"` + RelationshipType string `json:"relationshipType" gorm:"type:text;primaryKey"` + CreatedAt time.Time `json:"createdAt"` + CreatedByID *uuid.UUID `json:"createdById" gorm:"type:uuid"` +} + +func (ControlLink) TableName() string { + return "control_links" +} + +// Relationship vocabulary. Only implements/documents are accepted in this PoC; +// related/supersedes/equivalent are reserved and rejected on create. +const ( + RelationshipImplements = "implements" + RelationshipDocuments = "documents" + RelationshipRelated = "related" + RelationshipSupersedes = "supersedes" + RelationshipEquivalent = "equivalent" +) + +// Source returns the (catalog, control) reference for this link's source end. +func (l ControlLink) Source() ControlRef { + return ControlRef{CatalogID: l.SourceCatalogID, ControlID: l.SourceControlID} +} + +// Target returns the (catalog, control) reference for this link's target end. +func (l ControlLink) Target() ControlRef { + return ControlRef{CatalogID: l.TargetCatalogID, ControlID: l.TargetControlID} +} + +// ControlRef identifies a control node by (catalog, control). It is comparable so +// it can key maps/sets directly in the in-memory lineage graph. +type ControlRef struct { + CatalogID uuid.UUID `json:"catalogId"` + ControlID string `json:"controlId"` +} + +// ValidateRelationship enforces the closed-set vocabulary matrix against the +// catalog types of the two endpoints. Direction is concrete -> abstract. +// +// An "operational-control" is any control in a standard-type catalog (or any +// control not living in a policy/procedure catalog) acting as an implementer, +// so operationally it collapses to sourceType == standard here. +// +// implements: policy -> standard (policy-control implements standard-control) +// standard -> policy (operational-control implements policy-control) +// standard -> standard (operational-control -> standard-control, escape hatch) +// documents: procedure -> policy (procedure-control documents policy-control) +// +// Any other combination — including the reserved relationship types — is rejected. +// A non-nil error here maps to HTTP 422. +func ValidateRelationship(relationshipType, sourceType, targetType string) error { + switch relationshipType { + case RelationshipImplements: + switch { + case sourceType == CatalogTypePolicy && targetType == CatalogTypeStandard: + return nil + case sourceType == CatalogTypeStandard && targetType == CatalogTypePolicy: + return nil + case sourceType == CatalogTypeStandard && targetType == CatalogTypeStandard: + return nil + } + return fmt.Errorf("relationship %q not permitted from %s-control to %s-control", relationshipType, sourceType, targetType) + case RelationshipDocuments: + if sourceType == CatalogTypeProcedure && targetType == CatalogTypePolicy { + return nil + } + return fmt.Errorf("relationship %q only permitted from procedure-control to policy-control (got %s -> %s)", relationshipType, sourceType, targetType) + case RelationshipRelated, RelationshipSupersedes, RelationshipEquivalent: + return fmt.Errorf("relationship %q is reserved and not supported in this PoC", relationshipType) + default: + return fmt.Errorf("unknown relationship type %q", relationshipType) + } +} + +// ControlLinkGraph is an in-memory view of the control_links table. The table is +// small, so lineage loads every edge once and walks it here rather than issuing +// recursive SQL. It is cycle-tolerant: every walk carries a visited set. +type ControlLinkGraph struct { + // implementedBy maps a target control to the sources that `implements` it. + // This is the reverse of the stored edge and is the tree/rollup direction: + // a standard control's children are the controls that implement it. + implementedBy map[ControlRef][]ControlRef + // documentedBy maps a policy control to the procedure controls that `documents` it. + // Structural only — excluded from evidence/risk math per the vocabulary matrix. + documentedBy map[ControlRef][]ControlRef + // forward maps a source to its targets across ALL edge types, used only for + // acyclicity checks on create. + forward map[ControlRef][]ControlRef + // outgoingImplements records, per source, the implements targets — used to + // decide whether a policy control is anchored to a standard control. + outgoingImplements map[ControlRef][]ControlRef +} + +// NewControlLinkGraph indexes a flat edge list into the adjacency maps lineage needs. +func NewControlLinkGraph(links []ControlLink) *ControlLinkGraph { + g := &ControlLinkGraph{ + implementedBy: map[ControlRef][]ControlRef{}, + documentedBy: map[ControlRef][]ControlRef{}, + forward: map[ControlRef][]ControlRef{}, + outgoingImplements: map[ControlRef][]ControlRef{}, + } + for _, l := range links { + s := l.Source() + t := l.Target() + g.forward[s] = append(g.forward[s], t) + switch l.RelationshipType { + case RelationshipImplements: + g.implementedBy[t] = append(g.implementedBy[t], s) + g.outgoingImplements[s] = append(g.outgoingImplements[s], t) + case RelationshipDocuments: + g.documentedBy[t] = append(g.documentedBy[t], s) + } + } + return g +} + +// Children returns the direct lineage children of node: everything that +// `implements` it plus everything that `documents` it, in a stable order +// (implements first, then documents), de-duplicated. +func (g *ControlLinkGraph) Children(node ControlRef) []ControlRef { + seen := map[ControlRef]struct{}{} + children := make([]ControlRef, 0, len(g.implementedBy[node])+len(g.documentedBy[node])) + for _, src := range g.implementedBy[node] { + if _, ok := seen[src]; ok { + continue + } + seen[src] = struct{}{} + children = append(children, src) + } + for _, src := range g.documentedBy[node] { + if _, ok := seen[src]; ok { + continue + } + seen[src] = struct{}{} + children = append(children, src) + } + return children +} + +// ImplementsChildren returns the controls that directly `implements` node. +func (g *ControlLinkGraph) ImplementsChildren(node ControlRef) []ControlRef { + return append([]ControlRef(nil), g.implementedBy[node]...) +} + +// DocumentsChildren returns the controls that directly `documents` node. +func (g *ControlLinkGraph) DocumentsChildren(node ControlRef) []ControlRef { + return append([]ControlRef(nil), g.documentedBy[node]...) +} + +// EvidenceClosure returns the set of controls whose evidence/risk rolls up into +// node: node itself plus every control that implements it transitively. Only +// `implements` edges are followed — `documents` is presence-only and excluded +// from the math. The walk tolerates cycles via the visited set. +func (g *ControlLinkGraph) EvidenceClosure(node ControlRef) map[ControlRef]struct{} { + visited := map[ControlRef]struct{}{} + var walk func(n ControlRef) + walk = func(n ControlRef) { + if _, ok := visited[n]; ok { + return + } + visited[n] = struct{}{} + for _, child := range g.implementedBy[n] { + walk(child) + } + } + walk(node) + return visited +} + +// StructuralClosure is EvidenceClosure widened to also follow `documents` edges. +// Used for structural counts (subtree membership, linkage) that include procedures. +func (g *ControlLinkGraph) StructuralClosure(node ControlRef) map[ControlRef]struct{} { + visited := map[ControlRef]struct{}{} + var walk func(n ControlRef) + walk = func(n ControlRef) { + if _, ok := visited[n]; ok { + return + } + visited[n] = struct{}{} + for _, child := range g.implementedBy[n] { + walk(child) + } + for _, child := range g.documentedBy[n] { + walk(child) + } + } + walk(node) + return visited +} + +// HasOutgoingImplements reports whether node has at least one `implements` edge +// pointing at a control in one of the anchorCatalogs (typically standard catalogs). +// A policy control with no such edge is "unanchored". +func (g *ControlLinkGraph) HasOutgoingImplements(node ControlRef, anchorCatalogs map[uuid.UUID]struct{}) bool { + for _, tgt := range g.outgoingImplements[node] { + if anchorCatalogs == nil { + return true + } + if _, ok := anchorCatalogs[tgt.CatalogID]; ok { + return true + } + } + return false +} + +// WouldCreateCycle reports whether adding source -> target would introduce a +// cycle in the combined (all-relationship) directed graph, i.e. whether target +// can already reach source by following stored edges forward, or the edge is a +// self-loop. Acyclicity is checked across all edge types so no relationship can +// smuggle in a loop. +func (g *ControlLinkGraph) WouldCreateCycle(source, target ControlRef) bool { + if source == target { + return true + } + visited := map[ControlRef]struct{}{} + var reaches func(n ControlRef) bool + reaches = func(n ControlRef) bool { + if n == source { + return true + } + if _, ok := visited[n]; ok { + return false + } + visited[n] = struct{}{} + for _, next := range g.forward[n] { + if reaches(next) { + return true + } + } + return false + } + return reaches(target) +} diff --git a/internal/service/relational/control_link_test.go b/internal/service/relational/control_link_test.go new file mode 100644 index 00000000..8c7cbe17 --- /dev/null +++ b/internal/service/relational/control_link_test.go @@ -0,0 +1,206 @@ +package relational + +import ( + "testing" + + "github.com/google/uuid" +) + +var ( + standardCat = uuid.MustParse("11111111-1111-1111-1111-111111111111") + policyCat = uuid.MustParse("22222222-2222-2222-2222-222222222222") + procedureCat = uuid.MustParse("33333333-3333-3333-3333-333333333333") +) + +func ref(cat uuid.UUID, id string) ControlRef { return ControlRef{CatalogID: cat, ControlID: id} } + +func implementsLink(s, t ControlRef) ControlLink { + return ControlLink{ + SourceCatalogID: s.CatalogID, SourceControlID: s.ControlID, + TargetCatalogID: t.CatalogID, TargetControlID: t.ControlID, + RelationshipType: RelationshipImplements, + } +} + +func documentsLink(s, t ControlRef) ControlLink { + return ControlLink{ + SourceCatalogID: s.CatalogID, SourceControlID: s.ControlID, + TargetCatalogID: t.CatalogID, TargetControlID: t.ControlID, + RelationshipType: RelationshipDocuments, + } +} + +func TestValidateRelationshipMatrix(t *testing.T) { + cases := []struct { + name string + rel string + sourceType string + targetType string + wantErr bool + }{ + // implements — valid rows + {"policy implements standard", RelationshipImplements, CatalogTypePolicy, CatalogTypeStandard, false}, + {"operational implements policy", RelationshipImplements, CatalogTypeStandard, CatalogTypePolicy, false}, + {"operational implements standard (escape hatch)", RelationshipImplements, CatalogTypeStandard, CatalogTypeStandard, false}, + // implements — invalid rows + {"procedure implements policy", RelationshipImplements, CatalogTypeProcedure, CatalogTypePolicy, true}, + {"policy implements policy", RelationshipImplements, CatalogTypePolicy, CatalogTypePolicy, true}, + {"policy implements procedure", RelationshipImplements, CatalogTypePolicy, CatalogTypeProcedure, true}, + {"procedure implements standard", RelationshipImplements, CatalogTypeProcedure, CatalogTypeStandard, true}, + // documents — valid row + {"procedure documents policy", RelationshipDocuments, CatalogTypeProcedure, CatalogTypePolicy, false}, + // documents — invalid rows + {"policy documents standard", RelationshipDocuments, CatalogTypePolicy, CatalogTypeStandard, true}, + {"standard documents policy", RelationshipDocuments, CatalogTypeStandard, CatalogTypePolicy, true}, + {"procedure documents standard", RelationshipDocuments, CatalogTypeProcedure, CatalogTypeStandard, true}, + // reserved vocabulary — always rejected + {"related reserved", RelationshipRelated, CatalogTypePolicy, CatalogTypeStandard, true}, + {"supersedes reserved", RelationshipSupersedes, CatalogTypeStandard, CatalogTypeStandard, true}, + {"equivalent reserved", RelationshipEquivalent, CatalogTypePolicy, CatalogTypeStandard, true}, + // unknown relationship + {"unknown relationship", "invents", CatalogTypePolicy, CatalogTypeStandard, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateRelationship(tc.rel, tc.sourceType, tc.targetType) + if tc.wantErr && err == nil { + t.Fatalf("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + } +} + +func TestWouldCreateCycle(t *testing.T) { + a := ref(standardCat, "a") + b := ref(policyCat, "b") + c := ref(standardCat, "c") + + // Existing DAG: a -> b -> c (forward edges). + g := NewControlLinkGraph([]ControlLink{ + {SourceCatalogID: a.CatalogID, SourceControlID: a.ControlID, TargetCatalogID: b.CatalogID, TargetControlID: b.ControlID, RelationshipType: RelationshipImplements}, + {SourceCatalogID: b.CatalogID, SourceControlID: b.ControlID, TargetCatalogID: c.CatalogID, TargetControlID: c.ControlID, RelationshipType: RelationshipImplements}, + }) + + if !g.WouldCreateCycle(c, a) { + t.Error("c -> a should close the a->b->c chain into a cycle") + } + if g.WouldCreateCycle(a, c) { + t.Error("a -> c is a DAG shortcut, not a cycle") + } + if !g.WouldCreateCycle(a, a) { + t.Error("self-loop a -> a must be rejected") + } + // A brand new edge touching disconnected nodes is fine. + if g.WouldCreateCycle(ref(policyCat, "x"), ref(standardCat, "y")) { + t.Error("disconnected edge should not be a cycle") + } +} + +func TestEvidenceClosureDiamondAndEscapeHatch(t *testing.T) { + s := ref(standardCat, "s") + p1 := ref(policyCat, "p1") + p2 := ref(policyCat, "p2") + o := ref(standardCat, "o") // operational control implementing both policies + o2 := ref(standardCat, "o2") // operational control implementing the standard directly + + g := NewControlLinkGraph([]ControlLink{ + implementsLink(p1, s), // policy implements standard + implementsLink(p2, s), // policy implements standard + implementsLink(o, p1), // operational implements policy + implementsLink(o, p2), // operational implements policy (diamond join) + implementsLink(o2, s), // escape hatch: operational implements standard directly + }) + + closure := g.EvidenceClosure(s) + want := []ControlRef{s, p1, p2, o, o2} + if len(closure) != len(want) { + t.Fatalf("closure size = %d, want %d (%v)", len(closure), len(want), closure) + } + for _, w := range want { + if _, ok := closure[w]; !ok { + t.Errorf("closure missing %v", w) + } + } +} + +func TestEvidenceClosureOrphanPolicy(t *testing.T) { + s := ref(standardCat, "s") + anchored := ref(policyCat, "anchored") + orphan := ref(policyCat, "orphan") // no implements edge to any standard + + g := NewControlLinkGraph([]ControlLink{ + implementsLink(anchored, s), + }) + + // Orphan's closure is just itself. + oc := g.EvidenceClosure(orphan) + if len(oc) != 1 { + t.Fatalf("orphan closure size = %d, want 1", len(oc)) + } + if _, ok := oc[orphan]; !ok { + t.Errorf("orphan closure must contain itself") + } + + // Orphan does not leak into the standard's closure. + sc := g.EvidenceClosure(s) + if _, ok := sc[orphan]; ok { + t.Errorf("standard closure should not include the orphan policy") + } + + // Unanchored detection: orphan has no implements edge to a standard catalog. + standards := map[uuid.UUID]struct{}{standardCat: {}} + if g.HasOutgoingImplements(orphan, standards) { + t.Errorf("orphan policy must be reported as unanchored") + } + if !g.HasOutgoingImplements(anchored, standards) { + t.Errorf("anchored policy must be reported as anchored") + } +} + +func TestEvidenceClosureToleratesCycles(t *testing.T) { + s := ref(standardCat, "s") + p := ref(policyCat, "p") + + // Pathological cycle: p implements s AND s "implements" p. The walk must terminate. + g := NewControlLinkGraph([]ControlLink{ + implementsLink(p, s), + implementsLink(s, p), + }) + + closure := g.EvidenceClosure(s) + if len(closure) != 2 { + t.Fatalf("cyclic closure size = %d, want 2", len(closure)) + } + if _, ok := closure[s]; !ok { + t.Errorf("closure missing s") + } + if _, ok := closure[p]; !ok { + t.Errorf("closure missing p") + } +} + +func TestStructuralClosureIncludesDocuments(t *testing.T) { + s := ref(standardCat, "s") + p := ref(policyCat, "p") + proc := ref(procedureCat, "proc") + + g := NewControlLinkGraph([]ControlLink{ + implementsLink(p, s), + documentsLink(proc, p), + }) + + // EvidenceClosure follows implements only: proc is excluded. + ec := g.EvidenceClosure(s) + if _, ok := ec[proc]; ok { + t.Errorf("evidence closure must exclude documents-linked procedures") + } + + // StructuralClosure follows documents too: proc is included. + sc := g.StructuralClosure(s) + if _, ok := sc[proc]; !ok { + t.Errorf("structural closure must include documents-linked procedures") + } +} diff --git a/internal/service/relational/evidence.go b/internal/service/relational/evidence.go index 03f58a7e..7f7899cd 100644 --- a/internal/service/relational/evidence.go +++ b/internal/service/relational/evidence.go @@ -60,6 +60,131 @@ type Evidence struct { Status datatypes.JSONType[oscalTypes_1_1_3.ObjectiveStatus] `json:"status"` } +// StatusCount is one (status-state, distinct-stream-count) row of an evidence +// status rollup. It mirrors the ad-hoc struct in profile_compliance.go so the +// lineage rollups and the profile compliance endpoint speak the same shape. +type StatusCount struct { + Count int64 `json:"count"` + Status string `json:"status"` +} + +// latestEvidenceStreamsCTE builds the "latest evidence per stream" set as a +// MATERIALIZED CTE named `latest(id, uuid, state)` using a loose index scan: +// distinct stream uuids + a lateral pick of the most-recent row per uuid over the +// (uuid, "end" DESC) index. This avoids sorting the entire evidences table (a +// DISTINCT ON * over hundreds of thousands of rows spills to disk and costs ~1s+). +// componentID, when set, restricts to streams observed on that system component. +func latestEvidenceStreamsCTE(componentID *uuid.UUID) (string, []any) { + componentFilter := "" + var args []any + if componentID != nil { + componentFilter = "WHERE EXISTS (SELECT 1 FROM evidence_components ec WHERE ec.evidence_id = l.id AND ec.system_component_id = ?)" + args = append(args, *componentID) + } + cte := `WITH latest AS MATERIALIZED ( + SELECT l.id, u.uuid, l.status->>'state' AS state + FROM (SELECT DISTINCT uuid FROM evidences) u + CROSS JOIN LATERAL ( + SELECT e.id, e.status FROM evidences e WHERE e.uuid = u.uuid ORDER BY e."end" DESC LIMIT 1 + ) l + ` + componentFilter + ` + )` + return cte, args +} + +// GetEvidenceStatusCountsByFilters computes latest-evidence status counts for a +// batch of label filters keyed by an opaque id (typically the Filter row UUID), +// so the lineage rollups can resolve every in-scope control's compliance without +// an N+1 of one query per control/filter. +// +// Semantics match profile_compliance.getStatusCountsForFilters: it counts DISTINCT +// evidence streams grouped by status state over the latest evidence in each stream. +// componentID, when set, restricts to evidence observed on that system component. +// +// Performance: rather than run one label-scoped SQL aggregation per filter (which +// re-derives the latest-per-stream set every time — O(filters) full scans, tens of +// seconds on real data), this loads the latest streams and their labels ONCE and +// evaluates every filter in memory via labelfilter.MatchLabels (whose semantics +// mirror the SQL evaluator). That collapses the DB work to a single query and keeps +// the per-filter cost to cheap in-Go boolean checks. Postgres-first (loose index +// scan + MATERIALIZED CTE). +func GetEvidenceStatusCountsByFilters(db *gorm.DB, filters map[uuid.UUID]labelfilter.Filter, componentID *uuid.UUID) (map[uuid.UUID][]StatusCount, error) { + result := make(map[uuid.UUID][]StatusCount, len(filters)) + if len(filters) == 0 { + return result, nil + } + for id := range filters { + result[id] = []StatusCount{} // ensure every filter has an entry, even with zero matches + } + + // 1) Load the latest evidence per stream + its labels in one pass. + cte, args := latestEvidenceStreamsCTE(componentID) + query := cte + ` + SELECT l.uuid AS uuid, l.state AS state, el.labels_name AS name, el.labels_value AS value + FROM latest l LEFT JOIN evidence_labels el ON el.evidence_id = l.id` + + rows := []struct { + UUID uuid.UUID `gorm:"column:uuid"` + State string `gorm:"column:state"` + Name *string `gorm:"column:name"` + Value *string `gorm:"column:value"` + }{} + if err := db.Raw(query, args...).Scan(&rows).Error; err != nil { + return nil, err + } + + // 2) Group rows into per-stream {state, normalized labels}. + type stream struct { + state string + labels map[string][]string + } + streams := map[uuid.UUID]*stream{} + for _, r := range rows { + s := streams[r.UUID] + if s == nil { + s = &stream{state: r.State, labels: map[string][]string{}} + streams[r.UUID] = s + } + if r.Name != nil { + key := strings.ToLower(strings.TrimSpace(*r.Name)) + if key == "" { + continue + } + val := "" + if r.Value != nil { + val = strings.ToLower(strings.TrimSpace(*r.Value)) + } + s.labels[key] = append(s.labels[key], val) + } + } + + streamList := make([]*stream, 0, len(streams)) + for _, s := range streams { + streamList = append(streamList, s) + } + + // 3) Evaluate every filter against every latest stream in memory. Each stream + // is one distinct uuid, so a matching stream is one distinct-stream count. + for id, filter := range filters { + counts := map[string]int64{} + for _, s := range streamList { + match, err := labelfilter.MatchLabels(filter.Scope, s.labels) + if err != nil { + return nil, err + } + if match { + counts[s.state]++ + } + } + sc := make([]StatusCount, 0, len(counts)) + for state, c := range counts { + sc = append(sc, StatusCount{Count: c, Status: state}) + } + result[id] = sc + } + return result, nil +} + func GetLatestEvidenceStreamsQuery(db *gorm.DB) *gorm.DB { query := db. Model(&Evidence{}). diff --git a/internal/tests/migrate.go b/internal/tests/migrate.go index 9f4e8390..b9f452c7 100644 --- a/internal/tests/migrate.go +++ b/internal/tests/migrate.go @@ -56,6 +56,7 @@ func (t *TestMigrator) Up() error { &relational.Group{}, &relational.Control{}, &relational.Catalog{}, + &relational.ControlLink{}, &relational.ControlStatementImplementation{}, &relational.ImplementedRequirementControlImplementation{}, &relational.ControlImplementationSet{},