diff --git a/docs/docs.go b/docs/docs.go index 28d287c2..9281efae 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -11941,6 +11941,76 @@ const docTemplate = `{ ] } }, + "/oscal/profiles/{id}/compliance-progress": { + "get": { + "description": "Returns aggregated compliance progress for controls in a Profile, including summary, optional per-control rows, and group rollups.", + "produces": [ + "application/json" + ], + "tags": [ + "Profile" + ], + "summary": "Get compliance progress for a Profile", + "parameters": [ + { + "type": "string", + "description": "Profile ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Include per-control breakdown (default true)", + "name": "includeControls", + "in": "query" + }, + { + "type": "string", + "description": "System Security Plan ID for implementation coverage", + "name": "sspId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscal_ProfileComplianceProgress" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/oscal/profiles/{id}/full": { "get": { "description": "Retrieves the full OSCAL Profile, including all nested content.", @@ -21369,6 +21439,19 @@ const docTemplate = `{ } } }, + "handler.GenericDataResponse-oscal_ProfileComplianceProgress": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/oscal.ProfileComplianceProgress" + } + ] + } + } + }, "handler.GenericDataResponse-oscal_ProfileHandler": { "type": "object", "properties": { @@ -21845,6 +21928,155 @@ const docTemplate = `{ "MatchStrategyAny" ] }, + "oscal.ProfileComplianceControl": { + "type": "object", + "properties": { + "catalogId": { + "type": "string" + }, + "computedStatus": { + "type": "string" + }, + "controlId": { + "type": "string" + }, + "groupId": { + "type": "string" + }, + "groupTitle": { + "type": "string" + }, + "implemented": { + "type": "boolean" + }, + "statusCounts": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.ProfileComplianceStatusCount" + } + }, + "title": { + "type": "string" + } + } + }, + "oscal.ProfileComplianceGroup": { + "type": "object", + "properties": { + "compliancePercent": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "notSatisfied": { + "type": "integer" + }, + "satisfied": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "totalControls": { + "type": "integer" + }, + "unknown": { + "type": "integer" + } + } + }, + "oscal.ProfileComplianceImplementation": { + "type": "object", + "properties": { + "implementationPercent": { + "type": "integer" + }, + "implementedControls": { + "type": "integer" + }, + "unimplementedControls": { + "type": "integer" + } + } + }, + "oscal.ProfileComplianceProgress": { + "type": "object", + "properties": { + "controls": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.ProfileComplianceControl" + } + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.ProfileComplianceGroup" + } + }, + "implementation": { + "$ref": "#/definitions/oscal.ProfileComplianceImplementation" + }, + "scope": { + "$ref": "#/definitions/oscal.ProfileComplianceScope" + }, + "summary": { + "$ref": "#/definitions/oscal.ProfileComplianceSummary" + } + } + }, + "oscal.ProfileComplianceScope": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "oscal.ProfileComplianceStatusCount": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "status": { + "type": "string" + } + } + }, + "oscal.ProfileComplianceSummary": { + "type": "object", + "properties": { + "assessedPercent": { + "type": "integer" + }, + "compliancePercent": { + "type": "integer" + }, + "implementedControls": { + "type": "integer" + }, + "notSatisfied": { + "type": "integer" + }, + "satisfied": { + "type": "integer" + }, + "totalControls": { + "type": "integer" + }, + "unknown": { + "type": "integer" + } + } + }, "oscal.ProfileHandler": { "type": "object" }, diff --git a/docs/swagger.json b/docs/swagger.json index fa017b39..9531dbff 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -11935,6 +11935,76 @@ ] } }, + "/oscal/profiles/{id}/compliance-progress": { + "get": { + "description": "Returns aggregated compliance progress for controls in a Profile, including summary, optional per-control rows, and group rollups.", + "produces": [ + "application/json" + ], + "tags": [ + "Profile" + ], + "summary": "Get compliance progress for a Profile", + "parameters": [ + { + "type": "string", + "description": "Profile ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Include per-control breakdown (default true)", + "name": "includeControls", + "in": "query" + }, + { + "type": "string", + "description": "System Security Plan ID for implementation coverage", + "name": "sspId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscal_ProfileComplianceProgress" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/oscal/profiles/{id}/full": { "get": { "description": "Retrieves the full OSCAL Profile, including all nested content.", @@ -21363,6 +21433,19 @@ } } }, + "handler.GenericDataResponse-oscal_ProfileComplianceProgress": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/oscal.ProfileComplianceProgress" + } + ] + } + } + }, "handler.GenericDataResponse-oscal_ProfileHandler": { "type": "object", "properties": { @@ -21839,6 +21922,155 @@ "MatchStrategyAny" ] }, + "oscal.ProfileComplianceControl": { + "type": "object", + "properties": { + "catalogId": { + "type": "string" + }, + "computedStatus": { + "type": "string" + }, + "controlId": { + "type": "string" + }, + "groupId": { + "type": "string" + }, + "groupTitle": { + "type": "string" + }, + "implemented": { + "type": "boolean" + }, + "statusCounts": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.ProfileComplianceStatusCount" + } + }, + "title": { + "type": "string" + } + } + }, + "oscal.ProfileComplianceGroup": { + "type": "object", + "properties": { + "compliancePercent": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "notSatisfied": { + "type": "integer" + }, + "satisfied": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "totalControls": { + "type": "integer" + }, + "unknown": { + "type": "integer" + } + } + }, + "oscal.ProfileComplianceImplementation": { + "type": "object", + "properties": { + "implementationPercent": { + "type": "integer" + }, + "implementedControls": { + "type": "integer" + }, + "unimplementedControls": { + "type": "integer" + } + } + }, + "oscal.ProfileComplianceProgress": { + "type": "object", + "properties": { + "controls": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.ProfileComplianceControl" + } + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/oscal.ProfileComplianceGroup" + } + }, + "implementation": { + "$ref": "#/definitions/oscal.ProfileComplianceImplementation" + }, + "scope": { + "$ref": "#/definitions/oscal.ProfileComplianceScope" + }, + "summary": { + "$ref": "#/definitions/oscal.ProfileComplianceSummary" + } + } + }, + "oscal.ProfileComplianceScope": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "oscal.ProfileComplianceStatusCount": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "status": { + "type": "string" + } + } + }, + "oscal.ProfileComplianceSummary": { + "type": "object", + "properties": { + "assessedPercent": { + "type": "integer" + }, + "compliancePercent": { + "type": "integer" + }, + "implementedControls": { + "type": "integer" + }, + "notSatisfied": { + "type": "integer" + }, + "satisfied": { + "type": "integer" + }, + "totalControls": { + "type": "integer" + }, + "unknown": { + "type": "integer" + } + } + }, "oscal.ProfileHandler": { "type": "object" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 74240bd6..ccae0933 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -813,6 +813,13 @@ definitions: - $ref: '#/definitions/oscal.InventoryItemWithSource' description: Items from the list response type: object + handler.GenericDataResponse-oscal_ProfileComplianceProgress: + properties: + data: + allOf: + - $ref: '#/definitions/oscal.ProfileComplianceProgress' + description: Items from the list response + type: object handler.GenericDataResponse-oscal_ProfileHandler: properties: data: @@ -1484,6 +1491,103 @@ definitions: x-enum-varnames: - MatchStrategyAll - MatchStrategyAny + oscal.ProfileComplianceControl: + properties: + catalogId: + type: string + computedStatus: + type: string + controlId: + type: string + groupId: + type: string + groupTitle: + type: string + implemented: + type: boolean + statusCounts: + items: + $ref: '#/definitions/oscal.ProfileComplianceStatusCount' + type: array + title: + type: string + type: object + oscal.ProfileComplianceGroup: + properties: + compliancePercent: + type: integer + id: + type: string + notSatisfied: + type: integer + satisfied: + type: integer + title: + type: string + totalControls: + type: integer + unknown: + type: integer + type: object + oscal.ProfileComplianceImplementation: + properties: + implementationPercent: + type: integer + implementedControls: + type: integer + unimplementedControls: + type: integer + type: object + oscal.ProfileComplianceProgress: + properties: + controls: + items: + $ref: '#/definitions/oscal.ProfileComplianceControl' + type: array + groups: + items: + $ref: '#/definitions/oscal.ProfileComplianceGroup' + type: array + implementation: + $ref: '#/definitions/oscal.ProfileComplianceImplementation' + scope: + $ref: '#/definitions/oscal.ProfileComplianceScope' + summary: + $ref: '#/definitions/oscal.ProfileComplianceSummary' + type: object + oscal.ProfileComplianceScope: + properties: + id: + type: string + title: + type: string + type: + type: string + type: object + oscal.ProfileComplianceStatusCount: + properties: + count: + type: integer + status: + type: string + type: object + oscal.ProfileComplianceSummary: + properties: + assessedPercent: + type: integer + compliancePercent: + type: integer + implementedControls: + type: integer + notSatisfied: + type: integer + satisfied: + type: integer + totalControls: + type: integer + unknown: + type: integer + type: object oscal.ProfileHandler: type: object oscal.RuleOperator: @@ -14927,6 +15031,52 @@ paths: summary: Get Backmatter tags: - Profile + /oscal/profiles/{id}/compliance-progress: + get: + description: Returns aggregated compliance progress for controls in a Profile, + including summary, optional per-control rows, and group rollups. + parameters: + - description: Profile ID + in: path + name: id + required: true + type: string + - description: Include per-control breakdown (default true) + in: query + name: includeControls + type: boolean + - description: System Security Plan ID for implementation coverage + in: query + name: sspId + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-oscal_ProfileComplianceProgress' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Get compliance progress for a Profile + tags: + - Profile /oscal/profiles/{id}/full: get: description: Retrieves the full OSCAL Profile, including all nested content. diff --git a/internal/api/handler/oscal/profile_compliance.go b/internal/api/handler/oscal/profile_compliance.go new file mode 100644 index 00000000..08b43075 --- /dev/null +++ b/internal/api/handler/oscal/profile_compliance.go @@ -0,0 +1,494 @@ +package oscal + +import ( + "errors" + "math" + "net/http" + "sort" + "strconv" + "strings" + + "github.com/compliance-framework/api/internal/api" + "github.com/compliance-framework/api/internal/api/handler" + "github.com/compliance-framework/api/internal/converters/labelfilter" + "github.com/compliance-framework/api/internal/service/relational" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "gorm.io/gorm" +) + +type ProfileComplianceProgress struct { + Scope ProfileComplianceScope `json:"scope"` + Summary ProfileComplianceSummary `json:"summary"` + Implementation *ProfileComplianceImplementation `json:"implementation,omitempty"` + Groups []ProfileComplianceGroup `json:"groups"` + Controls []ProfileComplianceControl `json:"controls"` +} + +type ProfileComplianceScope struct { + Type string `json:"type"` + ID uuid.UUID `json:"id"` + Title string `json:"title"` +} + +type ProfileComplianceSummary struct { + TotalControls int `json:"totalControls"` + Satisfied int `json:"satisfied"` + NotSatisfied int `json:"notSatisfied"` + Unknown int `json:"unknown"` + CompliancePct int `json:"compliancePercent"` + AssessedPct int `json:"assessedPercent"` + ImplementedTotal *int `json:"implementedControls,omitempty"` +} + +type ProfileComplianceImplementation struct { + ImplementedControls int `json:"implementedControls"` + ImplementationPct int `json:"implementationPercent"` + UnimplementedControls int `json:"unimplementedControls"` +} + +type ProfileComplianceGroup struct { + ID string `json:"id"` + Title string `json:"title"` + TotalControls int `json:"totalControls"` + Satisfied int `json:"satisfied"` + NotSatisfied int `json:"notSatisfied"` + Unknown int `json:"unknown"` + CompliancePct int `json:"compliancePercent"` +} + +type ProfileComplianceControl struct { + ControlID string `json:"controlId"` + CatalogID uuid.UUID `json:"catalogId"` + Title string `json:"title"` + GroupID string `json:"groupId,omitempty"` + GroupTitle string `json:"groupTitle,omitempty"` + Implemented *bool `json:"implemented,omitempty"` + StatusCounts []ProfileComplianceStatusCount `json:"statusCounts"` + ComputedStatus string `json:"computedStatus"` +} + +type ProfileComplianceStatusCount struct { + Count int64 `json:"count"` + Status string `json:"status"` +} + +type profileControlKey struct { + CatalogID uuid.UUID + ControlID string +} + +type profileComplianceControlScope struct { + ControlID string + CatalogID uuid.UUID + Title string + GroupID string + GroupTitle string +} + +type profileComplianceGroupAccumulator struct { + ID string + Title string + TotalControls int + Satisfied int + NotSatisfied int + Unknown int +} + +// ComplianceProgress godoc +// +// @Summary Get compliance progress for a Profile +// @Description Returns aggregated compliance progress for controls in a Profile, including summary, optional per-control rows, and group rollups. +// @Tags Profile +// @Param id path string true "Profile ID" +// @Param includeControls query bool false "Include per-control breakdown (default true)" +// @Param sspId query string false "System Security Plan ID for implementation coverage" +// @Produce json +// @Success 200 {object} handler.GenericDataResponse[oscal.ProfileComplianceProgress] +// @Failure 400 {object} api.Error +// @Failure 401 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/profiles/{id}/compliance-progress [get] +func (h *ProfileHandler) ComplianceProgress(ctx echo.Context) error { + idParam := ctx.Param("id") + id, err := uuid.Parse(idParam) + if err != nil { + h.sugar.Warnw("error parsing UUID", "id", idParam, "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + includeControls := true + includeControlsParam := strings.TrimSpace(ctx.QueryParam("includeControls")) + if includeControlsParam != "" { + parsed, parseErr := strconv.ParseBool(includeControlsParam) + if parseErr != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(parseErr)) + } + includeControls = parsed + } + + profile, err := FindFullProfile(h.db, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(err)) + } + h.sugar.Errorw("error finding profile", "id", idParam, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + catalog, err := GetControlCatalogFromBuiltProfile(profile, h.db) + if err != nil { + h.sugar.Errorw("error building control catalog", "id", id, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + scopeControls := flattenProfileComplianceControls(catalog) + + response := ProfileComplianceProgress{ + Scope: ProfileComplianceScope{ + Type: "profile", + ID: id, + Title: profile.Metadata.Title, + }, + Summary: ProfileComplianceSummary{ + TotalControls: len(scopeControls), + }, + Groups: []ProfileComplianceGroup{}, + Controls: []ProfileComplianceControl{}, + } + + if len(scopeControls) == 0 { + return ctx.JSON(http.StatusOK, handler.GenericDataResponse[ProfileComplianceProgress]{Data: response}) + } + + filtersByControl, err := h.loadFiltersByControl(scopeControls) + if err != nil { + h.sugar.Errorw("failed to load filters for profile controls", "profileID", id, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + sspImplementedControls := map[string]struct{}{} + hasImplementationScope := false + sspIDParam := strings.TrimSpace(ctx.QueryParam("sspId")) + if sspIDParam != "" { + sspID, parseErr := uuid.Parse(sspIDParam) + if parseErr != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(parseErr)) + } + + sspImplementedControls, err = h.loadImplementedControlsForSSP(sspID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(err)) + } + h.sugar.Errorw("failed to load implemented requirements for SSP", "sspID", sspID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + hasImplementationScope = true + } + + groups := map[string]*profileComplianceGroupAccumulator{} + controls := make([]ProfileComplianceControl, 0, len(scopeControls)) + + satisfied := 0 + notSatisfied := 0 + unknown := 0 + implementedControls := 0 + + for _, scopedControl := range scopeControls { + controlKey := profileControlKey{CatalogID: scopedControl.CatalogID, ControlID: scopedControl.ControlID} + statusCounts, statusErr := h.getStatusCountsForFilters(filtersByControl[controlKey]) + if statusErr != nil { + h.sugar.Errorw("failed to compute status counts for control", "catalogID", scopedControl.CatalogID, "controlID", scopedControl.ControlID, "error", statusErr) + return ctx.JSON(http.StatusInternalServerError, api.NewError(statusErr)) + } + + computedStatus := computeProfileControlStatus(statusCounts) + switch computedStatus { + case "satisfied": + satisfied++ + case "not-satisfied": + notSatisfied++ + default: + unknown++ + } + + if scopedControl.GroupID != "" { + group, ok := groups[scopedControl.GroupID] + if !ok { + group = &profileComplianceGroupAccumulator{ + ID: scopedControl.GroupID, + Title: scopedControl.GroupTitle, + } + groups[scopedControl.GroupID] = group + } + group.TotalControls++ + switch computedStatus { + case "satisfied": + group.Satisfied++ + case "not-satisfied": + group.NotSatisfied++ + default: + group.Unknown++ + } + } + + controlResponse := ProfileComplianceControl{ + ControlID: scopedControl.ControlID, + CatalogID: scopedControl.CatalogID, + Title: scopedControl.Title, + GroupID: scopedControl.GroupID, + GroupTitle: scopedControl.GroupTitle, + StatusCounts: statusCounts, + ComputedStatus: computedStatus, + } + + if hasImplementationScope { + implemented := false + if _, ok := sspImplementedControls[scopedControl.ControlID]; ok { + implemented = true + implementedControls++ + } + controlResponse.Implemented = &implemented + } + + if includeControls { + controls = append(controls, controlResponse) + } + } + + summary := ProfileComplianceSummary{ + TotalControls: len(scopeControls), + Satisfied: satisfied, + NotSatisfied: notSatisfied, + Unknown: unknown, + CompliancePct: computePercent(satisfied, len(scopeControls)), + AssessedPct: computePercent(satisfied+notSatisfied, len(scopeControls)), + } + if hasImplementationScope { + summary.ImplementedTotal = &implementedControls + } + response.Summary = summary + + groupResponse := make([]ProfileComplianceGroup, 0, len(groups)) + for _, group := range groups { + groupResponse = append(groupResponse, ProfileComplianceGroup{ + ID: group.ID, + Title: group.Title, + TotalControls: group.TotalControls, + Satisfied: group.Satisfied, + NotSatisfied: group.NotSatisfied, + Unknown: group.Unknown, + CompliancePct: computePercent(group.Satisfied, group.TotalControls), + }) + } + + sort.Slice(groupResponse, func(i, j int) bool { + if groupResponse[i].Title == groupResponse[j].Title { + return groupResponse[i].ID < groupResponse[j].ID + } + return groupResponse[i].Title < groupResponse[j].Title + }) + + response.Groups = groupResponse + + if includeControls { + sort.Slice(controls, func(i, j int) bool { + if controls[i].GroupTitle == controls[j].GroupTitle { + if controls[i].ControlID == controls[j].ControlID { + return controls[i].CatalogID.String() < controls[j].CatalogID.String() + } + return controls[i].ControlID < controls[j].ControlID + } + return controls[i].GroupTitle < controls[j].GroupTitle + }) + response.Controls = controls + } + + if hasImplementationScope { + response.Implementation = &ProfileComplianceImplementation{ + ImplementedControls: implementedControls, + ImplementationPct: computePercent(implementedControls, len(scopeControls)), + UnimplementedControls: len(scopeControls) - implementedControls, + } + } + + return ctx.JSON(http.StatusOK, handler.GenericDataResponse[ProfileComplianceProgress]{Data: response}) +} + +func flattenProfileComplianceControls(catalog *relational.Catalog) []profileComplianceControlScope { + if catalog == nil { + return []profileComplianceControlScope{} + } + + controlsByKey := map[profileControlKey]profileComplianceControlScope{} + + var collectControls func(controls []relational.Control, groupID, groupTitle string) + collectControls = func(controls []relational.Control, groupID, groupTitle string) { + for _, control := range controls { + key := profileControlKey{CatalogID: control.CatalogID, ControlID: control.ID} + if _, exists := controlsByKey[key]; !exists { + controlsByKey[key] = profileComplianceControlScope{ + ControlID: control.ID, + CatalogID: control.CatalogID, + Title: control.Title, + GroupID: groupID, + GroupTitle: groupTitle, + } + } + + if len(control.Controls) > 0 { + collectControls(control.Controls, groupID, groupTitle) + } + } + } + + var collectGroups func(groups []relational.Group, rootGroupID, rootGroupTitle string) + collectGroups = func(groups []relational.Group, rootGroupID, rootGroupTitle string) { + for _, group := range groups { + topGroupID := rootGroupID + topGroupTitle := rootGroupTitle + if topGroupID == "" { + topGroupID = group.ID + topGroupTitle = group.Title + } + + collectControls(group.Controls, topGroupID, topGroupTitle) + if len(group.Groups) > 0 { + collectGroups(group.Groups, topGroupID, topGroupTitle) + } + } + } + + collectControls(catalog.Controls, "", "") + collectGroups(catalog.Groups, "", "") + + flattened := make([]profileComplianceControlScope, 0, len(controlsByKey)) + for _, control := range controlsByKey { + flattened = append(flattened, control) + } + + sort.Slice(flattened, func(i, j int) bool { + if flattened[i].GroupTitle == flattened[j].GroupTitle { + if flattened[i].ControlID == flattened[j].ControlID { + return flattened[i].CatalogID.String() < flattened[j].CatalogID.String() + } + return flattened[i].ControlID < flattened[j].ControlID + } + return flattened[i].GroupTitle < flattened[j].GroupTitle + }) + + return flattened +} + +func (h *ProfileHandler) loadFiltersByControl(scopeControls []profileComplianceControlScope) (map[profileControlKey][]labelfilter.Filter, error) { + filtersByControl := make(map[profileControlKey][]labelfilter.Filter, len(scopeControls)) + if len(scopeControls) == 0 { + return filtersByControl, nil + } + + query := h.db.Model(&relational.Control{}).Preload("Filters") + for idx, scopeControl := range scopeControls { + condition := h.db.Where("catalog_id = ? AND id = ?", scopeControl.CatalogID, scopeControl.ControlID) + if idx == 0 { + query = query.Where(condition) + } else { + query = query.Or(condition) + } + } + + controls := []relational.Control{} + if err := query.Find(&controls).Error; err != nil { + return nil, err + } + + for _, control := range controls { + key := profileControlKey{CatalogID: control.CatalogID, ControlID: control.ID} + filters := make([]labelfilter.Filter, 0, len(control.Filters)) + for _, filter := range control.Filters { + filters = append(filters, filter.Filter.Data()) + } + filtersByControl[key] = filters + } + + return filtersByControl, nil +} + +func (h *ProfileHandler) getStatusCountsForFilters(filters []labelfilter.Filter) ([]ProfileComplianceStatusCount, error) { + if len(filters) == 0 { + return []ProfileComplianceStatusCount{}, nil + } + + latestQuery := h.db.Session(&gorm.Session{}) + latestQuery = relational.GetLatestEvidenceStreamsQuery(latestQuery) + query, err := relational.GetEvidenceSearchByFilterQuery(latestQuery, h.db, filters...) + if err != nil { + return nil, err + } + + rows := []ProfileComplianceStatusCount{} + if err := query.Model(&relational.Evidence{}). + Select("count(*) as count, status->>'state' as status"). + Group("status->>'state'"). + Scan(&rows).Error; err != nil { + return nil, err + } + + sort.Slice(rows, func(i, j int) bool { + return rows[i].Status < rows[j].Status + }) + + return rows, nil +} + +func (h *ProfileHandler) loadImplementedControlsForSSP(sspID uuid.UUID) (map[string]struct{}, error) { + var ssp relational.SystemSecurityPlan + if err := h.db. + Preload("ControlImplementation.ImplementedRequirements.Statements"). + First(&ssp, "id = ?", sspID).Error; err != nil { + return nil, err + } + + implemented := make(map[string]struct{}, len(ssp.ControlImplementation.ImplementedRequirements)) + for _, requirement := range ssp.ControlImplementation.ImplementedRequirements { + if len(requirement.Statements) == 0 { + continue + } + + implemented[requirement.ControlId] = struct{}{} + } + + return implemented, nil +} + +func computeProfileControlStatus(rows []ProfileComplianceStatusCount) string { + hasSatisfied := false + for _, row := range rows { + if row.Count <= 0 { + continue + } + + switch strings.ToLower(strings.TrimSpace(row.Status)) { + case "not-satisfied": + return "not-satisfied" + case "satisfied": + hasSatisfied = true + } + } + + if hasSatisfied { + return "satisfied" + } + + return "unknown" +} + +func computePercent(part, total int) int { + if total == 0 { + return 0 + } + + return int(math.Round((float64(part) / float64(total)) * 100)) +} diff --git a/internal/api/handler/oscal/profiles.go b/internal/api/handler/oscal/profiles.go index 7f46dd61..4ded97f3 100644 --- a/internal/api/handler/oscal/profiles.go +++ b/internal/api/handler/oscal/profiles.go @@ -75,6 +75,7 @@ func (h *ProfileHandler) Register(api *echo.Group) { api.POST("/build-props", h.BuildByProps) api.GET("/:id", h.Get) api.GET("/:id/resolved", h.Resolved) + api.GET("/:id/compliance-progress", h.ComplianceProgress) api.GET("/:id/modify", h.GetModify) api.GET("/:id/back-matter", h.GetBackmatter) @@ -1457,8 +1458,12 @@ func rollUpToRootControl(db *gorm.DB, control relational.Control) (relational.Co tx := db.Session(&gorm.Session{}) if *control.ParentType == "controls" { + if control.ParentID == nil { + return control, fmt.Errorf("control %s has parent type %q but nil parent ID", control.ID, *control.ParentType) + } + parent := relational.Control{} - if err := tx.First(&parent, "id = ?", control.ParentID).Error; err != nil { + if err := tx.First(&parent, "id = ? AND catalog_id = ?", *control.ParentID, control.CatalogID).Error; err != nil { return control, err } parent.Controls = append(parent.Controls, control) @@ -1475,8 +1480,12 @@ func rollUpToRootGroup(db *gorm.DB, group relational.Group) (relational.Group, e tx := db.Session(&gorm.Session{}) if *group.ParentType == "groups" { + if group.ParentID == nil { + return group, fmt.Errorf("group %s has parent type %q but nil parent ID", group.ID, *group.ParentType) + } + parent := relational.Group{} - if err := tx.First(&parent, "id = ?", *group.ParentID).Error; err != nil { + if err := tx.First(&parent, "id = ? AND catalog_id = ?", *group.ParentID, group.CatalogID).Error; err != nil { return group, err } parent.Groups = append(parent.Groups, group) @@ -1486,15 +1495,26 @@ func rollUpToRootGroup(db *gorm.DB, group relational.Group) (relational.Group, e return group, nil } +type controlMergeKey struct { + CatalogID uuid.UUID + ID string +} + +type groupMergeKey struct { + CatalogID uuid.UUID + ID string +} + func mergeControls(controls ...relational.Control) []relational.Control { - mapped := map[string]relational.Control{} + mapped := map[controlMergeKey]relational.Control{} for _, control := range controls { - if sub, ok := mapped[control.ID]; ok { + key := controlMergeKey{CatalogID: control.CatalogID, ID: control.ID} + if sub, ok := mapped[key]; ok { control.Controls = append(control.Controls, sub.Controls...) } control.Controls = mergeControls(control.Controls...) - mapped[control.ID] = control + mapped[key] = control } flattened := []relational.Control{} @@ -1505,16 +1525,17 @@ func mergeControls(controls ...relational.Control) []relational.Control { } func mergeGroups(groups ...relational.Group) []relational.Group { - mapped := map[string]relational.Group{} + mapped := map[groupMergeKey]relational.Group{} for _, group := range groups { - if sub, ok := mapped[group.ID]; ok { + key := groupMergeKey{CatalogID: group.CatalogID, ID: group.ID} + if sub, ok := mapped[key]; ok { group.Groups = append(group.Groups, sub.Groups...) group.Controls = append(group.Controls, sub.Controls...) } group.Controls = mergeControls(group.Controls...) group.Groups = mergeGroups(group.Groups...) - mapped[group.ID] = group + mapped[key] = group } flattened := []relational.Group{} for _, group := range mapped { @@ -1550,8 +1571,12 @@ func rollUpControlsToCatalog(db *gorm.DB, allControls []relational.Control) (*re // If the control has a group as a parent, roll it up. if *rootControl.ParentType == "groups" { + if rootControl.ParentID == nil { + return nil, fmt.Errorf("control %s has parent type %q but nil parent ID", rootControl.ID, *rootControl.ParentType) + } + group := &relational.Group{} - if err = db.First(group, "id = ?", *rootControl.ParentID).Error; err != nil { + if err = db.First(group, "id = ? AND catalog_id = ?", *rootControl.ParentID, rootControl.CatalogID).Error; err != nil { return nil, err } group.Controls = append(group.Controls, rootControl) diff --git a/internal/api/handler/oscal/profiles_integration_test.go b/internal/api/handler/oscal/profiles_integration_test.go index 4810703d..66785b0f 100644 --- a/internal/api/handler/oscal/profiles_integration_test.go +++ b/internal/api/handler/oscal/profiles_integration_test.go @@ -14,6 +14,7 @@ import ( "github.com/compliance-framework/api/internal/api" "github.com/compliance-framework/api/internal/api/handler" + "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" @@ -21,6 +22,7 @@ import ( "github.com/labstack/echo/v4" "github.com/stretchr/testify/suite" "go.uber.org/zap" + "gorm.io/datatypes" ) var ( @@ -1017,6 +1019,353 @@ func (suite *ProfileIntegrationSuite) TestResolved() { }) } +func (suite *ProfileIntegrationSuite) TestComplianceProgress() { + suite.IntegrationTestSuite.Migrator.Refresh() + + token, err := suite.GetAuthToken() + suite.Require().NoError(err, "Failed to get auth token") + + catalog := &relational.Catalog{ + Metadata: relational.Metadata{Title: "Compliance Progress Catalog"}, + } + suite.Require().NoError(suite.DB.Create(catalog).Error) + + controlSatisfied := relational.Control{ID: "CTRL-SAT", CatalogID: *catalog.ID, Title: "Satisfied Control"} + controlNotSatisfied := relational.Control{ID: "CTRL-NS", CatalogID: *catalog.ID, Title: "Not Satisfied Control"} + controlUnknown := relational.Control{ID: "CTRL-UNK", CatalogID: *catalog.ID, Title: "Unknown Control"} + + suite.Require().NoError(suite.DB.Create(&controlSatisfied).Error) + suite.Require().NoError(suite.DB.Create(&controlNotSatisfied).Error) + suite.Require().NoError(suite.DB.Create(&controlUnknown).Error) + + filterSatisfied := relational.Filter{ + Name: "Satisfied Filter", + Filter: datatypes.NewJSONType(labelfilter.Filter{ + Scope: &labelfilter.Scope{ + Condition: &labelfilter.Condition{ + Label: "provider", + Operator: "=", + Value: "aws", + }, + }, + }), + } + + filterNotSatisfied := relational.Filter{ + Name: "Not Satisfied Filter", + Filter: datatypes.NewJSONType(labelfilter.Filter{ + Scope: &labelfilter.Scope{ + Condition: &labelfilter.Condition{ + Label: "provider", + Operator: "=", + Value: "gcp", + }, + }, + }), + } + + suite.Require().NoError(suite.DB.Create(&filterSatisfied).Error) + suite.Require().NoError(suite.DB.Create(&filterNotSatisfied).Error) + suite.Require().NoError(suite.DB.Model(&controlSatisfied).Association("Filters").Append(&filterSatisfied)) + suite.Require().NoError(suite.DB.Model(&controlNotSatisfied).Association("Filters").Append(&filterNotSatisfied)) + + profile := &relational.Profile{ + Metadata: relational.Metadata{Title: "Compliance Progress Profile"}, + Controls: []relational.Control{controlSatisfied, controlNotSatisfied, controlUnknown}, + } + suite.Require().NoError(suite.DB.Create(profile).Error) + + now := time.Now().UTC() + evidenceRecords := []relational.Evidence{ + { + UUID: uuid.New(), + Title: "AWS satisfied evidence", + Start: now.Add(-time.Hour), + End: now.Add(-time.Minute), + Status: datatypes.NewJSONType(oscalTypes_1_1_3.ObjectiveStatus{State: "satisfied"}), + Labels: []relational.Labels{{Name: "provider", Value: "aws"}}, + }, + { + UUID: uuid.New(), + Title: "GCP not satisfied evidence", + Start: now.Add(-time.Hour), + End: now.Add(-time.Minute), + Status: datatypes.NewJSONType(oscalTypes_1_1_3.ObjectiveStatus{State: "not-satisfied"}), + Labels: []relational.Labels{{Name: "provider", Value: "gcp"}}, + }, + { + UUID: uuid.New(), + Title: "Non-matching evidence", + Start: now.Add(-time.Hour), + End: now.Add(-time.Minute), + Status: datatypes.NewJSONType(oscalTypes_1_1_3.ObjectiveStatus{State: "satisfied"}), + Labels: []relational.Labels{{Name: "provider", Value: "azure"}}, + }, + } + suite.Require().NoError(suite.DB.Create(&evidenceRecords).Error) + + suite.Run("Returns aggregated compliance progress", func() { + rec := httptest.NewRecorder() + url := "/api/oscal/profiles/" + profile.ID.String() + "/compliance-progress" + req := httptest.NewRequest(http.MethodGet, url, nil) + req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) + + suite.server.E().ServeHTTP(rec, req) + suite.Require().Equal(http.StatusOK, rec.Code, "Expected status code 200 OK") + + var response handler.GenericDataResponse[ProfileComplianceProgress] + err = json.NewDecoder(rec.Body).Decode(&response) + suite.Require().NoError(err, "Failed to decode response body") + + suite.Require().Equal(profile.ID.String(), response.Data.Scope.ID.String()) + suite.Require().Equal("profile", response.Data.Scope.Type) + suite.Require().Equal("Compliance Progress Profile", response.Data.Scope.Title) + + suite.Require().Equal(3, response.Data.Summary.TotalControls) + suite.Require().Equal(1, response.Data.Summary.Satisfied) + suite.Require().Equal(1, response.Data.Summary.NotSatisfied) + suite.Require().Equal(1, response.Data.Summary.Unknown) + suite.Require().Equal(33, response.Data.Summary.CompliancePct) + suite.Require().Equal(67, response.Data.Summary.AssessedPct) + + suite.Require().Len(response.Data.Groups, 0) + suite.Require().Len(response.Data.Controls, 3) + + controlsByID := make(map[string]ProfileComplianceControl, len(response.Data.Controls)) + for _, control := range response.Data.Controls { + controlsByID[control.ControlID] = control + } + + suite.Require().Equal("satisfied", controlsByID["CTRL-SAT"].ComputedStatus) + suite.Require().Equal("not-satisfied", controlsByID["CTRL-NS"].ComputedStatus) + suite.Require().Equal("unknown", controlsByID["CTRL-UNK"].ComputedStatus) + }) + + suite.Run("Allows omitting controls from response", func() { + rec := httptest.NewRecorder() + url := "/api/oscal/profiles/" + profile.ID.String() + "/compliance-progress?includeControls=false" + req := httptest.NewRequest(http.MethodGet, url, nil) + req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) + + suite.server.E().ServeHTTP(rec, req) + suite.Require().Equal(http.StatusOK, rec.Code, "Expected status code 200 OK") + + var response handler.GenericDataResponse[ProfileComplianceProgress] + err = json.NewDecoder(rec.Body).Decode(&response) + suite.Require().NoError(err, "Failed to decode response body") + suite.Require().Len(response.Data.Controls, 0) + suite.Require().Equal(3, response.Data.Summary.TotalControls) + }) + + suite.Run("Returns 404 for non-existing profile", func() { + rec := httptest.NewRecorder() + url := "/api/oscal/profiles/" + uuid.New().String() + "/compliance-progress" + req := httptest.NewRequest(http.MethodGet, url, nil) + req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) + + suite.server.E().ServeHTTP(rec, req) + suite.Require().Equal(http.StatusNotFound, rec.Code, "Expected status code 404 Not Found") + }) + + suite.Run("Returns 400 for invalid profile UUID", func() { + rec := httptest.NewRecorder() + url := "/api/oscal/profiles/invalid-uuid/compliance-progress" + req := httptest.NewRequest(http.MethodGet, url, nil) + req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) + + suite.server.E().ServeHTTP(rec, req) + suite.Require().Equal(http.StatusBadRequest, rec.Code, "Expected status code 400 Bad Request") + }) +} + +func (suite *ProfileIntegrationSuite) TestComplianceProgressEdgeCases() { + suite.IntegrationTestSuite.Migrator.Refresh() + + token, err := suite.GetAuthToken() + suite.Require().NoError(err, "Failed to get auth token") + + suite.Run("Profile with zero controls returns empty summary", func() { + emptyProfile := &relational.Profile{ + Metadata: relational.Metadata{Title: "Empty Profile"}, + } + suite.Require().NoError(suite.DB.Create(emptyProfile).Error) + + rec := httptest.NewRecorder() + url := "/api/oscal/profiles/" + emptyProfile.ID.String() + "/compliance-progress" + req := httptest.NewRequest(http.MethodGet, url, nil) + req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) + + suite.server.E().ServeHTTP(rec, req) + suite.Require().Equal(http.StatusOK, rec.Code) + + var response handler.GenericDataResponse[ProfileComplianceProgress] + suite.Require().NoError(json.NewDecoder(rec.Body).Decode(&response)) + + suite.Require().Equal(0, response.Data.Summary.TotalControls) + suite.Require().Equal(0, response.Data.Summary.Satisfied) + suite.Require().Equal(0, response.Data.Summary.NotSatisfied) + suite.Require().Equal(0, response.Data.Summary.Unknown) + suite.Require().Equal(0, response.Data.Summary.CompliancePct) + suite.Require().Nil(response.Data.Summary.ImplementedTotal, "implementedControls should be absent when no sspId requested") + suite.Require().Len(response.Data.Controls, 0) + suite.Require().Len(response.Data.Groups, 0) + }) + + suite.Run("Control with no linked filters reports unknown status", func() { + cat := &relational.Catalog{Metadata: relational.Metadata{Title: "Unfiltered Catalog"}} + suite.Require().NoError(suite.DB.Create(cat).Error) + + ctrl := relational.Control{ID: "CTRL-NOFILTER", CatalogID: *cat.ID, Title: "No Filter Control"} + suite.Require().NoError(suite.DB.Create(&ctrl).Error) + + p := &relational.Profile{ + Metadata: relational.Metadata{Title: "No Filter Profile"}, + Controls: []relational.Control{ctrl}, + } + suite.Require().NoError(suite.DB.Create(p).Error) + + rec := httptest.NewRecorder() + url := "/api/oscal/profiles/" + p.ID.String() + "/compliance-progress" + req := httptest.NewRequest(http.MethodGet, url, nil) + req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) + + suite.server.E().ServeHTTP(rec, req) + suite.Require().Equal(http.StatusOK, rec.Code) + + var response handler.GenericDataResponse[ProfileComplianceProgress] + suite.Require().NoError(json.NewDecoder(rec.Body).Decode(&response)) + + suite.Require().Equal(1, response.Data.Summary.TotalControls) + suite.Require().Equal(0, response.Data.Summary.Satisfied) + suite.Require().Equal(0, response.Data.Summary.NotSatisfied) + suite.Require().Equal(1, response.Data.Summary.Unknown) + suite.Require().Len(response.Data.Controls, 1) + suite.Require().Equal("unknown", response.Data.Controls[0].ComputedStatus) + }) + + suite.Run("Duplicate control IDs across different catalogs are tracked separately", func() { + catA := &relational.Catalog{Metadata: relational.Metadata{Title: "Catalog A"}} + catB := &relational.Catalog{Metadata: relational.Metadata{Title: "Catalog B"}} + suite.Require().NoError(suite.DB.Create(catA).Error) + suite.Require().NoError(suite.DB.Create(catB).Error) + + ctrlA := relational.Control{ID: "CTRL-SHARED", CatalogID: *catA.ID, Title: "Shared Control from A"} + ctrlB := relational.Control{ID: "CTRL-SHARED", CatalogID: *catB.ID, Title: "Shared Control from B"} + suite.Require().NoError(suite.DB.Create(&ctrlA).Error) + suite.Require().NoError(suite.DB.Create(&ctrlB).Error) + + p := &relational.Profile{ + Metadata: relational.Metadata{Title: "Cross-Catalog Profile"}, + Controls: []relational.Control{ctrlA, ctrlB}, + } + suite.Require().NoError(suite.DB.Create(p).Error) + + rec := httptest.NewRecorder() + url := "/api/oscal/profiles/" + p.ID.String() + "/compliance-progress" + req := httptest.NewRequest(http.MethodGet, url, nil) + req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) + + suite.server.E().ServeHTTP(rec, req) + suite.Require().Equal(http.StatusOK, rec.Code) + + var response handler.GenericDataResponse[ProfileComplianceProgress] + suite.Require().NoError(json.NewDecoder(rec.Body).Decode(&response)) + + // Both controls have the same controlId but different catalogIds — they must each be counted + suite.Require().Equal(2, response.Data.Summary.TotalControls, "Controls with same ID but different catalogs must be counted separately") + suite.Require().Len(response.Data.Controls, 2) + + catalogIDs := make(map[string]struct{}, 2) + for _, c := range response.Data.Controls { + suite.Require().Equal("CTRL-SHARED", c.ControlID) + catalogIDs[c.CatalogID.String()] = struct{}{} + } + suite.Require().Len(catalogIDs, 2, "Each entry must have a distinct catalogId") + }) + + suite.Run("sspId scope reports implemented and unimplemented controls", func() { + cat := &relational.Catalog{Metadata: relational.Metadata{Title: "SSP Catalog"}} + suite.Require().NoError(suite.DB.Create(cat).Error) + + ctrlImpl := relational.Control{ID: "CTRL-IMPL", CatalogID: *cat.ID, Title: "Implemented Control"} + ctrlUnimpl := relational.Control{ID: "CTRL-UNIMPL", CatalogID: *cat.ID, Title: "Unimplemented Control"} + suite.Require().NoError(suite.DB.Create(&ctrlImpl).Error) + suite.Require().NoError(suite.DB.Create(&ctrlUnimpl).Error) + + p := &relational.Profile{ + Metadata: relational.Metadata{Title: "SSP Profile"}, + Controls: []relational.Control{ctrlImpl, ctrlUnimpl}, + } + suite.Require().NoError(suite.DB.Create(p).Error) + + ssp := &relational.SystemSecurityPlan{ + Metadata: relational.Metadata{Title: "Test SSP"}, + ControlImplementation: relational.ControlImplementation{ + ImplementedRequirements: []relational.ImplementedRequirement{ + { + ControlId: "CTRL-IMPL", + Statements: []relational.Statement{ + {StatementId: "CTRL-IMPL_smt.a"}, + }, + }, + }, + }, + } + suite.Require().NoError(suite.DB.Create(ssp).Error) + + rec := httptest.NewRecorder() + url := "/api/oscal/profiles/" + p.ID.String() + "/compliance-progress?sspId=" + ssp.ID.String() + req := httptest.NewRequest(http.MethodGet, url, nil) + req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) + + suite.server.E().ServeHTTP(rec, req) + suite.Require().Equal(http.StatusOK, rec.Code) + + var response handler.GenericDataResponse[ProfileComplianceProgress] + suite.Require().NoError(json.NewDecoder(rec.Body).Decode(&response)) + + suite.Require().Equal(2, response.Data.Summary.TotalControls) + suite.Require().NotNil(response.Data.Summary.ImplementedTotal, "implementedControls must be present when sspId provided") + suite.Require().Equal(1, *response.Data.Summary.ImplementedTotal) + + suite.Require().NotNil(response.Data.Implementation) + suite.Require().Equal(1, response.Data.Implementation.ImplementedControls) + suite.Require().Equal(1, response.Data.Implementation.UnimplementedControls) + suite.Require().Equal(50, response.Data.Implementation.ImplementationPct) + + implByID := make(map[string]bool, 2) + for _, c := range response.Data.Controls { + if c.Implemented != nil { + implByID[c.ControlID] = *c.Implemented + } + } + suite.Require().True(implByID["CTRL-IMPL"], "CTRL-IMPL should be implemented") + suite.Require().False(implByID["CTRL-UNIMPL"], "CTRL-UNIMPL should not be implemented") + }) + + suite.Run("Non-existent sspId returns 404", func() { + cat := &relational.Catalog{Metadata: relational.Metadata{Title: "404 SSP Catalog"}} + suite.Require().NoError(suite.DB.Create(cat).Error) + + ctrl := relational.Control{ID: "CTRL-ANY", CatalogID: *cat.ID, Title: "Any Control"} + suite.Require().NoError(suite.DB.Create(&ctrl).Error) + + p := &relational.Profile{ + Metadata: relational.Metadata{Title: "404 SSP Profile"}, + Controls: []relational.Control{ctrl}, + } + suite.Require().NoError(suite.DB.Create(p).Error) + + rec := httptest.NewRecorder() + url := "/api/oscal/profiles/" + p.ID.String() + "/compliance-progress?sspId=" + uuid.New().String() + req := httptest.NewRequest(http.MethodGet, url, nil) + req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) + + suite.server.E().ServeHTTP(rec, req) + suite.Require().Equal(http.StatusNotFound, rec.Code) + }) +} + func (suite *ProfileIntegrationSuite) TestGetControlCatalogFromBuiltProfile() { suite.IntegrationTestSuite.Migrator.Refresh() diff --git a/internal/api/handler/oscal/profiles_test.go b/internal/api/handler/oscal/profiles_test.go index af0d0d5f..27808868 100644 --- a/internal/api/handler/oscal/profiles_test.go +++ b/internal/api/handler/oscal/profiles_test.go @@ -2,6 +2,7 @@ package oscal import ( "github.com/compliance-framework/api/internal/service/relational" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "testing" ) @@ -50,4 +51,41 @@ func TestProfileControlMerging(t *testing.T) { assert.Equal(t, "AC", merged[0].ID) assert.Len(t, merged[0].Controls, 2) }) + + t.Run("CrossCatalog", func(t *testing.T) { + catalogA := uuid.New() + catalogB := uuid.New() + + merged := mergeControls([]relational.Control{ + { + CatalogID: catalogA, + ID: "AC", + Title: "from-a", + }, + { + CatalogID: catalogB, + ID: "AC", + Title: "from-b", + }, + }...) + + assert.Len(t, merged, 2) + catalogs := map[uuid.UUID]struct{}{} + for _, control := range merged { + catalogs[control.CatalogID] = struct{}{} + } + assert.Len(t, catalogs, 2) + }) +} + +func TestProfileGroupMerging(t *testing.T) { + catalogA := uuid.New() + catalogB := uuid.New() + + merged := mergeGroups([]relational.Group{ + {CatalogID: catalogA, ID: "G-1", Title: "A"}, + {CatalogID: catalogB, ID: "G-1", Title: "B"}, + }...) + + assert.Len(t, merged, 2) }