Skip to content
9 changes: 9 additions & 0 deletions backend/modules/compliance/connectors/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,12 @@ type ReportStore interface {
Get(ctx context.Context, id string) (*domain.ReportSnapshot, error)
Delete(ctx context.Context, id string) error
}

// ControlStatusOverrideRepository stores manual (framework, control) → status
// overrides. Upsert on the unique (framework_key, control_id) pair; ListByFramework
// returns a controlID → status map for the evaluator to consume.
type ControlStatusOverrideRepository interface {
Upsert(ctx context.Context, o *domain.UtmComplianceControlStatusOverride) error
Delete(ctx context.Context, frameworkKey, controlID string) error
ListByFramework(ctx context.Context, frameworkKey string) (map[string]string, error)
}
6 changes: 6 additions & 0 deletions backend/modules/compliance/connectors/usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ type EvaluatorUsecase interface {
DeleteReport(ctx context.Context, id string) error
FrameworkReportPDF(ctx context.Context, frameworkKey string) ([]byte, string, error) // live eval → PDF + framework name
SnapshotPDF(ctx context.Context, id string) ([]byte, string, error) // stored snapshot → PDF + framework name

// Manual status overrides — applied on live evaluations only (historical
// snapshots are frozen). SetStatusOverride upserts; ClearStatusOverride
// removes the override so the row falls back to the computed status.
SetStatusOverride(ctx context.Context, frameworkKey, controlID, status, reason string) error
ClearStatusOverride(ctx context.Context, frameworkKey, controlID string) error
}

type ScheduleUsecase interface {
Expand Down
26 changes: 26 additions & 0 deletions backend/modules/compliance/domain/control_status_override.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package domain

import "time"

// UtmComplianceControlStatusOverride is a manual status assignment for a
// (framework, control) pair. Applied on top of the evaluator's computed status
type UtmComplianceControlStatusOverride struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
FrameworkKey string `gorm:"column:framework_key;size:100;not null;uniqueIndex:ux_ovr_fw_ctl,priority:1"`
ControlID string `gorm:"column:control_id;size:100;not null;uniqueIndex:ux_ovr_fw_ctl,priority:2"`
Status string `gorm:"column:status;size:32;not null"`
Reason string `gorm:"column:reason;size:500"`
UpdatedAt time.Time `gorm:"column:updated_at;not null"`
}

func (UtmComplianceControlStatusOverride) TableName() string {
return "utm_compliance_control_status_override"
}

func ValidStatus(s string) bool {
switch s {
case StatusCompliant, StatusNonCompliant, StatusAtRisk, StatusNotCovered, StatusOutOfScope, StatusPending:
return true
}
return false
}
1 change: 1 addition & 0 deletions backend/modules/compliance/domain/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ var (
ErrInvalidID = errors.New("invalid id/key (must be non-empty and contain no path separators)")
ErrFrameworkLocked = errors.New("this framework requires an Enterprise license")
ErrControlLocked = errors.New("this control requires an Enterprise license")
ErrInvalidStatus = errors.New("invalid control status")
)
16 changes: 7 additions & 9 deletions backend/modules/compliance/domain/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,6 @@ type ReportSection struct {
Controls []ReportControlRow `json:"controls"`
}

// ReportSnapshot is a stored, point-in-time compliance report (in OpenSearch).
// The report is DATA: the frontend renders it, the PDF path renders it, and the
// snapshot is the durable history — no binary PDF is stored.
type ReportSnapshot struct {
ID string `json:"id"`
FrameworkKey string `json:"frameworkKey"`
Expand All @@ -56,10 +53,11 @@ type ReportSnapshotMeta struct {
}

type ReportControlRow struct {
ControlID string `json:"controlId"`
Name string `json:"name"`
Status string `json:"status"` // COMPLIANT | NON_COMPLIANT | AT_RISK | NOT_COVERED | OUT_OF_SCOPE | PENDING
Evidence string `json:"evidence"`
Coverage int `json:"coverage"` // # enabled correlation rules covering this control
Activity int `json:"activity"` // # alerts from those rules in the window
ControlID string `json:"controlId"`
Name string `json:"name"`
Status string `json:"status"` // COMPLIANT | NON_COMPLIANT | AT_RISK | NOT_COVERED | OUT_OF_SCOPE | PENDING
Evidence string `json:"evidence"`
Coverage int `json:"coverage"` // # enabled correlation rules covering this control
Activity int `json:"activity"` // # alerts from those rules in the window
Overridden bool `json:"overridden,omitempty"` // true when status came from a manual override
}
2 changes: 1 addition & 1 deletion backend/modules/compliance/handler/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func writeError(c *gin.Context, err error) {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
case errors.Is(err, domain.ErrControlExists), errors.Is(err, domain.ErrFrameworkExists):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, domain.ErrInvalidCron), errors.Is(err, domain.ErrInvalidID):
case errors.Is(err, domain.ErrInvalidCron), errors.Is(err, domain.ErrInvalidID), errors.Is(err, domain.ErrInvalidStatus):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
Expand Down
56 changes: 56 additions & 0 deletions backend/modules/compliance/handler/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,62 @@ func (h *ReportHandler) GetFrameworkReportPDF(c *gin.Context) {
writePDF(c, pdf, name)
}

// SetStatusOverride godoc
//
// @Summary Set a manual status override for a control
// @Description Overrides the evaluator's computed status for a (framework, control) pair. Applied on live evaluations only; historical snapshots are unchanged.
// @Tags Compliance Reports
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param key path string true "Framework key"
// @Param id path string true "Control id"
// @Param body body object true "New status (COMPLIANT | NON_COMPLIANT | AT_RISK | NOT_COVERED | OUT_OF_SCOPE | PENDING) and optional reason"
// @Success 204
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /compliance/frameworks/{key}/controls/{id}/status [put]
func (h *ReportHandler) SetStatusOverride(c *gin.Context) {
var body struct {
Status string `json:"status"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.uc.SetStatusOverride(c.Request.Context(), c.Param("key"), c.Param("id"), body.Status, body.Reason)
audit.Record(c, audit_connectors.Event{Action: "compliance.control.status.override", ResourceType: "compliance_control", ResourceID: c.Param("id")},
audit_domain.COMPLIANCE_CONTROL_UPDATE_ATTEMPT, audit_domain.COMPLIANCE_CONTROL_UPDATE_SUCCESS, err)
if err != nil {
writeError(c, err)
return
}
c.Status(http.StatusNoContent)
}

// ClearStatusOverride godoc
//
// @Summary Clear a manual status override
// @Description Removes the manual override so the control status falls back to the evaluator's computed value.
// @Tags Compliance Reports
// @Security BearerAuth
// @Param key path string true "Framework key"
// @Param id path string true "Control id"
// @Success 204
// @Failure 404 {object} map[string]string
// @Router /compliance/frameworks/{key}/controls/{id}/status [delete]
func (h *ReportHandler) ClearStatusOverride(c *gin.Context) {
err := h.uc.ClearStatusOverride(c.Request.Context(), c.Param("key"), c.Param("id"))
audit.Record(c, audit_connectors.Event{Action: "compliance.control.status.override.clear", ResourceType: "compliance_control", ResourceID: c.Param("id")},
audit_domain.COMPLIANCE_CONTROL_UPDATE_ATTEMPT, audit_domain.COMPLIANCE_CONTROL_UPDATE_SUCCESS, err)
if err != nil {
writeError(c, err)
return
}
c.Status(http.StatusNoContent)
}

// @Summary Download a stored report snapshot as PDF
// @Tags Compliance Reports
// @Security BearerAuth
Expand Down
3 changes: 2 additions & 1 deletion backend/modules/compliance/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func (m *Module) GetScheduleUsecase() connectors.ScheduleUsecase { return m.sc

func NewModule(db *gorm.DB, mailSvc mail_connectors.MailService, brand connectors.BrandingProvider, isEnterprise func() bool) *Module {
scheduleRepo := repository.NewScheduleRepository(db)
overrideRepo := repository.NewControlStatusOverrideRepository(db)

root := env.String("COMPLIANCE_DIR", "/workdir/compliance", false)
src := env.String("COMPLIANCE_SRC_DIR", "/utmstack/compliance", false)
Expand All @@ -62,7 +63,7 @@ func NewModule(db *gorm.DB, mailSvc mail_connectors.MailService, brand connector

entitlement := usecase.NewEntitlement(isEnterprise)
frameworkUC := usecase.NewFrameworkUsecase(controlStore, frameworkStore, entitlement)
evaluatorUC := usecase.NewEvaluator(controlStore, frameworkStore, repository.NewOpenSearchSQL(), coverageIdx, repository.NewOpenSearchAlerts(), repository.NewReportStore(), brand, entitlement)
evaluatorUC := usecase.NewEvaluator(controlStore, frameworkStore, repository.NewOpenSearchSQL(), coverageIdx, repository.NewOpenSearchAlerts(), repository.NewReportStore(), overrideRepo, brand, entitlement)
scheduleUC := usecase.NewScheduleUsecase(scheduleRepo, frameworkStore, entitlement)

mailSender := &mailSender{svc: mailSvc}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package repository

import (
"context"
"time"

"github.com/utmstack/utmstack/backend/modules/compliance/connectors"
"github.com/utmstack/utmstack/backend/modules/compliance/domain"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)

type pgOverrideRepo struct{ db *gorm.DB }

func NewControlStatusOverrideRepository(db *gorm.DB) connectors.ControlStatusOverrideRepository {
return &pgOverrideRepo{db: db}
}

func (r *pgOverrideRepo) Upsert(ctx context.Context, o *domain.UtmComplianceControlStatusOverride) error {
o.UpdatedAt = time.Now().UTC()
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "framework_key"}, {Name: "control_id"}},
DoUpdates: clause.AssignmentColumns([]string{"status", "reason", "updated_at"}),
}).Create(o).Error
}

func (r *pgOverrideRepo) Delete(ctx context.Context, frameworkKey, controlID string) error {
return r.db.WithContext(ctx).
Where("framework_key = ? AND control_id = ?", frameworkKey, controlID).
Delete(&domain.UtmComplianceControlStatusOverride{}).Error
}

func (r *pgOverrideRepo) ListByFramework(ctx context.Context, frameworkKey string) (map[string]string, error) {
var rows []domain.UtmComplianceControlStatusOverride
if err := r.db.WithContext(ctx).
Where("framework_key = ?", frameworkKey).
Find(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]string, len(rows))
for _, o := range rows {
out[o.ControlID] = o.Status
}
return out, nil
}
3 changes: 3 additions & 0 deletions backend/modules/compliance/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) {
fw.PUT("/frameworks/:key", write, m.frameworkH.UpdateFramework)
fw.DELETE("/frameworks/:key", write, m.frameworkH.DeleteFramework)
fw.PUT("/frameworks/:key/enabled", write, m.frameworkH.SetFrameworkEnabled)

fw.PUT("/frameworks/:key/controls/:id/status", write, m.reportH.SetStatusOverride)
fw.DELETE("/frameworks/:key/controls/:id/status", write, m.reportH.ClearStatusOverride)
fw.POST("/entitlement", middleware.RequireInternal(), m.frameworkH.ApplyEntitlement)

sched := api.Group("/compliance-report-schedules", userAuth)
Expand Down
53 changes: 50 additions & 3 deletions backend/modules/compliance/usecase/evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ type evaluator struct {
coverage *CoverageIndex
alerts connectors.OpenSearchAlerts
store connectors.ReportStore
brand connectors.BrandingProvider // optional; nil → default UTMStack branding
overrides connectors.ControlStatusOverrideRepository // optional; nil → no manual overrides
brand connectors.BrandingProvider // optional; nil → default UTMStack branding
ent *Entitlement
}

func NewEvaluator(controls *ControlStore, frameworks *FrameworkStore, sql connectors.OpenSearchSQL, coverage *CoverageIndex, alerts connectors.OpenSearchAlerts, store connectors.ReportStore, brand connectors.BrandingProvider, ent *Entitlement) connectors.EvaluatorUsecase {
return &evaluator{controls: controls, frameworks: frameworks, sql: sql, coverage: coverage, alerts: alerts, store: store, brand: brand, ent: ent}
func NewEvaluator(controls *ControlStore, frameworks *FrameworkStore, sql connectors.OpenSearchSQL, coverage *CoverageIndex, alerts connectors.OpenSearchAlerts, store connectors.ReportStore, overrides connectors.ControlStatusOverrideRepository, brand connectors.BrandingProvider, ent *Entitlement) connectors.EvaluatorUsecase {
return &evaluator{controls: controls, frameworks: frameworks, sql: sql, coverage: coverage, alerts: alerts, store: store, overrides: overrides, brand: brand, ent: ent}
}

func (e *evaluator) reportBrand(ctx context.Context) connectors.ReportBrand {
Expand Down Expand Up @@ -111,6 +112,37 @@ func (e *evaluator) SnapshotPDF(ctx context.Context, id string) ([]byte, string,
return pdf, snap.FrameworkName, err
}

func (e *evaluator) SetStatusOverride(ctx context.Context, frameworkKey, controlID, status, reason string) error {
if e.overrides == nil {
return fmt.Errorf("status overrides not configured")
}
if frameworkKey == "" || controlID == "" {
return domain.ErrInvalidID
}
if _, ok := e.frameworks.Get(frameworkKey); !ok {
return domain.ErrFrameworkNotFound
}
if !domain.ValidStatus(status) {
return domain.ErrInvalidStatus
}
return e.overrides.Upsert(ctx, &domain.UtmComplianceControlStatusOverride{
FrameworkKey: frameworkKey,
ControlID: controlID,
Status: status,
Reason: reason,
})
}

func (e *evaluator) ClearStatusOverride(ctx context.Context, frameworkKey, controlID string) error {
if e.overrides == nil {
return nil
}
if frameworkKey == "" || controlID == "" {
return domain.ErrInvalidID
}
return e.overrides.Delete(ctx, frameworkKey, controlID)
}

// activityWindow is how far back the activity dimension counts alerts.
const activityWindow = -30 * 24 * time.Hour

Expand All @@ -134,6 +166,16 @@ func (e *evaluator) EvaluateFramework(ctx context.Context, key string) (*domain.
since := now.Add(activityWindow).Format(time.RFC3339)
cache := map[string]domain.ReportControlRow{}

// Load manual overrides once per evaluation; missing / errored → treat as no overrides.
var overrides map[string]string
if e.overrides != nil {
if m, err := e.overrides.ListByFramework(ctx, fw.Key); err == nil {
overrides = m
} else {
_ = catcher.Error("compliance: loading status overrides failed", err, map[string]any{"framework": fw.Key})
}
}

for _, sec := range fw.Sections {
rs := domain.ReportSection{Name: sec.Name}
seen := map[string]bool{}
Expand All @@ -146,6 +188,11 @@ func (e *evaluator) EvaluateFramework(ctx context.Context, key string) (*domain.
row, cached := cache[cid]
if !cached {
row = e.evalControl(ctx, cid, since)
if s, ok := overrides[cid]; ok && domain.ValidStatus(s) && s != row.Status {
row.Evidence = fmt.Sprintf("manual override (was %s)", row.Status)
row.Status = s
row.Overridden = true
}
cache[cid] = row
}
rs.Controls = append(rs.Controls, row)
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { LogExplorerPage } from '@/features/log-explorer/pages/LogExplorerPage'
import { UserAuditorPage } from '@/features/user-auditor/pages/UserAuditorPage'
import { ThreatIntelPage } from '@/features/threat-intel/pages/ThreatIntelPage'
import { CompliancePage } from '@/features/compliance/pages/CompliancePage'
import { FrameworkReportPage } from '@/features/compliance/pages/FrameworkReportPage'
import { DataSourcesPage } from '@/features/datasources/pages/DataSourcesPage'
import { IntegrationsPage } from '@/features/integrations/pages/IntegrationsPage'
import { AlertingRulesPage } from '@/features/alerting-rules/pages/AlertingRulesPage'
Expand Down Expand Up @@ -121,6 +122,7 @@ export function AppRoutes() {

{/* Compliance */}
<Route path="compliance" element={<CompliancePage />} />
<Route path="compliance/frameworks/:key" element={<FrameworkReportPage />} />
{/* Legacy redirects */}
<Route path="compliance/new" element={<Navigate to="/compliance" replace />} />
<Route path="compliance/schedule" element={<Navigate to="/compliance" replace />} />
Expand Down
Loading
Loading