diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d366144..c270c110 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,8 +45,8 @@ jobs: uses: golangci/golangci-lint-action@v7 with: version: ${{ env.GOLANGCI_VERSION }} - skip-pkg-cache: true - skip-build-cache: true + skip-cache: true + skip-save-cache: true args: --timeout=30m check-diff: diff --git a/docs/docs.go b/docs/docs.go index dacc842b..72ab8fa4 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -11704,6 +11704,69 @@ const docTemplate = `{ ] } }, + "/oscal/profiles/build-props": { + "post": { + "description": "Generates a Profile selecting controls from a catalog based on prop matching rules. Returns the created Profile and the matched control IDs.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Profile" + ], + "summary": "Build Profile by Control Props", + "parameters": [ + { + "description": "Prop matching request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscal.ProfileHandler" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscal_ProfileHandler" + } + }, + "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}": { "get": { "description": "Get an OSCAL profile with the uuid provided", diff --git a/docs/swagger.json b/docs/swagger.json index b9fe7c8f..5b3a2273 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -11698,6 +11698,69 @@ ] } }, + "/oscal/profiles/build-props": { + "post": { + "description": "Generates a Profile selecting controls from a catalog based on prop matching rules. Returns the created Profile and the matched control IDs.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Profile" + ], + "summary": "Build Profile by Control Props", + "parameters": [ + { + "description": "Prop matching request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscal.ProfileHandler" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscal_ProfileHandler" + } + }, + "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}": { "get": { "description": "Get an OSCAL profile with the uuid provided", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 8c57dbf3..da328953 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -13789,6 +13789,47 @@ paths: summary: Get Resolved Profile tags: - Profile + /oscal/profiles/build-props: + post: + consumes: + - application/json + description: Generates a Profile selecting controls from a catalog based on + prop matching rules. Returns the created Profile and the matched control IDs. + parameters: + - description: Prop matching request + in: body + name: request + required: true + schema: + $ref: '#/definitions/oscal.ProfileHandler' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handler.GenericDataResponse-oscal_ProfileHandler' + "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: Build Profile by Control Props + tags: + - Profile /oscal/roles: get: description: Retrieves all roles. diff --git a/internal/api/handler/oscal/profiles.go b/internal/api/handler/oscal/profiles.go index 66879847..3c189fd5 100644 --- a/internal/api/handler/oscal/profiles.go +++ b/internal/api/handler/oscal/profiles.go @@ -1,9 +1,11 @@ package oscal import ( + "encoding/json" "errors" "fmt" "net/http" + "regexp" "strings" "time" @@ -15,6 +17,7 @@ import ( "github.com/google/uuid" "github.com/labstack/echo/v4" "go.uber.org/zap" + "gorm.io/datatypes" "gorm.io/gorm" ) @@ -23,6 +26,13 @@ type ProfileHandler struct { db *gorm.DB } +type rule struct { + Name string `json:"name"` + Ns string `json:"ns"` + Operator string `json:"operator"` // equals | contains | regex | in + Value string `json:"value"` +} + func NewProfileHandler(sugar *zap.SugaredLogger, db *gorm.DB) *ProfileHandler { return &ProfileHandler{ sugar: sugar, @@ -33,6 +43,7 @@ func NewProfileHandler(sugar *zap.SugaredLogger, db *gorm.DB) *ProfileHandler { func (h *ProfileHandler) Register(api *echo.Group) { api.GET("", h.List) api.POST("", h.Create) + api.POST("/build-props", h.BuildByProps) api.GET("/:id", h.Get) api.GET("/:id/resolved", h.Resolved) @@ -53,6 +64,256 @@ func (h *ProfileHandler) Register(api *echo.Group) { api.PUT("/:id/merge", h.UpdateMerge) } +// BuildByProps +// +// @Summary Build Profile by Control Props +// @Description Generates a Profile selecting controls from a catalog based on prop matching rules. Returns the created Profile and the matched control IDs. +// @Tags Profile +// @Accept json +// @Produce json +// @Param request body oscal.ProfileHandler.BuildByProps.request true "Prop matching request" +// @Success 201 {object} handler.GenericDataResponse[oscal.ProfileHandler.BuildByProps.response] +// @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/build-props [post] +func (h *ProfileHandler) BuildByProps(ctx echo.Context) error { + type request struct { + CatalogID string `json:"catalogId"` + MatchStrategy string `json:"matchStrategy"` // all | any + Rules []rule `json:"rules"` + Title string `json:"title"` + Version string `json:"version"` + } + type response struct { + ProfileID uuid.UUID `json:"profileId"` + ControlIDs []string `json:"controlIds"` + Profile oscalTypes_1_1_3.Profile `json:"profile"` + } + var req request + var raw map[string]any + if err := json.NewDecoder(ctx.Request().Body).Decode(&raw); err != nil { + h.sugar.Warnw("failed to decode BuildByProps request", "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + // Accept both camelCase and kebab-case keys + getStr := func(m map[string]any, keys ...string) string { + for _, k := range keys { + if v, ok := m[k]; ok { + if s, ok := v.(string); ok { + return s + } + } + } + return "" + } + req.CatalogID = getStr(raw, "catalogId", "catalog-id") + req.MatchStrategy = getStr(raw, "matchStrategy", "match-strategy") + req.Title = getStr(raw, "title") + req.Version = getStr(raw, "version") + if rv, ok := raw["rules"]; ok { + if arr, ok := rv.([]any); ok { + out := make([]rule, 0, len(arr)) + for _, it := range arr { + if mm, ok := it.(map[string]any); ok { + out = append(out, rule{ + Name: getStr(mm, "name"), + Ns: getStr(mm, "ns"), + Operator: getStr(mm, "operator"), + Value: getStr(mm, "value"), + }) + } + } + req.Rules = out + } + } + if req.CatalogID == "" || len(req.Rules) == 0 { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("catalogId and rules are required"))) + } + // filter out invalid rules (empty operator or value) + validRules := make([]rule, 0, len(req.Rules)) + for _, r := range req.Rules { + if strings.TrimSpace(r.Operator) != "" && strings.TrimSpace(r.Value) != "" { + validRules = append(validRules, r) + } + } + if len(validRules) == 0 { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("rules must include non-empty operator and value"))) + } + catUUID, err := uuid.Parse(req.CatalogID) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + var controls []relational.Control + if err := h.db.Where("catalog_id = ?", catUUID).Find(&controls).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(err)) + } + h.sugar.Errorw("failed to list catalog controls", "catalogId", req.CatalogID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + matchAll := strings.ToLower(req.MatchStrategy) == "all" + matched := make([]relational.Control, 0, len(controls)) + matchedIDs := make([]string, 0, len(controls)) + for i := range controls { + if matchControlByProps(&controls[i], validRules, matchAll) { + matched = append(matched, controls[i]) + matchedIDs = append(matchedIDs, controls[i].ID) + } + } + now := time.Now() + // build BackMatter resource and Import pointing to the catalog + var catalog relational.Catalog + if err := h.db.Preload("Metadata").First(&catalog, "id = ?", catUUID).Error; err != nil { + h.sugar.Warnw("failed to load catalog metadata", "catalogId", req.CatalogID, "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + resourceUUID := uuid.New() + title := catalog.Metadata.Title + resource := relational.BackMatterResource{ + ID: resourceUUID, + Title: &title, + RLinks: []relational.ResourceLink{ + { + Href: "#" + req.CatalogID, + MediaType: "application/ccf+oscal+json", + }, + }, + } + includeGroup := relational.SelectControlById{ + WithChildControls: "", + WithIds: datatypes.NewJSONSlice(matchedIDs), + } + newImport := relational.Import{ + Href: "#" + resourceUUID.String(), + } + profile := &relational.Profile{ + Metadata: relational.Metadata{ + Title: req.Title, + Version: req.Version, + OscalVersion: versioning.GetLatestSupportedVersion(), + LastModified: &now, + }, + Controls: matched, + } + if err := h.db.Create(profile).Error; err != nil { + h.sugar.Errorw("failed to create profile from props", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + // Persist BackMatter and resource under this profile + parentID := profile.ID.String() + parentType := "profiles" + bmRecord := &relational.BackMatter{ + ParentID: &parentID, + ParentType: &parentType, + } + if err := h.db.Create(bmRecord).Error; err != nil { + h.sugar.Errorw("failed to create backmatter for profile", "profileId", profile.ID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + if bmRecord.ID != nil { + resource.BackMatterID = *bmRecord.ID + } + if err := h.db.Create(&resource).Error; err != nil { + h.sugar.Errorw("failed to create backmatter resource", "profileId", profile.ID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + // Persist import and include-controls + newImport.ProfileID = *profile.ID + if err := h.db.Create(&newImport).Error; err != nil { + h.sugar.Errorw("failed to create import", "profileId", profile.ID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + if len(matchedIDs) > 0 && newImport.ID != nil { + includeGroup.ParentID = *newImport.ID + includeGroup.ParentType = "included" + if err := h.db.Create(&includeGroup).Error; err != nil { + h.sugar.Errorw("failed to create include-controls", "profileId", profile.ID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + } + if _, err := SyncProfileControls(h.db, *profile.ID); err != nil { + h.sugar.Errorw("failed to sync profile controls", "profileId", profile.ID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + // Reload full profile with associations for response + fullProfile, err := FindFullProfile(h.db, *profile.ID) + if err != nil { + h.sugar.Errorw("failed to reload full profile", "profileId", profile.ID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + oscalProfile := fullProfile.MarshalOscal() + return ctx.JSON(http.StatusCreated, handler.GenericDataResponse[response]{ + Data: response{ + ProfileID: *profile.ID, + ControlIDs: matchedIDs, + Profile: *oscalProfile, + }, + }) +} + +func matchControlByProps(ctl *relational.Control, rules []rule, matchAll bool) bool { + if len(rules) == 0 { + return false + } + eval := func(r rule, p relational.Prop) bool { + if r.Name != "" && !strings.EqualFold(r.Name, p.Name) { + return false + } + if r.Ns != "" && !strings.EqualFold(r.Ns, p.Ns) { + return false + } + switch strings.ToLower(r.Operator) { + case "equals": + return strings.EqualFold(p.Value, r.Value) + case "contains": + return strings.Contains(strings.ToLower(p.Value), strings.ToLower(r.Value)) + case "regex": + m, _ := func() (bool, error) { + // simple regex match + re, err := regexp.Compile(r.Value) + if err != nil { + return false, err + } + return re.MatchString(p.Value), nil + }() + return m + case "in": + parts := strings.Split(r.Value, ",") + for _, v := range parts { + if strings.EqualFold(strings.TrimSpace(v), p.Value) { + return true + } + } + return false + default: + return false + } + } + matchedCount := 0 + for _, rule := range rules { + ruleMatched := false + for _, prop := range ctl.Props { + if eval(rule, prop) { + ruleMatched = true + break + } + } + if matchAll && !ruleMatched { + return false + } + if !matchAll && ruleMatched { + return true + } + if ruleMatched { + matchedCount++ + } + } + return matchAll && matchedCount == len(rules) +} + // List godoc // // @Summary List Profiles @@ -1368,7 +1629,7 @@ func FindOscalCatalogFromBackMatter(profile *relational.Profile, ref string) (uu } } } - return uuid.Nil, errors.New("No valid catalog UUID was found within the backmatter. Ref: " + ref) + return uuid.Nil, errors.New("no valid catalog uuid was found within the backmatter. ref: " + ref) } // GatherControlIds extracts unique control IDs from an Import’s IncludeControls, avoiding duplicates. diff --git a/internal/api/handler/oscal/profiles_integration_test.go b/internal/api/handler/oscal/profiles_integration_test.go index 3fdce1e5..5f776dac 100644 --- a/internal/api/handler/oscal/profiles_integration_test.go +++ b/internal/api/handler/oscal/profiles_integration_test.go @@ -6,7 +6,6 @@ import ( "bytes" "context" "encoding/json" - "fmt" "net/http" "net/http/httptest" "os" @@ -517,7 +516,6 @@ func (suite *ProfileIntegrationSuite) TestAddImport() { suite.server.E().ServeHTTP(rec, req) - fmt.Println("Response Body:", rec.Body.String()) suite.Require().Equal(http.StatusCreated, rec.Code, "Expected status code 201 Created") var response handler.GenericDataResponse[oscalTypes_1_1_3.Import] @@ -577,6 +575,69 @@ func (suite *ProfileIntegrationSuite) TestUpdateImport() { }) } +func (suite *ProfileIntegrationSuite) TestBuildByPropsCreatesImportAndControls() { + suite.IntegrationTestSuite.Migrator.Refresh() + token, err := suite.GetAuthToken() + suite.Require().NoError(err, "Failed to get auth token") + + // Seed a minimal catalog with one technical control + catID := uuid.New() + catalog := &relational.Catalog{ + UUIDModel: relational.UUIDModel{ID: &catID}, + Metadata: relational.Metadata{Title: "Prop Match Test Catalog"}, + Controls: []relational.Control{ + { + ID: "ac-1", + Title: "Access Control 1", + CatalogID: catID, + Props: []relational.Prop{{Name: "class", Value: "technical"}}, + }, + }, + } + err = suite.DB.Create(catalog).Error + suite.Require().NoError(err, "Failed to seed test catalog") + + // Build profile by props targeting the seeded catalog and rule + body := map[string]any{ + "catalogId": catID.String(), + "matchStrategy": "all", + "rules": []map[string]string{ + {"name": "class", "operator": "equals", "value": "technical"}, + }, + "title": "Prop-Matched Profile", + "version": "1.0.0", + } + payload, _ := json.Marshal(body) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/oscal/profiles/build-props", bytes.NewReader(payload)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) + suite.server.E().ServeHTTP(rec, req) + suite.Require().Equal(http.StatusCreated, rec.Code, "Expected 201 from build-by-props") + + var response handler.GenericDataResponse[struct { + ProfileID uuid.UUID `json:"profileId"` + ControlIDs []string `json:"controlIds"` + Profile oscalTypes_1_1_3.Profile `json:"profile"` + }] + err = json.NewDecoder(rec.Body).Decode(&response) + suite.Require().NoError(err, "Failed to decode build-by-props response") + suite.Require().Len(response.Data.ControlIDs, 1, "Expected one matched control") + suite.Require().Equal("ac-1", response.Data.ControlIDs[0], "Expected matched control to be ac-1") + + // Verify persisted import/back-matter can be listed + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/api/oscal/profiles/"+response.Data.ProfileID.String()+"/imports", nil) + req.Header.Set(echo.HeaderAuthorization, "Bearer "+*token) + suite.server.E().ServeHTTP(rec, req) + suite.Require().Equal(http.StatusOK, rec.Code, "Expected 200 listing imports") + var list handler.GenericDataListResponse[oscalTypes_1_1_3.Import] + err = json.NewDecoder(rec.Body).Decode(&list) + suite.Require().NoError(err) + suite.Require().Len(list.Data, 1, "Expected a single import") +} + func (suite *ProfileIntegrationSuite) TestDeleteImport() { suite.IntegrationTestSuite.Migrator.Refresh() suite.SeedDatabase()