diff --git a/internal/api/handler/oscal/system_security_plans.go b/internal/api/handler/oscal/system_security_plans.go index 260fcdda..1c4f72b2 100644 --- a/internal/api/handler/oscal/system_security_plans.go +++ b/internal/api/handler/oscal/system_security_plans.go @@ -23,13 +23,13 @@ import ( "github.com/compliance-framework/api/internal/service/relational" evidencesvc "github.com/compliance-framework/api/internal/service/relational/evidence" riskrel "github.com/compliance-framework/api/internal/service/relational/risks" + "github.com/compliance-framework/api/internal/service/relational/sspservice" oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" "github.com/labstack/echo/v4" "go.uber.org/zap" "gorm.io/datatypes" "gorm.io/gorm" - "gorm.io/gorm/clause" ) // SSPJobEnqueuer is a minimal interface for enqueueing SSP-related background jobs. @@ -118,6 +118,7 @@ type SystemSecurityPlanHandler struct { db *gorm.DB suggestionService *relational.SystemComponentSuggestionService jobEnqueuer SSPJobEnqueuer + sspService *sspservice.Service } // profileControlCache memoizes the set of resolved control IDs for a profile. @@ -190,12 +191,14 @@ func (r *ApplySuggestionRequest) Validate() error { } func NewSystemSecurityPlanHandler(sugar *zap.SugaredLogger, db *gorm.DB, evidenceSvc *evidencesvc.EvidenceService, jobEnqueuer SSPJobEnqueuer) *SystemSecurityPlanHandler { - return &SystemSecurityPlanHandler{ + h := &SystemSecurityPlanHandler{ sugar: sugar, db: db, suggestionService: relational.NewSystemComponentSuggestionService(db, evidenceSvc), jobEnqueuer: jobEnqueuer, } + h.sspService = sspservice.NewService(h.getControlIDsForProfile) + return h } // getControlIDsForProfile returns all control IDs for a given profile, using an optimized multi-step resolution path: @@ -632,13 +635,8 @@ func (h *SystemSecurityPlanHandler) Create(ctx echo.Context) error { return ctx.JSON(http.StatusBadRequest, api.NewError(err)) } - now := time.Now() - relSSP := &relational.SystemSecurityPlan{} - relSSP.UnmarshalOscal(oscalSSP) - relSSP.Metadata.LastModified = &now - relSSP.Metadata.OscalVersion = versioning.GetLatestSupportedVersion() - - if err := h.db.Create(relSSP).Error; err != nil { + relSSP, err := h.sspService.Create(h.db, oscalSSP) + if err != nil { h.sugar.Errorf("Failed to create SSP: %v", err) return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } @@ -2372,105 +2370,18 @@ func (h *SystemSecurityPlanHandler) AddProfile(ctx echo.Context) error { return ctx.JSON(http.StatusBadRequest, api.NewError(err)) } - var ssp relational.SystemSecurityPlan - if err := h.db.Preload("ControlImplementation").First(&ssp, "id = ?", sspID).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("SSP not found"))) - } - h.sugar.Errorw("Failed to load SSP", "sspID", sspID, "error", err) - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) - } - - // Check profile exists - var profile relational.Profile - if err := h.db.First(&profile, "id = ?", profileID).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(errors.New("profile not found"))) - } - h.sugar.Errorw("Failed to load profile", "profileId", profileID, "error", err) - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) - } - - var duplicateBinding bool - errNoResolvedControls := errors.New("no controls were resolved from the selected profile") - err = h.db.Transaction(func(tx *gorm.DB) error { - // Insert join-table row idempotently; ON CONFLICT DO NOTHING avoids a TOCTOU race. - result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&relational.SSPProfile{ - SystemSecurityPlanID: sspID, - ProfileID: profileID, - }) - if result.Error != nil { - return fmt.Errorf("failed to insert ssp_profiles row: %w", result.Error) - } - if result.RowsAffected == 0 { - duplicateBinding = true - return nil - } - - controlIDs, err := h.getControlIDsForProfile(profileID) - if err != nil { - return fmt.Errorf("failed to resolve control IDs for profile: %w", err) - } - if len(controlIDs) == 0 { - return errNoResolvedControls - } - - // Ensure ControlImplementation exists - if ssp.ControlImplementation.ID == nil { - newID := uuid.New() - ssp.ControlImplementation = relational.ControlImplementation{ - UUIDModel: relational.UUIDModel{ID: &newID}, - Description: "Control implementation", - SystemSecurityPlanId: *ssp.ID, - } - if err := tx.Create(&ssp.ControlImplementation).Error; err != nil { - return err - } - } - - // Add any new ImplementedRequirements for the profile's controls - var existingControlIDs []string - if err := tx.Model(&relational.ImplementedRequirement{}). - Where("control_implementation_id = ?", ssp.ControlImplementation.ID). - Pluck("control_id", &existingControlIDs).Error; err != nil { - return err - } - - existingMap := make(map[string]bool, len(existingControlIDs)) - for _, id := range existingControlIDs { - existingMap[normalizeControlID(id)] = true - } - - var newReqs []relational.ImplementedRequirement - for _, controlID := range controlIDs { - // Dedupe case-insensitively, but persist the canonical casing. - if !existingMap[normalizeControlID(controlID)] { - newUUID := uuid.New() - newReqs = append(newReqs, relational.ImplementedRequirement{ - UUIDModel: relational.UUIDModel{ID: &newUUID}, - ControlImplementationId: *ssp.ControlImplementation.ID, - ControlId: controlID, - }) - } - } - - if len(newReqs) > 0 { - if err := tx.Create(&newReqs).Error; err != nil { - return err - } - } - - return nil - }) - if err != nil { - if errors.Is(err, errNoResolvedControls) { - return ctx.JSON(http.StatusBadRequest, api.NewError(errNoResolvedControls)) + if err := h.sspService.AttachProfile(h.db, sspID, profileID); err != nil { + switch { + case errors.Is(err, sspservice.ErrSSPNotFound), errors.Is(err, sspservice.ErrProfileNotFound): + return ctx.JSON(http.StatusNotFound, api.NewError(err)) + case errors.Is(err, sspservice.ErrNoResolvedControls): + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + case errors.Is(err, sspservice.ErrProfileAlreadyBound): + return ctx.JSON(http.StatusConflict, api.NewError(fmt.Errorf("profile %s is already bound to this SSP", profileID))) + default: + h.sugar.Errorw("Failed to add profile to SSP", "sspId", sspID, "profileId", profileID, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } - h.sugar.Errorf("Failed to add profile to SSP: %v", err) - return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) - } - if duplicateBinding { - return ctx.JSON(http.StatusConflict, api.NewError(fmt.Errorf("profile %s is already bound to this SSP", profileID))) } // Enqueue orphaned risk cleanup diff --git a/internal/service/relational/sspservice/service.go b/internal/service/relational/sspservice/service.go new file mode 100644 index 00000000..5178caa4 --- /dev/null +++ b/internal/service/relational/sspservice/service.go @@ -0,0 +1,159 @@ +// Package sspservice creates System Security Plans and binds profiles to them. The SSP +// HTTP handlers and SSP template discovery both go through it, so an SSP is built the +// same way whoever creates it. +package sspservice + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/compliance-framework/api/internal/service/relational" + "github.com/defenseunicorns/go-oscal/src/pkg/versioning" + oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + // ErrSSPNotFound is returned when the SSP does not exist. + ErrSSPNotFound = errors.New("SSP not found") + // ErrProfileNotFound is returned when the profile does not exist. + ErrProfileNotFound = errors.New("profile not found") + // ErrProfileAlreadyBound is returned when the profile is already bound to the SSP. + ErrProfileAlreadyBound = errors.New("profile is already bound to this SSP") + // ErrNoResolvedControls is returned when the profile resolves to no controls. + ErrNoResolvedControls = errors.New("no controls were resolved from the selected profile") +) + +// ControlResolver returns the catalog-canonical control IDs a profile selects, +// de-duplicated. +type ControlResolver func(profileID uuid.UUID) ([]string, error) + +// Service creates SSPs and binds profiles to them. Every method takes the *gorm.DB to +// run on, so callers can pass a transaction. +type Service struct { + resolveControls ControlResolver +} + +// NewService returns a Service resolving profile controls with resolveControls. +func NewService(resolveControls ControlResolver) *Service { + return &Service{resolveControls: resolveControls} +} + +// Create stores ssp, stamping its last-modified time and OSCAL version. Callers +// validate ssp first. +func (s *Service) Create(db *gorm.DB, ssp oscalTypes_1_1_3.SystemSecurityPlan) (*relational.SystemSecurityPlan, error) { + now := time.Now() + relSSP := &relational.SystemSecurityPlan{} + relSSP.UnmarshalOscal(ssp) + relSSP.Metadata.LastModified = &now + relSSP.Metadata.OscalVersion = versioning.GetLatestSupportedVersion() + if err := db.Create(relSSP).Error; err != nil { + return nil, err + } + return relSSP, nil +} + +// AttachProfile binds the profile to the SSP and creates an implemented requirement for +// each of the profile's controls the SSP does not implement yet (matched +// case-insensitively, stored with the catalog's casing). The writes run in one +// transaction (a savepoint when db is already one). It fails with ErrSSPNotFound, +// ErrProfileNotFound, ErrProfileAlreadyBound or ErrNoResolvedControls. +func (s *Service) AttachProfile(db *gorm.DB, sspID, profileID uuid.UUID) error { + var ssp relational.SystemSecurityPlan + if err := db.Preload("ControlImplementation").First(&ssp, "id = ?", sspID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrSSPNotFound + } + return err + } + var profile relational.Profile + if err := db.First(&profile, "id = ?", profileID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrProfileNotFound + } + return err + } + + var duplicateBinding bool + err := db.Transaction(func(tx *gorm.DB) error { + // Insert join-table row idempotently; ON CONFLICT DO NOTHING avoids a TOCTOU race. + result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&relational.SSPProfile{ + SystemSecurityPlanID: sspID, + ProfileID: profileID, + }) + if result.Error != nil { + return fmt.Errorf("failed to insert ssp_profiles row: %w", result.Error) + } + if result.RowsAffected == 0 { + duplicateBinding = true + return nil + } + + controlIDs, err := s.resolveControls(profileID) + if err != nil { + return fmt.Errorf("failed to resolve control IDs for profile: %w", err) + } + if len(controlIDs) == 0 { + return ErrNoResolvedControls + } + + // Ensure ControlImplementation exists + if ssp.ControlImplementation.ID == nil { + newID := uuid.New() + ssp.ControlImplementation = relational.ControlImplementation{ + UUIDModel: relational.UUIDModel{ID: &newID}, + Description: "Control implementation", + SystemSecurityPlanId: *ssp.ID, + } + if err := tx.Create(&ssp.ControlImplementation).Error; err != nil { + return err + } + } + + // Add any new ImplementedRequirements for the profile's controls + var existingControlIDs []string + if err := tx.Model(&relational.ImplementedRequirement{}). + Where("control_implementation_id = ?", ssp.ControlImplementation.ID). + Pluck("control_id", &existingControlIDs).Error; err != nil { + return err + } + + // Control IDs compare case-insensitively; the lowered form is only a key. + existingMap := make(map[string]bool, len(existingControlIDs)) + for _, id := range existingControlIDs { + existingMap[strings.ToLower(id)] = true + } + + var newReqs []relational.ImplementedRequirement + for _, controlID := range controlIDs { + // Dedupe case-insensitively, but persist the canonical casing. + if !existingMap[strings.ToLower(controlID)] { + newUUID := uuid.New() + newReqs = append(newReqs, relational.ImplementedRequirement{ + UUIDModel: relational.UUIDModel{ID: &newUUID}, + ControlImplementationId: *ssp.ControlImplementation.ID, + ControlId: controlID, + }) + } + } + + if len(newReqs) > 0 { + if err := tx.Create(&newReqs).Error; err != nil { + return err + } + } + + return nil + }) + if err != nil { + return err + } + if duplicateBinding { + return ErrProfileAlreadyBound + } + return nil +} diff --git a/internal/service/relational/sspservice/service_integration_test.go b/internal/service/relational/sspservice/service_integration_test.go new file mode 100644 index 00000000..6374e1ce --- /dev/null +++ b/internal/service/relational/sspservice/service_integration_test.go @@ -0,0 +1,101 @@ +//go:build integration + +package sspservice_test + +import ( + "errors" + "testing" + + "github.com/compliance-framework/api/internal/service/relational" + "github.com/compliance-framework/api/internal/service/relational/sspservice" + "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/stretchr/testify/suite" + "gorm.io/gorm" +) + +type SSPServiceIntegrationSuite struct { + tests.IntegrationTestSuite + controls map[uuid.UUID][]string + svc *sspservice.Service + profileID uuid.UUID +} + +func TestSSPServiceIntegration(t *testing.T) { + suite.Run(t, new(SSPServiceIntegrationSuite)) +} + +func (s *SSPServiceIntegrationSuite) SetupTest() { + s.Require().NoError(s.Migrator.Refresh()) + s.controls = map[uuid.UUID][]string{} + s.svc = sspservice.NewService(func(profileID uuid.UUID) ([]string, error) { + return s.controls[profileID], nil + }) + s.profileID = uuid.New() + s.Require().NoError(s.DB.Create(&relational.Profile{UUIDModel: relational.UUIDModel{ID: &s.profileID}}).Error) +} + +func (s *SSPServiceIntegrationSuite) createSSP(db *gorm.DB) uuid.UUID { + ssp, err := s.svc.Create(db, oscalTypes_1_1_3.SystemSecurityPlan{ + UUID: uuid.NewString(), + Metadata: oscalTypes_1_1_3.Metadata{Title: "SSP", Version: "1.0"}, + }) + s.Require().NoError(err) + s.NotNil(ssp.Metadata.LastModified) + s.NotEmpty(ssp.Metadata.OscalVersion) + return *ssp.ID +} + +func (s *SSPServiceIntegrationSuite) controlIDs(sspID uuid.UUID) []string { + var ids []string + s.Require().NoError(s.DB.Model(&relational.ImplementedRequirement{}). + Joins("JOIN control_implementations ON control_implementations.id = implemented_requirements.control_implementation_id"). + Where("control_implementations.system_security_plan_id = ?", sspID). + Order("control_id").Pluck("control_id", &ids).Error) + return ids +} + +func (s *SSPServiceIntegrationSuite) TestAttachProfileCreatesMissingRequirements() { + sspID := s.createSSP(s.DB) + other := uuid.New() + s.Require().NoError(s.DB.Create(&relational.Profile{UUIDModel: relational.UUIDModel{ID: &other}}).Error) + s.controls[other] = []string{"AC-1"} + s.controls[s.profileID] = []string{"ac-1", "AC-2"} + + s.Require().NoError(s.svc.AttachProfile(s.DB, sspID, other)) + s.Require().NoError(s.svc.AttachProfile(s.DB, sspID, s.profileID)) + s.Equal([]string{"AC-1", "AC-2"}, s.controlIDs(sspID), "ac-1 matches the existing AC-1 case-insensitively") + + s.ErrorIs(s.svc.AttachProfile(s.DB, sspID, s.profileID), sspservice.ErrProfileAlreadyBound) + s.ErrorIs(s.svc.AttachProfile(s.DB, uuid.New(), s.profileID), sspservice.ErrSSPNotFound) + s.ErrorIs(s.svc.AttachProfile(s.DB, sspID, uuid.New()), sspservice.ErrProfileNotFound) +} + +func (s *SSPServiceIntegrationSuite) TestNoControlsLeavesNoBinding() { + sspID := s.createSSP(s.DB) + s.ErrorIs(s.svc.AttachProfile(s.DB, sspID, s.profileID), sspservice.ErrNoResolvedControls) + + var bindings int64 + s.Require().NoError(s.DB.Model(&relational.SSPProfile{}).Where("system_security_plan_id = ?", sspID).Count(&bindings).Error) + s.Zero(bindings) +} + +// TestRunsInCallersTransaction checks that both calls take part in the caller's +// transaction: rolling it back leaves neither the SSP nor its requirements. +func (s *SSPServiceIntegrationSuite) TestRunsInCallersTransaction() { + s.controls[s.profileID] = []string{"AC-1"} + var sspID uuid.UUID + rollback := errors.New("rollback") + err := s.DB.Transaction(func(tx *gorm.DB) error { + sspID = s.createSSP(tx) + s.Require().NoError(s.svc.AttachProfile(tx, sspID, s.profileID)) + return rollback + }) + s.Require().ErrorIs(err, rollback) + + var ssps int64 + s.Require().NoError(s.DB.Model(&relational.SystemSecurityPlan{}).Where("id = ?", sspID).Count(&ssps).Error) + s.Zero(ssps) + s.Empty(s.controlIDs(sspID)) +}