Skip to content
2 changes: 2 additions & 0 deletions backend/database/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ func Models() []any {
arr_domain.UtmIncidentActionCommand{},
arr_domain.UtmIncidentJob{},
compliance_domain.UtmComplianceReportSchedule{},
compliance_domain.UtmComplianceControlStatusOverride{},
compliance_domain.UtmComplianceControlNote{},
opensearch_domain.UtmIndexPattern{},
integrations_domain.UtmModule{},
incidents_domain.UtmIncident{},
Expand Down
18 changes: 18 additions & 0 deletions backend/modules/compliance/connectors/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,21 @@ 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)
}

// ControlNoteRepository stores freeform notes per (framework, control). Same
// upsert-on-unique pattern; ListByFramework returns a controlID → note map for
// the evaluator to attach to report rows.
type ControlNoteRepository interface {
Upsert(ctx context.Context, n *domain.UtmComplianceControlNote) error
Delete(ctx context.Context, frameworkKey, controlID string) error
ListByFramework(ctx context.Context, frameworkKey string) (map[string]string, error)
}
11 changes: 11 additions & 0 deletions backend/modules/compliance/connectors/usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ 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

// User notes on (framework, control) — freeform text, surfaced on live report
// rows. Empty note deletes the row.
SetControlNote(ctx context.Context, frameworkKey, controlID, note string) error
ClearControlNote(ctx context.Context, frameworkKey, controlID string) error
}

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

import "time"

// UtmComplianceControlNote is a freeform user note attached to a (framework, control)
// pair. Doesn't affect status; surfaced on the report row for the frontend to display.
type UtmComplianceControlNote struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
FrameworkKey string `gorm:"column:framework_key;size:100;not null;uniqueIndex:ux_note_fw_ctl,priority:1"`
ControlID string `gorm:"column:control_id;size:100;not null;uniqueIndex:ux_note_fw_ctl,priority:2"`
Note string `gorm:"column:note;type:text;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;not null"`
}

func (UtmComplianceControlNote) TableName() string {
return "utm_compliance_control_note"
}
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")
)
17 changes: 8 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,12 @@ 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
Note string `json:"note,omitempty"` // user note attached to this (framework, control)
}
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
109 changes: 109 additions & 0 deletions backend/modules/compliance/handler/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,115 @@ 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)
}

// SetControlNote godoc
//
// @Summary Set / update a user note on a control
// @Description Upserts a freeform note attached to a (framework, control). Empty body deletes the note.
// @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 "Note body ({note: string})"
// @Success 204
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /compliance/frameworks/{key}/controls/{id}/note [put]
func (h *ReportHandler) SetControlNote(c *gin.Context) {
var body struct {
Note string `json:"note"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.uc.SetControlNote(c.Request.Context(), c.Param("key"), c.Param("id"), body.Note)
audit.Record(c, audit_connectors.Event{Action: "compliance.control.note.set", 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)
}

// ClearControlNote godoc
//
// @Summary Delete a user note on a control
// @Tags Compliance Reports
// @Security BearerAuth
// @Param key path string true "Framework key"
// @Param id path string true "Control id"
// @Success 204
// @Router /compliance/frameworks/{key}/controls/{id}/note [delete]
func (h *ReportHandler) ClearControlNote(c *gin.Context) {
err := h.uc.ClearControlNote(c.Request.Context(), c.Param("key"), c.Param("id"))
audit.Record(c, audit_connectors.Event{Action: "compliance.control.note.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
4 changes: 3 additions & 1 deletion backend/modules/compliance/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ 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)
noteRepo := repository.NewControlNoteRepository(db)

root := env.String("COMPLIANCE_DIR", "/workdir/compliance", false)
src := env.String("COMPLIANCE_SRC_DIR", "/utmstack/compliance", false)
Expand All @@ -62,7 +64,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, noteRepo, brand, entitlement)
scheduleUC := usecase.NewScheduleUsecase(scheduleRepo, frameworkStore, entitlement)

mailSender := &mailSender{svc: mailSvc}
Expand Down
45 changes: 45 additions & 0 deletions backend/modules/compliance/repository/control_note_pg.go
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 pgNoteRepo struct{ db *gorm.DB }

func NewControlNoteRepository(db *gorm.DB) connectors.ControlNoteRepository {
return &pgNoteRepo{db: db}
}

func (r *pgNoteRepo) Upsert(ctx context.Context, n *domain.UtmComplianceControlNote) error {
n.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{"note", "updated_at"}),
}).Create(n).Error
}

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

func (r *pgNoteRepo) ListByFramework(ctx context.Context, frameworkKey string) (map[string]string, error) {
var rows []domain.UtmComplianceControlNote
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 _, n := range rows {
out[n.ControlID] = n.Note
}
return out, nil
}
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
}
5 changes: 5 additions & 0 deletions backend/modules/compliance/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ 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.PUT("/frameworks/:key/controls/:id/note", write, m.reportH.SetControlNote)
fw.DELETE("/frameworks/:key/controls/:id/note", write, m.reportH.ClearControlNote)
fw.POST("/entitlement", middleware.RequireInternal(), m.frameworkH.ApplyEntitlement)

sched := api.Group("/compliance-report-schedules", userAuth)
Expand Down
Loading
Loading