Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 18 additions & 107 deletions internal/api/handler/oscal/system_security_plans.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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
Expand Down
159 changes: 159 additions & 0 deletions internal/service/relational/sspservice/service.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading