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
11 changes: 10 additions & 1 deletion backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2918,17 +2918,26 @@ func main() {
// The calculation itself lives in internal/domain/scoring and nowhere else:
// no formula, no threshold and no band mapping exists in the frontend.
// =========================================================================
scoreHandler := newScoreHandler(
scoreSnapshotRepo := repository.NewGormScoreSnapshotRepository(database.DB)
scoreHandler, scoreHistoryUC := newScoreHandler(
riskRepo, assetRepo, vulnRepo,
repository.NewGormMitigationRepository(database.DB),
getGapAnalysisUC, incidentService,
scoreSnapshotRepo,
)
// One snapshot per tenant per day, so the dashboard can draw the year and
// the 30-day movement even for a tenant nobody opened that day (#901).
go workers.NewScoreSnapshotWorker(scoreSnapshotRepo, scoreHistoryUC, zeroLogger).
Start(context.Background())
// Readable by any authenticated member: a posture score is what the product
// is FOR, and the underlying detail is already gated per source.
// Tenant-scoped score figures; every business role preset holds risks:read,
// and preview persists nothing. Session-sufficient (#529).
protected.Get("/score", scoreHandler.GetScore)
protected.Get("/score/model", scoreHandler.GetScoreModel)
// Month-by-month tenant score and its 30-day delta (#901). Same audience as
// /score: the history of a figure every member can already read.
protected.Get("/score/history", scoreHandler.GetScoreHistory)
// Live preview for forms (debounced client-side). Persists nothing, so it
// carries no write permission.
protected.Post("/score/preview", scoreHandler.PreviewScore)
Expand Down
2 changes: 2 additions & 0 deletions backend/cmd/server/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ func schemaModels() []interface{} {
// invitations model was never in AutoMigrate, so the table never existed
// and the invitation code that referenced it could not have run.
&domain.Invitation{},
// Daily tenant exposure score history (#901, migration 0069).
&domain.TenantScoreSnapshot{},
&coreauth.RefreshToken{},
// L2/L4/L5/L7 auth tables. Previously absent from AutoMigrate, so the whole
// feature set (MFA setup/challenge, PAT auth, SSO account linking, and the
Expand Down
9 changes: 6 additions & 3 deletions backend/cmd/server/score_wiring.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,17 @@ func (a incidentPressureAdapter) OpenIncidentCounts(_ context.Context, tenantID

// newScoreHandler assembles the ONE score endpoint from the already-constructed,
// tenant-scoped sources. Every source is optional in the use case, so a missing
// one degrades its own factor instead of failing the request.
// one degrades its own factor instead of failing the request. It also returns
// the history use case, which the daily snapshot worker shares (#901).
func newScoreHandler(
riskRepo *repository.GormRiskRepository,
assetRepo *repository.GormAssetRepository,
vulnRepo *repository.GormVulnerabilityRepository,
mitigationRepo repository.MitigationRepository,
gapUC *compliance.GetGapAnalysisUseCase,
incidentSvc *service.IncidentService,
) *handlers.ScoreHandler {
snapshots *repository.GormScoreSnapshotRepository,
) (*handlers.ScoreHandler, *score.HistoryUseCase) {
uc := score.New().
WithRiskCounts(riskRepo).
WithRisk(riskRepo).
Expand All @@ -73,5 +75,6 @@ func newScoreHandler(
WithMitigations(mitigationRepo).
WithCompliance(complianceCoverageAdapter{gaps: gapUC}).
WithIncidents(incidentPressureAdapter{svc: incidentSvc})
return handlers.NewScoreHandler(uc)
history := score.NewHistory(uc, snapshots)
return handlers.NewScoreHandler(uc).WithHistory(history), history
}
163 changes: 163 additions & 0 deletions backend/internal/application/score/history.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// Copyright (c) 2026 OpenDefender Contributors
// SPDX-License-Identifier: AGPL-3.0-only
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License v3.0 (see LICENSE).

package score

import (
"context"
"time"

"github.com/google/uuid"

"github.com/opendefender/openrisk/internal/domain"
"github.com/opendefender/openrisk/internal/domain/scoring"
)

// SnapshotStore persists the daily tenant score (#901).
type SnapshotStore interface {
Upsert(ctx context.Context, s *domain.TenantScoreSnapshot) error
ListSince(ctx context.Context, tenantID uuid.UUID, since time.Time) ([]domain.TenantScoreSnapshot, error)
}

// HistoryPoint is one month of the tenant score: the last snapshot taken in
// that month. A month with no snapshot is absent, never zero.
type HistoryPoint struct {
Month string `json:"month"` // YYYY-MM
Day time.Time `json:"day"`
Value float64 `json:"value"`
Band string `json:"band"`
}

// History is what GET /score/history returns.
type History struct {
Months int `json:"months"`
Points []HistoryPoint `json:"points"`
// Current is today's measured value, nil when the tenant has nothing to
// score yet.
Current *float64 `json:"current"`
// Delta30d is today's value minus the value 30 days ago (the last snapshot
// on or before that day). Nil when no snapshot is that old: a delta needs
// two real measurements.
Delta30d *float64 `json:"delta_30d"`
// Since is the oldest snapshot kept for the tenant within the window, so
// the UI can say how far back the line really goes.
Since *time.Time `json:"since"`
}

// HistoryUseCase records and reads the daily tenant score history.
type HistoryUseCase struct {
score *UseCase
store SnapshotStore
now func() time.Time
}

func NewHistory(score *UseCase, store SnapshotStore) *HistoryUseCase {
return &HistoryUseCase{score: score, store: store, now: func() time.Time { return time.Now().UTC() }}
}

// Record computes the tenant score and stores it as today's snapshot. An
// unmeasured score stores nothing.
func (h *HistoryUseCase) Record(ctx context.Context, tenantID uuid.UUID) (*scoring.Result, error) {
if tenantID == uuid.Nil {
return nil, domain.NewValidationError("tenant is required")
}
res, err := h.score.Execute(ctx, tenantID, scoring.ScopeTenant, uuid.Nil)
if err != nil {
return nil, err
}
if res == nil || !res.Measured {
return res, nil
}
snap := &domain.TenantScoreSnapshot{
TenantID: tenantID,
Day: domain.SnapshotDay(h.now()),
Value: res.Value,
Band: string(res.Band),
FormulaVersion: res.FormulaVersion,
}
if err := h.store.Upsert(ctx, snap); err != nil {
return res, err
}
return res, nil
}

// Execute returns the monthly history over the last `months` months (1–24),
// recording today's snapshot first so the line always ends on the live value.
func (h *HistoryUseCase) Execute(ctx context.Context, tenantID uuid.UUID, months int) (*History, error) {
if tenantID == uuid.Nil {
return nil, domain.NewValidationError("tenant is required")
}
if months < 1 || months > 24 {
return nil, domain.NewValidationError("months must be between 1 and 24")
}
if _, err := h.Record(ctx, tenantID); err != nil {
return nil, err
}

today := domain.SnapshotDay(h.now())
start := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, -(months - 1), 0)
// Reach 30 days further back than the window when needed, so the delta can
// be computed even for a one-month window.
from := start
if d := today.AddDate(0, 0, -30); d.Before(from) {
from = d
}
rows, err := h.store.ListSince(ctx, tenantID, from)
if err != nil {
return nil, err
}

out := &History{Months: months, Points: []HistoryPoint{}}
byMonth := map[string]HistoryPoint{}
var order []string
for _, r := range rows {
day := domain.SnapshotDay(r.Day)
if day.Before(start) {
continue
}
key := day.Format("2006-01")
if _, seen := byMonth[key]; !seen {
order = append(order, key)
}
// Rows come oldest first, so the last write wins: the month's closing value.
byMonth[key] = HistoryPoint{Month: key, Day: day, Value: r.Value, Band: r.Band}
if out.Since == nil {
d := day
out.Since = &d
}
}
for _, k := range order {
out.Points = append(out.Points, byMonth[k])
}

var current *domain.TenantScoreSnapshot
var past *domain.TenantScoreSnapshot
cutoff := today.AddDate(0, 0, -30)
for i := range rows {
day := domain.SnapshotDay(rows[i].Day)
if day.Equal(today) {
current = &rows[i]
}
if !day.After(cutoff) {
past = &rows[i]
}
}
if current != nil {
v := current.Value
out.Current = &v
if past != nil {
d := roundTenth(current.Value - past.Value)
out.Delta30d = &d
}
}
return out, nil
}

func roundTenth(v float64) float64 {
if v < 0 {
return -float64(int64(-v*10+0.5)) / 10
}
return float64(int64(v*10+0.5)) / 10
}
166 changes: 166 additions & 0 deletions backend/internal/application/score/history_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Copyright (c) 2026 OpenDefender Contributors
// SPDX-License-Identifier: AGPL-3.0-only
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License v3.0 (see LICENSE).

package score

import (
"context"
"errors"
"testing"
"time"

"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/opendefender/openrisk/internal/domain"
)

// memSnapshots is an in-memory SnapshotStore keyed like the real table.
type memSnapshots struct {
rows map[string]domain.TenantScoreSnapshot
upsertErr error
}

func newMemSnapshots() *memSnapshots {
return &memSnapshots{rows: map[string]domain.TenantScoreSnapshot{}}
}

func (m *memSnapshots) key(t uuid.UUID, d time.Time) string {
return t.String() + "|" + domain.SnapshotDay(d).Format("2006-01-02")
}

func (m *memSnapshots) Upsert(_ context.Context, s *domain.TenantScoreSnapshot) error {
if m.upsertErr != nil {
return m.upsertErr
}
m.rows[m.key(s.TenantID, s.Day)] = *s
return nil
}

func (m *memSnapshots) ListSince(_ context.Context, tenantID uuid.UUID, since time.Time) ([]domain.TenantScoreSnapshot, error) {
var out []domain.TenantScoreSnapshot
for _, r := range m.rows {
if r.TenantID == tenantID && !domain.SnapshotDay(r.Day).Before(domain.SnapshotDay(since)) {
out = append(out, r)
}
}
// oldest first, as the repository orders
for i := 0; i < len(out); i++ {
for j := i + 1; j < len(out); j++ {
if out[j].Day.Before(out[i].Day) {
out[i], out[j] = out[j], out[i]
}
}
}
return out, nil
}

func (m *memSnapshots) put(t uuid.UUID, day string, v float64) {
d, _ := time.Parse("2006-01-02", day)
m.rows[m.key(t, d)] = domain.TenantScoreSnapshot{TenantID: t, Day: d, Value: v, Band: "medium"}
}

// measuredUseCase scores a tenant with two critical and three high risks, which
// is enough data for a measured result.
func measuredUseCase() *UseCase {
rc := &stubRiskCounts{counts: map[string]int{"critical": 2, "high": 3, "medium": 1}}
return New().WithRiskCounts(rc)
}

func fixedNow(day string) func() time.Time {
d, _ := time.Parse("2006-01-02", day)
return func() time.Time { return d.Add(15 * time.Hour) }
}

func TestScoreHistory_Success(t *testing.T) {
store := newMemSnapshots()
tenant := uuid.New()
// Two readings in August (the later one closes the month), one in
// September exactly 30 days before "today".
store.put(tenant, "2026-08-03", 70)
store.put(tenant, "2026-08-28", 66)
store.put(tenant, "2026-09-08", 61)

uc := measuredUseCase()
uc.now = fixedNow("2026-10-08")
h := NewHistory(uc, store)
h.now = fixedNow("2026-10-08")

out, err := h.Execute(context.Background(), tenant, 12)
require.NoError(t, err)
require.NotNil(t, out.Current, "today is recorded before the history is read")

months := make([]string, 0, len(out.Points))
for _, p := range out.Points {
months = append(months, p.Month)
}
assert.Equal(t, []string{"2026-08", "2026-09", "2026-10"}, months)
assert.Equal(t, 66.0, out.Points[0].Value, "a month reads as its last snapshot")
require.NotNil(t, out.Delta30d)
assert.InDelta(t, *out.Current-61, *out.Delta30d, 0.051)
require.NotNil(t, out.Since)
assert.Equal(t, "2026-08-03", out.Since.Format("2006-01-02"))
}

// A tenant with no history yet still gets today's point, but no delta: a
// movement needs two real measurements.
func TestScoreHistory_NotFound(t *testing.T) {
store := newMemSnapshots()
uc := measuredUseCase()
h := NewHistory(uc, store)

out, err := h.Execute(context.Background(), uuid.New(), 12)
require.NoError(t, err)
assert.Len(t, out.Points, 1)
assert.NotNil(t, out.Current)
assert.Nil(t, out.Delta30d)
}

// An unmeasured tenant stores nothing and reports no current value.
func TestScoreHistory_UnmeasuredStoresNothing(t *testing.T) {
store := newMemSnapshots()
uc, _, _, _, _ := emptyTenantUseCase()
h := NewHistory(uc, store)

out, err := h.Execute(context.Background(), uuid.New(), 12)
require.NoError(t, err)
assert.Empty(t, out.Points)
assert.Nil(t, out.Current)
assert.Empty(t, store.rows)
}

// No tenant, no history; and one tenant never reads another's snapshots.
func TestScoreHistory_Unauthorized(t *testing.T) {
store := newMemSnapshots()
h := NewHistory(measuredUseCase(), store)

_, err := h.Execute(context.Background(), uuid.Nil, 12)
assert.True(t, errors.Is(err, domain.ErrValidation))

other, mine := uuid.New(), uuid.New()
store.put(other, "2026-09-01", 90)
out, err := h.Execute(context.Background(), mine, 12)
require.NoError(t, err)
for _, p := range out.Points {
assert.NotEqual(t, 90.0, p.Value, "another tenant's snapshot leaked into the history")
}
}

func TestScoreHistory_MonthsValidation(t *testing.T) {
h := NewHistory(measuredUseCase(), newMemSnapshots())
for _, m := range []int{0, 25, -1} {
_, err := h.Execute(context.Background(), uuid.New(), m)
assert.True(t, errors.Is(err, domain.ErrValidation), "months=%d", m)
}
}

func TestScoreHistory_StoreFailureSurfaces(t *testing.T) {
store := newMemSnapshots()
store.upsertErr = errors.New("db down")
h := NewHistory(measuredUseCase(), store)
_, err := h.Execute(context.Background(), uuid.New(), 12)
assert.Error(t, err)
}
Loading
Loading