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
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