diff --git a/backend/database/migrations.go b/backend/database/migrations.go index 3399f6b97..e454da0c5 100644 --- a/backend/database/migrations.go +++ b/backend/database/migrations.go @@ -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{}, diff --git a/backend/modules/compliance/connectors/repository.go b/backend/modules/compliance/connectors/repository.go index bb8b0997f..3ff62dd23 100644 --- a/backend/modules/compliance/connectors/repository.go +++ b/backend/modules/compliance/connectors/repository.go @@ -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) +} diff --git a/backend/modules/compliance/connectors/usecase.go b/backend/modules/compliance/connectors/usecase.go index efa3e7fdb..6b66f729e 100644 --- a/backend/modules/compliance/connectors/usecase.go +++ b/backend/modules/compliance/connectors/usecase.go @@ -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 { diff --git a/backend/modules/compliance/domain/control_note.go b/backend/modules/compliance/domain/control_note.go new file mode 100644 index 000000000..e22f4548e --- /dev/null +++ b/backend/modules/compliance/domain/control_note.go @@ -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" +} diff --git a/backend/modules/compliance/domain/control_status_override.go b/backend/modules/compliance/domain/control_status_override.go new file mode 100644 index 000000000..9c8028f57 --- /dev/null +++ b/backend/modules/compliance/domain/control_status_override.go @@ -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 +} diff --git a/backend/modules/compliance/domain/errors.go b/backend/modules/compliance/domain/errors.go index 0b385bc09..551019aa0 100644 --- a/backend/modules/compliance/domain/errors.go +++ b/backend/modules/compliance/domain/errors.go @@ -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") ) diff --git a/backend/modules/compliance/domain/report.go b/backend/modules/compliance/domain/report.go index 91faa2d24..25c2e5ea3 100644 --- a/backend/modules/compliance/domain/report.go +++ b/backend/modules/compliance/domain/report.go @@ -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"` @@ -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) } diff --git a/backend/modules/compliance/handler/common.go b/backend/modules/compliance/handler/common.go index b8e3736d3..c607fbca4 100644 --- a/backend/modules/compliance/handler/common.go +++ b/backend/modules/compliance/handler/common.go @@ -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()}) diff --git a/backend/modules/compliance/handler/report.go b/backend/modules/compliance/handler/report.go index 27f58ef0c..a33bcfb09 100644 --- a/backend/modules/compliance/handler/report.go +++ b/backend/modules/compliance/handler/report.go @@ -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 diff --git a/backend/modules/compliance/module.go b/backend/modules/compliance/module.go index 9da647eff..aa39a2a35 100644 --- a/backend/modules/compliance/module.go +++ b/backend/modules/compliance/module.go @@ -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) @@ -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} diff --git a/backend/modules/compliance/repository/control_note_pg.go b/backend/modules/compliance/repository/control_note_pg.go new file mode 100644 index 000000000..663b30f7c --- /dev/null +++ b/backend/modules/compliance/repository/control_note_pg.go @@ -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 +} diff --git a/backend/modules/compliance/repository/control_status_override_pg.go b/backend/modules/compliance/repository/control_status_override_pg.go new file mode 100644 index 000000000..6378a7837 --- /dev/null +++ b/backend/modules/compliance/repository/control_status_override_pg.go @@ -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 +} diff --git a/backend/modules/compliance/routes.go b/backend/modules/compliance/routes.go index 126948861..8fe6f9dab 100644 --- a/backend/modules/compliance/routes.go +++ b/backend/modules/compliance/routes.go @@ -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) diff --git a/backend/modules/compliance/usecase/evaluator.go b/backend/modules/compliance/usecase/evaluator.go index 19894dd76..e3cbff513 100644 --- a/backend/modules/compliance/usecase/evaluator.go +++ b/backend/modules/compliance/usecase/evaluator.go @@ -3,6 +3,7 @@ package usecase import ( "context" "fmt" + "strings" "time" "github.com/google/uuid" @@ -18,12 +19,14 @@ 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 + notes connectors.ControlNoteRepository // optional; nil → no user notes on rows + 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, notes connectors.ControlNoteRepository, brand connectors.BrandingProvider, ent *Entitlement) connectors.EvaluatorUsecase { + return &evaluator{controls: controls, frameworks: frameworks, sql: sql, coverage: coverage, alerts: alerts, store: store, overrides: overrides, notes: notes, brand: brand, ent: ent} } func (e *evaluator) reportBrand(ctx context.Context) connectors.ReportBrand { @@ -111,6 +114,68 @@ 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) +} + +func (e *evaluator) SetControlNote(ctx context.Context, frameworkKey, controlID, note string) error { + if e.notes == nil { + return fmt.Errorf("control notes not configured") + } + if frameworkKey == "" || controlID == "" { + return domain.ErrInvalidID + } + if _, ok := e.frameworks.Get(frameworkKey); !ok { + return domain.ErrFrameworkNotFound + } + // Empty / whitespace-only note is a delete — keep the table tidy. + if strings.TrimSpace(note) == "" { + return e.notes.Delete(ctx, frameworkKey, controlID) + } + return e.notes.Upsert(ctx, &domain.UtmComplianceControlNote{ + FrameworkKey: frameworkKey, + ControlID: controlID, + Note: note, + }) +} + +func (e *evaluator) ClearControlNote(ctx context.Context, frameworkKey, controlID string) error { + if e.notes == nil { + return nil + } + if frameworkKey == "" || controlID == "" { + return domain.ErrInvalidID + } + return e.notes.Delete(ctx, frameworkKey, controlID) +} + // activityWindow is how far back the activity dimension counts alerts. const activityWindow = -30 * 24 * time.Hour @@ -134,6 +199,25 @@ 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}) + } + } + // Notes are surfaced on report rows; errored → treat as no notes. + var notes map[string]string + if e.notes != nil { + if m, err := e.notes.ListByFramework(ctx, fw.Key); err == nil { + notes = m + } else { + _ = catcher.Error("compliance: loading control notes failed", err, map[string]any{"framework": fw.Key}) + } + } + for _, sec := range fw.Sections { rs := domain.ReportSection{Name: sec.Name} seen := map[string]bool{} @@ -146,6 +230,14 @@ 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 + } + if n, ok := notes[cid]; ok { + row.Note = n + } cache[cid] = row } rs.Controls = append(rs.Controls, row) diff --git a/frontend/src/app/routes/index.tsx b/frontend/src/app/routes/index.tsx index 6412f0e88..4813f49fc 100644 --- a/frontend/src/app/routes/index.tsx +++ b/frontend/src/app/routes/index.tsx @@ -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' @@ -121,6 +122,7 @@ export function AppRoutes() { {/* Compliance */} } /> + } /> {/* Legacy redirects */} } /> } /> diff --git a/frontend/src/features/compliance/components/ControlDetailDrawer.tsx b/frontend/src/features/compliance/components/ControlDetailDrawer.tsx new file mode 100644 index 000000000..86b2fc8ec --- /dev/null +++ b/frontend/src/features/compliance/components/ControlDetailDrawer.tsx @@ -0,0 +1,183 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { AlertTriangle, Loader2, X } from 'lucide-react' +import { cn } from '@/shared/lib/utils' +import { complianceService } from '../services/compliance-http.service' +import type { Control, ReportControlRow } from '../types/compliance.types' +import { ControlStatusBadge } from './ControlStatusBadge' + +/** Right-side drawer showing full details of a report control row + its library Control. */ +export function ControlDetailDrawer({ + frameworkKey, + row, + onClose, + onStatusChanged, +}: { + frameworkKey: string + row: ReportControlRow + onClose: () => void + onStatusChanged?: () => void +}) { + const { t } = useTranslation() + const [control, setControl] = useState(null) + const [loading, setLoading] = useState(true) + const [notFound, setNotFound] = useState(false) + + useEffect(() => { + setLoading(true) + setNotFound(false) + complianceService + .getControl(row.controlId) + .then(setControl) + .catch(() => setNotFound(true)) + .finally(() => setLoading(false)) + }, [row.controlId]) + + return ( +
+ +
+ ) +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function Block({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+ {children} +
+ ) +} + +// EvidenceBlock spans the full drawer width, clamps evidence to 2 lines by +// default, and offers a "see more" toggle when the content overflows. +function EvidenceBlock({ text }: { text: string }) { + const { t } = useTranslation() + const [expanded, setExpanded] = useState(false) + const [truncated, setTruncated] = useState(false) + const bodyRef = useRef(null) + + useLayoutEffect(() => { + const el = bodyRef.current + if (!el) return + // Only meaningful while clamped: scrollHeight > clientHeight means content overflows 2 lines. + setTruncated(el.scrollHeight > el.clientHeight + 1) + }, [text]) + + const value = text || '—' + return ( +
+
+ {t('compliance.evidenceLabel', { defaultValue: 'Evidence' })} +
+
+ {value} +
+ {text && (truncated || expanded) && ( + + )} +
+ ) +} diff --git a/frontend/src/features/compliance/components/ControlNoteBubble.tsx b/frontend/src/features/compliance/components/ControlNoteBubble.tsx new file mode 100644 index 000000000..96c998551 --- /dev/null +++ b/frontend/src/features/compliance/components/ControlNoteBubble.tsx @@ -0,0 +1,130 @@ +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { Loader2, MessageSquare, MessageSquareText } from 'lucide-react' +import { toast } from 'sonner' +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/components/ui/button' +import { complianceService, ComplianceHttpError } from '../services/compliance-http.service' +import type { ReportControlRow } from '../types/compliance.types' + +/** + * Notes icon on a control row. Filled when a note exists; click opens a floating + * bubble anchored to the icon with the note text + a textarea to edit or clear it. + * Stops propagation so it doesn't open the row's drawer. + */ +export function ControlNoteBubble({ + frameworkKey, + row, + onSaved, + className, +}: { + frameworkKey: string + row: ReportControlRow + onSaved?: () => void + className?: string +}) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [anchor, setAnchor] = useState(null) + const [draft, setDraft] = useState('') + const [saving, setSaving] = useState(false) + const btnRef = useRef(null) + + const has = !!row.note + + useEffect(() => { + if (!open) return + const onEsc = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false) + window.addEventListener('keydown', onEsc) + return () => window.removeEventListener('keydown', onEsc) + }, [open]) + + const openBubble = (e: React.MouseEvent) => { + e.stopPropagation() + setAnchor(e.currentTarget.getBoundingClientRect()) + setDraft(row.note ?? '') + setOpen(true) + } + + const save = async () => { + if (saving) return + setSaving(true) + try { + const value = draft.trim() + if (value === '') { + if (has) await complianceService.clearControlNote(frameworkKey, row.controlId) + } else { + await complianceService.setControlNote(frameworkKey, row.controlId, value) + } + onSaved?.() + setOpen(false) + } catch (e) { + toast.error(e instanceof ComplianceHttpError ? e.message : t('compliance.noteError', { defaultValue: 'Failed to save note' })) + } finally { + setSaving(false) + } + } + + return ( + <> + + + {open && anchor && createPortal( +
setOpen(false)} + > +
e.stopPropagation()} + > +
+ {t('compliance.notes.title', { defaultValue: 'Notes' })} — {row.controlId} +
+