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
3 changes: 3 additions & 0 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2925,6 +2925,7 @@ func main() {
getGapAnalysisUC, incidentService,
scoreSnapshotRepo,
)
scoreHistoryUC.WithFigures(postureFiguresAdapter{financial: financialSummaryUseCase, gaps: getGapAnalysisUC})
// 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).
Expand Down Expand Up @@ -3244,6 +3245,8 @@ func main() {
protected.Post("/governance/approvals", governanceHandler.SubmitApproval)
protected.Get("/governance/approvals/:id", governanceHandler.GetApproval)
protected.Post("/governance/approvals/:id/decide", governanceHandler.DecideApproval)
// Defer to the next committee (#903): eligibility is the decide rule.
protected.Post("/governance/approvals/:id/defer", governanceHandler.DeferApproval)
protected.Post("/governance/approvals/:id/cancel", governanceHandler.CancelApproval)
// Requests nobody decided in time close as EXPIRED — a distinct outcome from
// a refusal, because "nobody said no" and "someone said no" call for
Expand Down
40 changes: 40 additions & 0 deletions backend/cmd/server/score_wiring.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ package main

import (
"context"
"time"

"github.com/google/uuid"

"github.com/opendefender/openrisk/internal/application/compliance"
"github.com/opendefender/openrisk/internal/application/risk"
"github.com/opendefender/openrisk/internal/application/score"
"github.com/opendefender/openrisk/internal/domain/timeframe"
handlers "github.com/opendefender/openrisk/internal/handler"
"github.com/opendefender/openrisk/internal/infrastructure/database"
"github.com/opendefender/openrisk/internal/infrastructure/repository"
"github.com/opendefender/openrisk/internal/service"
)
Expand Down Expand Up @@ -78,3 +82,39 @@ func newScoreHandler(
history := score.NewHistory(uc, snapshots)
return handlers.NewScoreHandler(uc).WithHistory(history), history
}

// postureFiguresAdapter reads the executive view's headline figures for the
// daily snapshot (#903): the annualised exposure, the live critical risks and
// the average compliance coverage. Each source is read on its own; a failure
// leaves that one figure nil rather than failing the snapshot.
type postureFiguresAdapter struct {
financial *risk.FinancialSummaryUseCase
gaps *compliance.GetGapAnalysisUseCase
}

func (a postureFiguresAdapter) Figures(ctx context.Context, tenantID uuid.UUID) score.Figures {
var f score.Figures
if a.financial != nil {
if sum, err := a.financial.Execute(ctx, tenantID); err == nil && sum != nil {
v := sum.TotalALE.XAF
f.ALEXAF = &v
}
}
if w, err := timeframe.Parse("all", "", "", time.Now().UTC()); err == nil {
if stats, err := handlers.ComputeDashboardStats(ctx, database.DB, tenantID, w); err == nil {
n := int(stats.CriticalLive)
f.CriticalRisks = &n
}
}
if a.gaps != nil {
if g, err := a.gaps.Execute(ctx, tenantID, uuid.Nil); err == nil && g != nil && len(g.Frameworks) > 0 {
var sum float64
for _, fw := range g.Frameworks {
sum += fw.PercentComplete
}
v := sum / float64(len(g.Frameworks))
f.CompliancePct = &v
}
}
return f
}
87 changes: 87 additions & 0 deletions backend/internal/application/governance/approval_defer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// 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 governance

import (
"context"
"errors"
"testing"

"github.com/google/uuid"

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

func submitTwoStep(t *testing.T) (*fakeApprovalRepo, uuid.UUID, uuid.UUID, *domain.ApprovalRequest) {
t.Helper()
repo := newFakeApprovalRepo()
tenant, requester := uuid.New(), uuid.New()
seedTwoStepWorkflow(t, repo, tenant)
req, err := NewSubmitApprovalRequestUseCase(repo, repo).Execute(context.Background(), tenant, requester, SubmitApprovalInput{
EntityType: "risk_acceptance", Action: "accept", EntityID: "risk-1", Title: "Accept residual risk",
})
if err != nil {
t.Fatalf("submit: %v", err)
}
return repo, tenant, requester, req
}

// #903: deferring leaves the request pending at the same step, with a dated
// note, and the next approval still works as before.
func TestDeferApproval_Success(t *testing.T) {
repo, tenant, _, req := submitTwoStep(t)
decide := NewDecideApprovalUseCase(repo)
manager := uuid.New()

got, err := decide.Defer(context.Background(), tenant, req.ID,
ApproverIdentity{UserID: manager, Email: "m@x.io", Roles: []string{"manager"}}, " needs the Q4 budget ")
if err != nil {
t.Fatalf("defer: %v", err)
}
if got.Status != domain.ApprovalPending || got.CurrentStep != req.CurrentStep {
t.Fatalf("defer moved the circuit: status=%s step=%d", got.Status, got.CurrentStep)
}
if len(got.Deferrals) != 1 || got.Deferrals[0].Comment != "needs the Q4 budget" || got.Deferrals[0].DeferredBy != manager.String() {
t.Fatalf("deferral not recorded: %+v", got.Deferrals)
}
if len(got.Decisions) != 0 {
t.Fatalf("a deferral must not be a decision: %+v", got.Decisions)
}
// The same manager can still approve the step afterwards.
after, err := decide.Execute(context.Background(), tenant, req.ID,
ApproverIdentity{UserID: manager, Roles: []string{"manager"}}, DecideInput{Decision: "approve"})
if err != nil || after.CurrentStep != 1 {
t.Fatalf("approve after defer: err=%v step=%d", err, after.CurrentStep)
}
}

func TestDeferApproval_NotFound(t *testing.T) {
repo, tenant, _, _ := submitTwoStep(t)
_, err := NewDecideApprovalUseCase(repo).Defer(context.Background(), tenant, uuid.New(),
ApproverIdentity{UserID: uuid.New(), IsAdmin: true}, "")
if !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("expected not found, got %v", err)
}
}

// Only someone who could sign may defer: not the requester (four-eyes), not an
// ineligible role, not another tenant.
func TestDeferApproval_Unauthorized(t *testing.T) {
repo, tenant, requester, req := submitTwoStep(t)
decide := NewDecideApprovalUseCase(repo)
if _, err := decide.Defer(context.Background(), tenant, req.ID,
ApproverIdentity{UserID: requester, Roles: []string{"manager"}}, ""); !errors.Is(err, domain.ErrForbidden) {
t.Fatalf("maker deferred own request: %v", err)
}
if _, err := decide.Defer(context.Background(), tenant, req.ID,
ApproverIdentity{UserID: uuid.New(), Roles: []string{"viewer"}}, ""); !errors.Is(err, domain.ErrForbidden) {
t.Fatalf("ineligible role deferred: %v", err)
}
if _, err := decide.Defer(context.Background(), uuid.New(), req.ID,
ApproverIdentity{UserID: uuid.New(), IsAdmin: true}, ""); !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("other tenant reached the request: %v", err)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -419,3 +419,61 @@ func (uc *GetApprovalDetailUseCase) Execute(ctx context.Context, tenantID, id uu
}
return detail, nil
}

// Defer records that the committee pushed a pending request to its next
// sitting (#903, owner decision 2026-10-08). The request stays pending and
// nothing in the signature circuit moves; only someone who could sign the open
// step may defer it, so a deferral is never a way round the four-eyes rule.
func (uc *DecideApprovalUseCase) Defer(ctx context.Context, tenantID, id uuid.UUID, who ApproverIdentity, comment string) (*domain.ApprovalRequest, error) {
req, err := uc.requests.GetRequestByID(ctx, id, tenantID)
if err != nil {
return nil, err
}
if req == nil {
return nil, domain.NewNotFoundError("approval request", id)
}
now := time.Now().UTC()
if domain.IsExpired(req, now) {
return nil, domain.NewValidationError("this request expired on " + req.ExpiresAt.Format(time.RFC3339) + " and can no longer be deferred")
}
if req.Status != domain.ApprovalPending {
return nil, domain.NewValidationError("request is already " + string(req.Status))
}
approver := uc.resolveApprover(ctx, tenantID, who, now)
step := domain.StepFor(req, nil)
if step == nil {
return nil, domain.NewValidationError("this request has no step awaiting a decision")
}
if verdict := domain.CanSign(req, step, approver); !verdict.Eligible {
return nil, domain.NewForbiddenError(verdict.Reason)
}

comment = strings.TrimSpace(comment)
req.Deferrals = append(req.Deferrals, domain.ApprovalDeferral{
DeferredBy: approver.UserID.String(),
DeferredByEmail: approver.Email,
Comment: comment,
DeferredAt: now,
})
if err := uc.requests.UpdateRequest(ctx, req); err != nil {
return nil, err
}

if uc.recorder != nil {
actor := approver.UserID
after := domain.JSONMap{"status": string(req.Status), "deferrals": len(req.Deferrals)}
if comment != "" {
after["comment"] = comment
}
uc.recorder.Record(ctx, domain.AuditEvent{
TenantID: tenantID,
ActorID: &actor,
Action: domain.AuditActionDefer,
EntityType: "approval_request",
EntityID: req.ID.String(),
Summary: "defer \"" + req.Title + "\" to the next committee",
After: after,
})
}
return req, nil
}
8 changes: 6 additions & 2 deletions backend/internal/application/risk/financial_summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@ type TopRiskFinancial struct {
Criticality string `json:"criticality"`
ALE crq.Money `json:"ale"`
ALEWorst crq.Money `json:"ale_worst"`
ROSI float64 `json:"rosi"`
ROSIOK bool `json:"rosi_computable"`
// ALEAfter is the exposure targeted once the mitigations are in place
// (#903: the executive view draws it beside the inherent ALE).
ALEAfter crq.Money `json:"ale_after"`
ROSI float64 `json:"rosi"`
ROSIOK bool `json:"rosi_computable"`
}

// FinancialSummary is the tenant-wide financial posture rendered by the CFO/CISO
Expand Down Expand Up @@ -175,6 +178,7 @@ func (uc *FinancialSummaryUseCase) Execute(ctx context.Context, tenantID uuid.UU
Criticality: string(r.Criticality),
ALE: a.ALE,
ALEWorst: a.ALEWorst,
ALEAfter: a.ALEAfter,
ROSI: a.ROSI,
ROSIOK: a.ROSIComputable,
})
Expand Down
64 changes: 61 additions & 3 deletions backend/internal/application/score/history.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,30 @@ type SnapshotStore interface {
ListSince(ctx context.Context, tenantID uuid.UUID, since time.Time) ([]domain.TenantScoreSnapshot, error)
}

// Figures are the other headline numbers recorded with the score (#903).
// A nil field means its source could not be read.
type Figures struct {
ALEXAF *float64
CriticalRisks *int
CompliancePct *float64
}

// FiguresSource reads today's figures for one tenant.
type FiguresSource interface {
Figures(ctx context.Context, tenantID uuid.UUID) Figures
}

// Baseline is the last snapshot before the current quarter began: what the
// executive view compares today's figures with ("depuis le T3").
type Baseline struct {
Quarter string `json:"quarter"` // e.g. "2026-Q3"
Day time.Time `json:"day"`
Value float64 `json:"value"`
ALEXAF *float64 `json:"ale_xaf"`
CriticalRisks *int `json:"critical_risks"`
CompliancePct *float64 `json:"compliance_pct"`
}

// 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 {
Expand All @@ -44,13 +68,23 @@ type History struct {
// 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"`
// QuarterBaseline is nil until a snapshot exists from before the current
// quarter: a delta needs a real reading at both ends.
QuarterBaseline *Baseline `json:"quarter_baseline"`
}

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

// WithFigures records the executive figures alongside the score (#903).
func (h *HistoryUseCase) WithFigures(f FiguresSource) *HistoryUseCase {
h.figures = f
return h
}

func NewHistory(score *UseCase, store SnapshotStore) *HistoryUseCase {
Expand All @@ -77,6 +111,10 @@ func (h *HistoryUseCase) Record(ctx context.Context, tenantID uuid.UUID) (*scori
Band: string(res.Band),
FormulaVersion: res.FormulaVersion,
}
if h.figures != nil {
f := h.figures.Figures(ctx, tenantID)
snap.ALEXAF, snap.CriticalRisks, snap.CompliancePct = f.ALEXAF, f.CriticalRisks, f.CompliancePct
}
if err := h.store.Upsert(ctx, snap); err != nil {
return res, err
}
Expand All @@ -98,12 +136,17 @@ func (h *HistoryUseCase) Execute(ctx context.Context, tenantID uuid.UUID, months

today := domain.SnapshotDay(h.now())
start := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, -(months - 1), 0)
quarterStart := time.Date(today.Year(), time.Month(((int(today.Month())-1)/3)*3+1), 1, 0, 0, 0, 0, time.UTC)
// 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
}
// The previous quarter's closing snapshot can be older than the window.
if d := quarterStart.AddDate(0, -3, 0); d.Before(from) {
from = d
}
rows, err := h.store.ListSince(ctx, tenantID, from)
if err != nil {
return nil, err
Expand Down Expand Up @@ -134,6 +177,7 @@ func (h *HistoryUseCase) Execute(ctx context.Context, tenantID uuid.UUID, months

var current *domain.TenantScoreSnapshot
var past *domain.TenantScoreSnapshot
var base *domain.TenantScoreSnapshot
cutoff := today.AddDate(0, 0, -30)
for i := range rows {
day := domain.SnapshotDay(rows[i].Day)
Expand All @@ -143,6 +187,9 @@ func (h *HistoryUseCase) Execute(ctx context.Context, tenantID uuid.UUID, months
if !day.After(cutoff) {
past = &rows[i]
}
if day.Before(quarterStart) {
base = &rows[i]
}
}
if current != nil {
v := current.Value
Expand All @@ -152,6 +199,17 @@ func (h *HistoryUseCase) Execute(ctx context.Context, tenantID uuid.UUID, months
out.Delta30d = &d
}
}
if base != nil {
bd := domain.SnapshotDay(base.Day)
out.QuarterBaseline = &Baseline{
Quarter: bd.Format("2006") + "-Q" + string(rune('0'+(int(bd.Month())-1)/3+1)),
Day: bd,
Value: base.Value,
ALEXAF: base.ALEXAF,
CriticalRisks: base.CriticalRisks,
CompliancePct: base.CompliancePct,
}
}
return out, nil
}

Expand Down
Loading
Loading