Skip to content

Commit 3c0741f

Browse files
committed
feat(compliance)!: measure controls on the event store and make the report a document someone signs
1 parent f9fc388 commit 3c0741f

733 files changed

Lines changed: 12581 additions & 6569 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎backend/database/migrations.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,9 @@ func Models() []any {
5656
arr_domain.UtmIncidentAction{},
5757
arr_domain.UtmIncidentActionCommand{},
5858
arr_domain.UtmIncidentJob{},
59-
compliance_domain.UtmComplianceReportSchedule{},
60-
compliance_domain.UtmComplianceControlStatusOverride{},
61-
compliance_domain.UtmComplianceControlNote{},
59+
compliance_domain.ReportSchedule{},
60+
compliance_domain.Report{},
61+
compliance_domain.ReportScore{},
6262
opensearch_domain.UtmIndexPattern{},
6363
integrations_domain.UtmModule{},
6464
incidents_domain.Incident{},

‎backend/go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ require (
2626
github.com/swaggo/files v1.0.1
2727
github.com/swaggo/gin-swagger v1.6.1
2828
github.com/swaggo/swag v1.16.6
29-
github.com/threatwinds/go-sdk v1.1.27-0.20260805161341-89331a9dad80
29+
github.com/threatwinds/go-sdk v1.1.27-0.20260808112451-a0c815800e09
3030
github.com/tidwall/gjson v1.19.0
3131
github.com/utmstack/license-manager-sdk v0.1.0
3232
golang.org/x/crypto v0.54.0

‎backend/go.sum‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,8 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
417417
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
418418
github.com/threatwinds/go-sdk v1.1.27-0.20260805161341-89331a9dad80 h1:2j0aqm3poypbkY+GQk5nd/H0PcrqBRKaeeCtCKqequg=
419419
github.com/threatwinds/go-sdk v1.1.27-0.20260805161341-89331a9dad80/go.mod h1:Mr5r+NTTe8k28gVS7EgEorn76VPtf0vPSaUCD2bWRSU=
420+
github.com/threatwinds/go-sdk v1.1.27-0.20260808112451-a0c815800e09 h1:5yIMEE1iQDQdyqVafjOGzPkC0rAlb1XIebm+oTQ2JwI=
421+
github.com/threatwinds/go-sdk v1.1.27-0.20260808112451-a0c815800e09/go.mod h1:Mr5r+NTTe8k28gVS7EgEorn76VPtf0vPSaUCD2bWRSU=
420422
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
421423
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
422424
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=

‎backend/migrations/000001_init.up.sql‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ INSERT INTO permissions (name, description) VALUES
5757
('incidents.write', 'Create and manage incidents, their alerts and notes'),
5858
('soar.read', 'List and view SOAR response flows'),
5959
('soar.write', 'Create and run SOAR response flows'),
60-
('compliance.read', 'List and view frameworks, controls, reports and evaluation history'),
61-
('compliance.write', 'Manage controls, evidence, exceptions and report schedules'),
60+
('compliance.read', 'List and view frameworks, controls, reports and score history'),
61+
('compliance.write', 'Manage frameworks and controls, run evaluations, record verdicts on a report and manage report schedules'),
6262
('dashboards.read', 'List and view dashboards, visualizations and their layouts'),
6363
('dashboards.write', 'Create, update and delete dashboards, visualizations and layouts'),
6464
('loganalyzer.read', 'Explore logs and view saved queries'),

‎backend/modules.go‎

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,6 @@ import (
4646
"github.com/utmstack/utmstack/backend/modules/socai"
4747
socai_repository "github.com/utmstack/utmstack/backend/modules/socai/repository"
4848
"github.com/utmstack/utmstack/backend/modules/tenant"
49-
tenant_domain "github.com/utmstack/utmstack/backend/modules/tenant/domain"
50-
tenant_dto "github.com/utmstack/utmstack/backend/modules/tenant/dto"
5149
"github.com/utmstack/utmstack/backend/modules/threatintel"
5250
"github.com/utmstack/utmstack/backend/pkg/agentmanager"
5351
"github.com/utmstack/utmstack/backend/pkg/env"
@@ -146,26 +144,7 @@ func initModules(db *gorm.DB, cfg *config) *modules {
146144
if events != nil {
147145
complianceEventReader = events
148146
}
149-
// tenantMod is built further down but the compliance eval loop needs to
150-
// enumerate active tenants at tick time — a closure late-binds it.
151-
var tenantModRef *tenant.Module
152-
complianceTenantLister := compliance.TenantLister(func(ctx context.Context) ([]string, error) {
153-
if tenantModRef == nil {
154-
return nil, nil
155-
}
156-
items, _, err := tenantModRef.GetTenantUsecase().List(ctx, tenant_dto.Filter{Size: 1000})
157-
if err != nil {
158-
return nil, err
159-
}
160-
out := make([]string, 0, len(items))
161-
for _, t := range items {
162-
if t.Status == tenant_domain.StatusActive {
163-
out = append(out, t.ID.String())
164-
}
165-
}
166-
return out, nil
167-
})
168-
complianceMod := compliance.NewModule(db, complianceEventReader, mailMod.Service(), complianceBranding{uc: brand, uploadDir: cfg.uploadDir}, complianceTenantLister, joblease.New(db),
147+
complianceMod := compliance.NewModule(db, complianceEventReader, mailMod.Service(), complianceBranding{uc: brand, uploadDir: cfg.uploadDir},
169148
func() bool { return billingMod.License().Current().IsEnterprise() })
170149
var eventReader dash_usecase.Reader
171150
if events != nil {
@@ -246,7 +225,6 @@ func initModules(db *gorm.DB, cfg *config) *modules {
246225
iamMod.SetSessionPurger(iam_usecase.NewSessionPurger(refreshRepo, joblease.New(db)))
247226

248227
tenantMod := tenant.NewModule(db, userUsecase)
249-
tenantModRef = tenantMod
250228

251229
aiUsage := socai_repository.NewUsageRepo(db)
252230
aiQuota := &socai.AIQuota{
@@ -354,6 +332,7 @@ func (a complianceBranding) ReportBrand(ctx context.Context) compliance_connecto
354332
return compliance_connectors.ReportBrand{
355333
Name: b.ProductName,
356334
LogoPath: uploadFilePath(a.uploadDir, logoURL),
335+
CoverPath: uploadFilePath(a.uploadDir, b.ReportCoverURL),
357336
AccentHex: b.AccentColor,
358337
}
359338
}

‎backend/modules/audit/domain/event_type.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,10 @@ const (
221221
COMPLIANCE_SCHEDULE_DELETE_SUCCESS ApplicationEventType = "COMPLIANCE_SCHEDULE_DELETE_SUCCESS"
222222
COMPLIANCE_REPORT_DELETE_ATTEMPT ApplicationEventType = "COMPLIANCE_REPORT_DELETE_ATTEMPT"
223223
COMPLIANCE_REPORT_DELETE_SUCCESS ApplicationEventType = "COMPLIANCE_REPORT_DELETE_SUCCESS"
224+
COMPLIANCE_REPORT_EVALUATE_ATTEMPT ApplicationEventType = "COMPLIANCE_REPORT_EVALUATE_ATTEMPT"
225+
COMPLIANCE_REPORT_EVALUATE_SUCCESS ApplicationEventType = "COMPLIANCE_REPORT_EVALUATE_SUCCESS"
224226
// Compliance control + framework user-overlay CRUD (create custom, edit via
225-
// copy-on-write, delete, enable/disable). SetEnabled is audited as UPDATE.
227+
// copy-on-write, delete). Each tenant writes to its own overlay.
226228
COMPLIANCE_CONTROL_CREATE_ATTEMPT ApplicationEventType = "COMPLIANCE_CONTROL_CREATE_ATTEMPT"
227229
COMPLIANCE_CONTROL_CREATE_SUCCESS ApplicationEventType = "COMPLIANCE_CONTROL_CREATE_SUCCESS"
228230
COMPLIANCE_CONTROL_UPDATE_ATTEMPT ApplicationEventType = "COMPLIANCE_CONTROL_UPDATE_ATTEMPT"

‎backend/modules/compliance/connectors/external.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@ package connectors
33
import "context"
44

55
type MailSender interface {
6-
SendComplianceReport(ctx context.Context, toEmail, subject string, pdfData []byte) error
6+
SendComplianceReport(ctx context.Context, to, cc []string, subject string, pdfData []byte) error
77
}

‎backend/modules/compliance/connectors/repository.go‎

Lines changed: 27 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,50 +8,43 @@ import (
88

99
"github.com/utmstack/utmstack/backend/modules/compliance/domain"
1010
"github.com/utmstack/utmstack/backend/modules/compliance/dto"
11+
"github.com/utmstack/utmstack/backend/pkg/common_models"
1112
)
1213

1314
type ScheduleRepository interface {
14-
Create(ctx context.Context, s *domain.UtmComplianceReportSchedule) error
15-
Update(ctx context.Context, s *domain.UtmComplianceReportSchedule) error
16-
GetByID(ctx context.Context, id int64) (*domain.UtmComplianceReportSchedule, error)
17-
ListByUser(ctx context.Context, userID uuid.UUID, f dto.ScheduleFilters) ([]domain.UtmComplianceReportSchedule, int64, error)
18-
ListAll(ctx context.Context) ([]domain.UtmComplianceReportSchedule, error)
19-
Delete(ctx context.Context, id int64) error
20-
ClaimDue(ctx context.Context, id int64, expectedLast, newLast time.Time) (bool, error)
15+
Create(ctx context.Context, s *domain.ReportSchedule) error
16+
Update(ctx context.Context, s *domain.ReportSchedule) error
17+
GetByID(ctx context.Context, id uuid.UUID) (*domain.ReportSchedule, error)
18+
ListByUser(ctx context.Context, userID uuid.UUID, f dto.ScheduleFilters) ([]domain.ReportSchedule, int64, error)
19+
ListDue(ctx context.Context, now time.Time) ([]domain.ReportSchedule, error)
20+
Delete(ctx context.Context, id uuid.UUID) error
21+
ClaimDue(ctx context.Context, id uuid.UUID, expectedNext, newLast, newNext time.Time) (bool, error)
22+
ForFramework(ctx context.Context, frameworkKey string) (*domain.ReportSchedule, error)
2123
}
2224

23-
type OpenSearchSQL interface {
24-
RunCheck(ctx context.Context, sql string) (hits int64, err error)
25+
type CheckQuery struct {
26+
Dataset domain.Dataset
27+
DataType string
28+
Filters []common_models.FilterType
29+
From, To time.Time
2530
}
2631

27-
type OpenSearchAlerts interface {
28-
CountByRuleNames(ctx context.Context, ruleNames []string, sinceISO string) (int64, error)
32+
type EventCounter interface {
33+
Count(ctx context.Context, q CheckQuery) (int64, error)
34+
HasData(ctx context.Context, dataset domain.Dataset, dataType string, from, to time.Time) (bool, error)
35+
CountByRuleNames(ctx context.Context, ruleNames []string, from, to time.Time) (map[string]int64, error)
2936
}
3037

3138
type ReportStore interface {
32-
Save(ctx context.Context, snap *domain.ReportSnapshot) error
33-
List(ctx context.Context, frameworkKey string, limit int) ([]domain.ReportSnapshotMeta, error)
34-
Get(ctx context.Context, id string) (*domain.ReportSnapshot, error)
35-
Delete(ctx context.Context, id string) error
39+
Get(ctx context.Context, frameworkKey string) (*domain.Report, error)
40+
Save(ctx context.Context, r *domain.Report) error
41+
List(ctx context.Context) ([]domain.Report, error)
42+
Delete(ctx context.Context, frameworkKey string) error
3643
}
3744

38-
type ControlStatusOverrideRepository interface {
39-
Upsert(ctx context.Context, o *domain.UtmComplianceControlStatusOverride) error
40-
Delete(ctx context.Context, frameworkKey, controlID string) error
41-
ListByFramework(ctx context.Context, frameworkKey string) (map[string]string, error)
42-
}
43-
44-
type ControlNoteRepository interface {
45-
Upsert(ctx context.Context, n *domain.UtmComplianceControlNote) error
46-
Delete(ctx context.Context, frameworkKey, controlID string) error
47-
ListByFramework(ctx context.Context, frameworkKey string) (map[string]string, error)
48-
}
49-
50-
type TenantFrameworkRepository interface {
51-
List(ctx context.Context) ([]string, error) // framework keys the acting tenant possesses
52-
ListForTenant(ctx context.Context, tenantID string) ([]string, error) // same, for a specific tenant
53-
Enable(ctx context.Context, frameworkKey string) error // upsert (tenant, framework)
54-
Disable(ctx context.Context, frameworkKey string) error // delete (tenant, framework)
55-
Has(ctx context.Context, frameworkKey string) (bool, error)
56-
ListTenants(ctx context.Context, frameworkKey string) ([]string, error) // tenants who possess this framework
45+
type ReportScoreStore interface {
46+
Upsert(ctx context.Context, p *domain.ReportScore) error
47+
History(ctx context.Context, frameworkKey string, from, to time.Time) ([]domain.ReportScore, error)
48+
Body(ctx context.Context, frameworkKey string, day time.Time) ([]byte, error)
49+
PruneBodies(ctx context.Context, before time.Time) (int64, error)
5750
}

‎backend/modules/compliance/connectors/usecase.go‎

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package connectors
22

33
import (
44
"context"
5+
"time"
56

67
"github.com/google/uuid"
78

@@ -10,51 +11,55 @@ import (
1011
)
1112

1213
type FrameworkUsecase interface {
13-
ListControls(ctx context.Context) []domain.Control
14-
GetControl(ctx context.Context, id string) (*domain.Control, error)
14+
ListControls(ctx context.Context) []dto.ControlResponse
15+
GetControl(ctx context.Context, id string) (*dto.ControlResponse, error)
1516

16-
ListFrameworks(ctx context.Context) []domain.Framework
17-
GetFramework(ctx context.Context, key string) (*domain.Framework, error)
17+
ListFrameworks(ctx context.Context) []dto.FrameworkResponse
18+
GetFramework(ctx context.Context, key string) (*dto.FrameworkResponse, error)
1819

1920
CreateControl(ctx context.Context, c domain.Control) (*domain.Control, error)
2021
UpdateControl(ctx context.Context, c domain.Control) (*domain.Control, error)
2122
DeleteControl(ctx context.Context, id string) error
22-
SetControlEnabled(ctx context.Context, id string, enabled bool) error
2323

2424
CreateFramework(ctx context.Context, f domain.Framework) (*domain.Framework, error)
2525
UpdateFramework(ctx context.Context, f domain.Framework) (*domain.Framework, error)
2626
DeleteFramework(ctx context.Context, key string) error
27-
SetFrameworkEnabled(ctx context.Context, key string, enabled bool) error
2827
}
2928

3029
type ReportBrand struct {
31-
Name string // product/company name (defaults to "UTMStack")
32-
LogoPath string // absolute path to a PNG/JPG report logo on disk ("" → none)
30+
Name string // product/company name (defaults to "UTMStack")
31+
LogoPath string // absolute path to a PNG/JPG report logo on disk ("" → none)
32+
// CoverPath is the full-bleed image behind the cover page, configured with
33+
// the rest of the branding. Empty falls back to a plain typographic cover.
34+
CoverPath string
3335
AccentHex string // accent color, CSS hex (e.g. "#6366f1"); "" → default
36+
// PreparedBy names whoever asked for the document. A report says who
37+
// produced it; that is half of what makes it evidence.
38+
PreparedBy string
3439
}
3540

3641
type BrandingProvider interface {
3742
ReportBrand(ctx context.Context) ReportBrand
3843
}
3944

4045
type EvaluatorUsecase interface {
41-
EvaluateFramework(ctx context.Context, frameworkKey string) (*domain.Report, error)
42-
GenerateReport(ctx context.Context, frameworkKey string) (*domain.Report, error) // evaluate + store snapshot
43-
ListReports(ctx context.Context, frameworkKey string, limit int) ([]domain.ReportSnapshotMeta, error)
44-
GetReport(ctx context.Context, id string) (*domain.ReportSnapshot, error)
45-
DeleteReport(ctx context.Context, id string) error
46-
FrameworkReportPDF(ctx context.Context, frameworkKey string) ([]byte, string, error) // live eval → PDF + framework name
47-
SnapshotPDF(ctx context.Context, id string) ([]byte, string, error) // stored snapshot → PDF + framework name
48-
SetStatusOverride(ctx context.Context, frameworkKey, controlID, status, reason string) error
49-
ClearStatusOverride(ctx context.Context, frameworkKey, controlID string) error
50-
SetControlNote(ctx context.Context, frameworkKey, controlID, note string) error
51-
ClearControlNote(ctx context.Context, frameworkKey, controlID string) error
46+
Evaluate(ctx context.Context, frameworkKey string, windowDays int) (*dto.ReportResponse, error)
47+
Get(ctx context.Context, frameworkKey string) (*dto.ReportResponse, error)
48+
List(ctx context.Context) ([]dto.ReportMeta, error)
49+
Delete(ctx context.Context, frameworkKey string) error
50+
EditControl(ctx context.Context, editedBy, frameworkKey, controlID string, req dto.EditControlRequest) (*dto.ReportResponse, error)
51+
// preparedBy names whoever asked for the document — a report says who
52+
// produced it, and that is half of what makes it evidence. A scheduled run
53+
// has no requester and passes "".
54+
PDF(ctx context.Context, frameworkKey, preparedBy string) ([]byte, string, error)
55+
History(ctx context.Context, frameworkKey string, from, to time.Time) ([]dto.ScorePoint, error)
56+
HistoryPDF(ctx context.Context, frameworkKey, preparedBy string, day time.Time) ([]byte, string, error)
5257
}
5358

5459
type ScheduleUsecase interface {
5560
Create(ctx context.Context, userID uuid.UUID, req dto.CreateScheduleRequest) (*dto.ScheduleResponse, error)
5661
Update(ctx context.Context, userID uuid.UUID, req dto.UpdateScheduleRequest) (*dto.ScheduleResponse, error)
57-
GetByID(ctx context.Context, id int64) (*dto.ScheduleResponse, error)
62+
GetByID(ctx context.Context, id uuid.UUID) (*dto.ScheduleResponse, error)
5863
ListByUser(ctx context.Context, userID uuid.UUID, f dto.ScheduleFilters) ([]dto.ScheduleResponse, int64, error)
59-
Delete(ctx context.Context, id int64) error
64+
Delete(ctx context.Context, id uuid.UUID) error
6065
}

0 commit comments

Comments
 (0)