Skip to content

Commit 3ec1262

Browse files
Backlog/v12 compilance (#2317)
* fix[frontend](compilance): added controls visualization inside framework * fix[frontend](compilance): added controls details visualization * fix[backend](compilance): added manual state changes on framework-controls * fix[frontend](compilance): added controls status change button * fix[frontend](compilance): added controls notes * fix[backend](compilance): added control notes * fix[backend](compilance): added new entities on database migration schedule * fix[frontend](compilance): re added framework report generator --------- Co-authored-by: Yorjander Hernandez Vergara <99102374+Kbayero@users.noreply.github.com>
1 parent ff86773 commit 3ec1262

30 files changed

Lines changed: 1052 additions & 96 deletions

backend/database/migrations.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ func Models() []any {
4848
arr_domain.UtmIncidentActionCommand{},
4949
arr_domain.UtmIncidentJob{},
5050
compliance_domain.UtmComplianceReportSchedule{},
51+
compliance_domain.UtmComplianceControlStatusOverride{},
52+
compliance_domain.UtmComplianceControlNote{},
5153
opensearch_domain.UtmIndexPattern{},
5254
integrations_domain.UtmModule{},
5355
incidents_domain.UtmIncident{},

backend/modules/compliance/connectors/repository.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,21 @@ type ReportStore interface {
3030
Get(ctx context.Context, id string) (*domain.ReportSnapshot, error)
3131
Delete(ctx context.Context, id string) error
3232
}
33+
34+
// ControlStatusOverrideRepository stores manual (framework, control) → status
35+
// overrides. Upsert on the unique (framework_key, control_id) pair; ListByFramework
36+
// returns a controlID → status map for the evaluator to consume.
37+
type ControlStatusOverrideRepository interface {
38+
Upsert(ctx context.Context, o *domain.UtmComplianceControlStatusOverride) error
39+
Delete(ctx context.Context, frameworkKey, controlID string) error
40+
ListByFramework(ctx context.Context, frameworkKey string) (map[string]string, error)
41+
}
42+
43+
// ControlNoteRepository stores freeform notes per (framework, control). Same
44+
// upsert-on-unique pattern; ListByFramework returns a controlID → note map for
45+
// the evaluator to attach to report rows.
46+
type ControlNoteRepository interface {
47+
Upsert(ctx context.Context, n *domain.UtmComplianceControlNote) error
48+
Delete(ctx context.Context, frameworkKey, controlID string) error
49+
ListByFramework(ctx context.Context, frameworkKey string) (map[string]string, error)
50+
}

backend/modules/compliance/connectors/usecase.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@ type EvaluatorUsecase interface {
4444
DeleteReport(ctx context.Context, id string) error
4545
FrameworkReportPDF(ctx context.Context, frameworkKey string) ([]byte, string, error) // live eval → PDF + framework name
4646
SnapshotPDF(ctx context.Context, id string) ([]byte, string, error) // stored snapshot → PDF + framework name
47+
48+
// Manual status overrides — applied on live evaluations only (historical
49+
// snapshots are frozen). SetStatusOverride upserts; ClearStatusOverride
50+
// removes the override so the row falls back to the computed status.
51+
SetStatusOverride(ctx context.Context, frameworkKey, controlID, status, reason string) error
52+
ClearStatusOverride(ctx context.Context, frameworkKey, controlID string) error
53+
54+
// User notes on (framework, control) — freeform text, surfaced on live report
55+
// rows. Empty note deletes the row.
56+
SetControlNote(ctx context.Context, frameworkKey, controlID, note string) error
57+
ClearControlNote(ctx context.Context, frameworkKey, controlID string) error
4758
}
4859

4960
type ScheduleUsecase interface {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package domain
2+
3+
import "time"
4+
5+
// UtmComplianceControlNote is a freeform user note attached to a (framework, control)
6+
// pair. Doesn't affect status; surfaced on the report row for the frontend to display.
7+
type UtmComplianceControlNote struct {
8+
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
9+
FrameworkKey string `gorm:"column:framework_key;size:100;not null;uniqueIndex:ux_note_fw_ctl,priority:1"`
10+
ControlID string `gorm:"column:control_id;size:100;not null;uniqueIndex:ux_note_fw_ctl,priority:2"`
11+
Note string `gorm:"column:note;type:text;not null"`
12+
UpdatedAt time.Time `gorm:"column:updated_at;not null"`
13+
}
14+
15+
func (UtmComplianceControlNote) TableName() string {
16+
return "utm_compliance_control_note"
17+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package domain
2+
3+
import "time"
4+
5+
// UtmComplianceControlStatusOverride is a manual status assignment for a
6+
// (framework, control) pair. Applied on top of the evaluator's computed status
7+
type UtmComplianceControlStatusOverride struct {
8+
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
9+
FrameworkKey string `gorm:"column:framework_key;size:100;not null;uniqueIndex:ux_ovr_fw_ctl,priority:1"`
10+
ControlID string `gorm:"column:control_id;size:100;not null;uniqueIndex:ux_ovr_fw_ctl,priority:2"`
11+
Status string `gorm:"column:status;size:32;not null"`
12+
Reason string `gorm:"column:reason;size:500"`
13+
UpdatedAt time.Time `gorm:"column:updated_at;not null"`
14+
}
15+
16+
func (UtmComplianceControlStatusOverride) TableName() string {
17+
return "utm_compliance_control_status_override"
18+
}
19+
20+
func ValidStatus(s string) bool {
21+
switch s {
22+
case StatusCompliant, StatusNonCompliant, StatusAtRisk, StatusNotCovered, StatusOutOfScope, StatusPending:
23+
return true
24+
}
25+
return false
26+
}

backend/modules/compliance/domain/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ var (
1414
ErrInvalidID = errors.New("invalid id/key (must be non-empty and contain no path separators)")
1515
ErrFrameworkLocked = errors.New("this framework requires an Enterprise license")
1616
ErrControlLocked = errors.New("this control requires an Enterprise license")
17+
ErrInvalidStatus = errors.New("invalid control status")
1718
)

backend/modules/compliance/domain/report.go

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,6 @@ type ReportSection struct {
3434
Controls []ReportControlRow `json:"controls"`
3535
}
3636

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

5855
type ReportControlRow struct {
59-
ControlID string `json:"controlId"`
60-
Name string `json:"name"`
61-
Status string `json:"status"` // COMPLIANT | NON_COMPLIANT | AT_RISK | NOT_COVERED | OUT_OF_SCOPE | PENDING
62-
Evidence string `json:"evidence"`
63-
Coverage int `json:"coverage"` // # enabled correlation rules covering this control
64-
Activity int `json:"activity"` // # alerts from those rules in the window
56+
ControlID string `json:"controlId"`
57+
Name string `json:"name"`
58+
Status string `json:"status"` // COMPLIANT | NON_COMPLIANT | AT_RISK | NOT_COVERED | OUT_OF_SCOPE | PENDING
59+
Evidence string `json:"evidence"`
60+
Coverage int `json:"coverage"` // # enabled correlation rules covering this control
61+
Activity int `json:"activity"` // # alerts from those rules in the window
62+
Overridden bool `json:"overridden,omitempty"` // true when status came from a manual override
63+
Note string `json:"note,omitempty"` // user note attached to this (framework, control)
6564
}

backend/modules/compliance/handler/common.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func writeError(c *gin.Context, err error) {
2222
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
2323
case errors.Is(err, domain.ErrControlExists), errors.Is(err, domain.ErrFrameworkExists):
2424
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
25-
case errors.Is(err, domain.ErrInvalidCron), errors.Is(err, domain.ErrInvalidID):
25+
case errors.Is(err, domain.ErrInvalidCron), errors.Is(err, domain.ErrInvalidID), errors.Is(err, domain.ErrInvalidStatus):
2626
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
2727
default:
2828
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})

backend/modules/compliance/handler/report.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,115 @@ func (h *ReportHandler) GetFrameworkReportPDF(c *gin.Context) {
159159
writePDF(c, pdf, name)
160160
}
161161

162+
// SetStatusOverride godoc
163+
//
164+
// @Summary Set a manual status override for a control
165+
// @Description Overrides the evaluator's computed status for a (framework, control) pair. Applied on live evaluations only; historical snapshots are unchanged.
166+
// @Tags Compliance Reports
167+
// @Security BearerAuth
168+
// @Accept json
169+
// @Produce json
170+
// @Param key path string true "Framework key"
171+
// @Param id path string true "Control id"
172+
// @Param body body object true "New status (COMPLIANT | NON_COMPLIANT | AT_RISK | NOT_COVERED | OUT_OF_SCOPE | PENDING) and optional reason"
173+
// @Success 204
174+
// @Failure 400 {object} map[string]string
175+
// @Failure 404 {object} map[string]string
176+
// @Router /compliance/frameworks/{key}/controls/{id}/status [put]
177+
func (h *ReportHandler) SetStatusOverride(c *gin.Context) {
178+
var body struct {
179+
Status string `json:"status"`
180+
Reason string `json:"reason"`
181+
}
182+
if err := c.ShouldBindJSON(&body); err != nil {
183+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
184+
return
185+
}
186+
err := h.uc.SetStatusOverride(c.Request.Context(), c.Param("key"), c.Param("id"), body.Status, body.Reason)
187+
audit.Record(c, audit_connectors.Event{Action: "compliance.control.status.override", ResourceType: "compliance_control", ResourceID: c.Param("id")},
188+
audit_domain.COMPLIANCE_CONTROL_UPDATE_ATTEMPT, audit_domain.COMPLIANCE_CONTROL_UPDATE_SUCCESS, err)
189+
if err != nil {
190+
writeError(c, err)
191+
return
192+
}
193+
c.Status(http.StatusNoContent)
194+
}
195+
196+
// ClearStatusOverride godoc
197+
//
198+
// @Summary Clear a manual status override
199+
// @Description Removes the manual override so the control status falls back to the evaluator's computed value.
200+
// @Tags Compliance Reports
201+
// @Security BearerAuth
202+
// @Param key path string true "Framework key"
203+
// @Param id path string true "Control id"
204+
// @Success 204
205+
// @Failure 404 {object} map[string]string
206+
// @Router /compliance/frameworks/{key}/controls/{id}/status [delete]
207+
func (h *ReportHandler) ClearStatusOverride(c *gin.Context) {
208+
err := h.uc.ClearStatusOverride(c.Request.Context(), c.Param("key"), c.Param("id"))
209+
audit.Record(c, audit_connectors.Event{Action: "compliance.control.status.override.clear", ResourceType: "compliance_control", ResourceID: c.Param("id")},
210+
audit_domain.COMPLIANCE_CONTROL_UPDATE_ATTEMPT, audit_domain.COMPLIANCE_CONTROL_UPDATE_SUCCESS, err)
211+
if err != nil {
212+
writeError(c, err)
213+
return
214+
}
215+
c.Status(http.StatusNoContent)
216+
}
217+
218+
// SetControlNote godoc
219+
//
220+
// @Summary Set / update a user note on a control
221+
// @Description Upserts a freeform note attached to a (framework, control). Empty body deletes the note.
222+
// @Tags Compliance Reports
223+
// @Security BearerAuth
224+
// @Accept json
225+
// @Produce json
226+
// @Param key path string true "Framework key"
227+
// @Param id path string true "Control id"
228+
// @Param body body object true "Note body ({note: string})"
229+
// @Success 204
230+
// @Failure 400 {object} map[string]string
231+
// @Failure 404 {object} map[string]string
232+
// @Router /compliance/frameworks/{key}/controls/{id}/note [put]
233+
func (h *ReportHandler) SetControlNote(c *gin.Context) {
234+
var body struct {
235+
Note string `json:"note"`
236+
}
237+
if err := c.ShouldBindJSON(&body); err != nil {
238+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
239+
return
240+
}
241+
err := h.uc.SetControlNote(c.Request.Context(), c.Param("key"), c.Param("id"), body.Note)
242+
audit.Record(c, audit_connectors.Event{Action: "compliance.control.note.set", ResourceType: "compliance_control", ResourceID: c.Param("id")},
243+
audit_domain.COMPLIANCE_CONTROL_UPDATE_ATTEMPT, audit_domain.COMPLIANCE_CONTROL_UPDATE_SUCCESS, err)
244+
if err != nil {
245+
writeError(c, err)
246+
return
247+
}
248+
c.Status(http.StatusNoContent)
249+
}
250+
251+
// ClearControlNote godoc
252+
//
253+
// @Summary Delete a user note on a control
254+
// @Tags Compliance Reports
255+
// @Security BearerAuth
256+
// @Param key path string true "Framework key"
257+
// @Param id path string true "Control id"
258+
// @Success 204
259+
// @Router /compliance/frameworks/{key}/controls/{id}/note [delete]
260+
func (h *ReportHandler) ClearControlNote(c *gin.Context) {
261+
err := h.uc.ClearControlNote(c.Request.Context(), c.Param("key"), c.Param("id"))
262+
audit.Record(c, audit_connectors.Event{Action: "compliance.control.note.clear", ResourceType: "compliance_control", ResourceID: c.Param("id")},
263+
audit_domain.COMPLIANCE_CONTROL_UPDATE_ATTEMPT, audit_domain.COMPLIANCE_CONTROL_UPDATE_SUCCESS, err)
264+
if err != nil {
265+
writeError(c, err)
266+
return
267+
}
268+
c.Status(http.StatusNoContent)
269+
}
270+
162271
// @Summary Download a stored report snapshot as PDF
163272
// @Tags Compliance Reports
164273
// @Security BearerAuth

backend/modules/compliance/module.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ func (m *Module) GetScheduleUsecase() connectors.ScheduleUsecase { return m.sc
3636

3737
func NewModule(db *gorm.DB, mailSvc mail_connectors.MailService, brand connectors.BrandingProvider, isEnterprise func() bool) *Module {
3838
scheduleRepo := repository.NewScheduleRepository(db)
39+
overrideRepo := repository.NewControlStatusOverrideRepository(db)
40+
noteRepo := repository.NewControlNoteRepository(db)
3941

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

6365
entitlement := usecase.NewEntitlement(isEnterprise)
6466
frameworkUC := usecase.NewFrameworkUsecase(controlStore, frameworkStore, entitlement)
65-
evaluatorUC := usecase.NewEvaluator(controlStore, frameworkStore, repository.NewOpenSearchSQL(), coverageIdx, repository.NewOpenSearchAlerts(), repository.NewReportStore(), brand, entitlement)
67+
evaluatorUC := usecase.NewEvaluator(controlStore, frameworkStore, repository.NewOpenSearchSQL(), coverageIdx, repository.NewOpenSearchAlerts(), repository.NewReportStore(), overrideRepo, noteRepo, brand, entitlement)
6668
scheduleUC := usecase.NewScheduleUsecase(scheduleRepo, frameworkStore, entitlement)
6769

6870
mailSender := &mailSender{svc: mailSvc}

0 commit comments

Comments
 (0)