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
9 changes: 7 additions & 2 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1287,9 +1287,11 @@ func main() {
// factory converts every figure into the tenant's currency at a dated rate.
financialSummaryUseCase := risk.NewFinancialSummaryUseCase(riskRepo, riskQuantifier).
WithPresenters(financialPresenters).
WithCoverageCounter(riskRepo)
WithCoverageCounter(riskRepo).
WithAppetiteReader(orgRepo)
financialAnalyticsHandler := handlers.NewFinancialAnalyticsHandler(financialSummaryUseCase).
WithCurrencyWriter(orgRepo)
WithCurrencyWriter(orgRepo).
WithAppetiteUseCase(risk.NewSetRiskAppetiteUseCase(orgRepo))

// NOTE: same bug class as compliance (see comment above complianceFrameworkRead) —
// middleware.RequirePermissions reads the legacy *domain.UserClaims, which the RS256
Expand Down Expand Up @@ -2413,6 +2415,9 @@ func main() {
// Tenant display currency — chosen at onboarding, changeable here (admin).
protected.Put("/analytics/financial/currency",
middleware.RequireRole("admin", "root"), financialAnalyticsHandler.SetCurrency)
// Risk appetite (#904) — the board's accepted annual loss, a tenant policy (admin).
protected.Put("/analytics/financial/appetite",
middleware.RequireRole("admin", "root"), featFinancial, financialAnalyticsHandler.SetAppetite)

// Executive dashboard (spec §11) — ONE consolidated, tenant-scoped aggregation
// (cyber score, financial exposure, KRIs, top-10 risks, risk & incident trends,
Expand Down
86 changes: 85 additions & 1 deletion backend/internal/application/risk/financial_summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package risk

import (
"context"
"math"
"sort"
"strings"
"time"
Expand All @@ -31,6 +32,32 @@ type FinancialCoverageCounter interface {
CountFinancialCoverage(ctx context.Context, tenantID uuid.UUID) (total, quantified int, err error)
}

// RiskAppetiteReader returns the tenant's risk appetite in XAF, or nil when the
// tenant has not set one (#904). Optional/nil-safe.
type RiskAppetiteReader interface {
OrgRiskAppetite(ctx context.Context, tenantID uuid.UUID) (*float64, error)
}

// LossExceedance is the loss-exceedance curve of the whole register in compact
// form: total annual loss (XAF) at every percentile 0..100, before and after the
// treatment plans (#904). P(loss > x) is the share of percentiles above x.
type LossExceedance struct {
Inherent []float64 `json:"inherent"`
Residual []float64 `json:"residual"`
}

// TreatmentPayback is one risk's treatment plan seen as an investment: what it
// costs, how much annual loss it removes, and how many months of avoided loss
// pay for it (#904). Money lives on the risk's plan (remediation cost and
// expected effectiveness), not on individual mitigations.
type TreatmentPayback struct {
RiskID uuid.UUID `json:"risk_id"`
Title string `json:"title"`
Cost crq.Money `json:"cost"`
Reduction crq.Money `json:"reduction"` // annual loss avoided
PaybackMonths float64 `json:"payback_months"`
}

// CriticalityBucket is the aggregated annual loss for one criticality band.
type CriticalityBucket struct {
Criticality string `json:"criticality"`
Expand Down Expand Up @@ -77,6 +104,15 @@ type FinancialSummary struct {
PortfolioROSIOK bool `json:"portfolio_rosi_computable"`
ByCriticality []CriticalityBucket `json:"by_criticality"`
TopRisks []TopRiskFinancial `json:"top_risks"`

// #904 — the financial page of the October 2026 redesign.
// PortfolioROSI3Y is (3 × annual reduction − plan cost) / plan cost.
PortfolioROSI3Y float64 `json:"portfolio_rosi_3y"`
PortfolioROSI3YOK bool `json:"portfolio_rosi_3y_computable"`
TreatedRisks int `json:"treated_risks"` // risks whose plan has a cost
LossExceedance *LossExceedance `json:"loss_exceedance"`
Treatments []TreatmentPayback `json:"treatments"` // shortest payback first
RiskAppetiteXAF *float64 `json:"risk_appetite_xaf"`
}

// FinancialSummaryUseCase aggregates the CRQ model across a tenant's register.
Expand All @@ -85,6 +121,7 @@ type FinancialSummaryUseCase struct {
quantifier *crq.Quantifier
presenters *FinancialPresenterFactory // optional; nil → XAF/static
coverage FinancialCoverageCounter // optional; nil → derived from the list
appetite RiskAppetiteReader // optional; nil → no appetite
now func() time.Time
}

Expand All @@ -105,6 +142,12 @@ func (uc *FinancialSummaryUseCase) WithCoverageCounter(c FinancialCoverageCounte
return uc
}

// WithAppetiteReader attaches the tenant risk-appetite reader.
func (uc *FinancialSummaryUseCase) WithAppetiteReader(r RiskAppetiteReader) *FinancialSummaryUseCase {
uc.appetite = r
return uc
}

// WithClock overrides the clock (tests).
func (uc *FinancialSummaryUseCase) WithClock(now func() time.Time) *FinancialSummaryUseCase {
uc.now = now
Expand Down Expand Up @@ -147,14 +190,31 @@ func (uc *FinancialSummaryUseCase) Execute(ctx context.Context, tenantID uuid.UU

tops := make([]TopRiskFinancial, 0, len(risks))
sims := make([]crq.SimulationInput, 0, len(risks))
residualSims := make([]crq.SimulationInput, 0, len(risks))
treatments := make([]TreatmentPayback, 0, len(risks))

for i := range risks {
r := &risks[i]
in := financialInputs(r)
// Deterministic per-risk figures (no per-risk Monte Carlo); the band comes
// from ONE shared portfolio simulation below.
a := q.AssessDeterministic(in, string(r.Criticality))
sims = append(sims, q.SimulationInputFor(in, string(r.Criticality)))
sim := q.SimulationInputFor(in, string(r.Criticality))
sims = append(sims, sim)
residualSims = append(residualSims, crq.ScaleLoss(sim, 1-a.Effectiveness))

if a.RemediationCost.XAF > 0 {
sum.TreatedRisks++
if a.RiskReduction.XAF > 0 {
treatments = append(treatments, TreatmentPayback{
RiskID: r.ID,
Title: riskTitle(r),
Cost: a.RemediationCost,
Reduction: a.RiskReduction,
PaybackMonths: math.Round(a.RemediationCost.XAF/a.RiskReduction.XAF*12*10) / 10,
})
}
}

totalALE += a.ALE.XAF
totalWorst += a.ALEWorst.XAF
Expand Down Expand Up @@ -204,6 +264,30 @@ func (uc *FinancialSummaryUseCase) Execute(ctx context.Context, tenantID uuid.UU

// Portfolio ROSI over all modeled controls.
sum.PortfolioROSI, sum.PortfolioROSIOK = crq.ROSI(totalALE, totalAfter, totalRemediation)
if totalRemediation > 0 {
sum.PortfolioROSI3Y = math.Round((3*totalReduction-totalRemediation)/totalRemediation*100) / 100
sum.PortfolioROSI3YOK = true
}

// Loss-exceedance curve, before and after the plans, from the same draws.
if len(sims) > 0 {
sum.LossExceedance = &LossExceedance{
Inherent: crq.PortfolioQuantiles(sims, crq.DefaultIterations, crq.DefaultSeed),
Residual: crq.PortfolioQuantiles(residualSims, crq.DefaultIterations, crq.DefaultSeed),
}
}

sort.SliceStable(treatments, func(i, j int) bool {
return treatments[i].PaybackMonths < treatments[j].PaybackMonths
})
sum.Treatments = treatments

if uc.appetite != nil {
// A failing read leaves the appetite unset rather than failing the page.
if v, aErr := uc.appetite.OrgRiskAppetite(ctx, tenantID); aErr == nil {
sum.RiskAppetiteXAF = v
}
}

// Emit criticality buckets in a stable order with USD derived.
for _, b := range bandOrder {
Expand Down
109 changes: 109 additions & 0 deletions backend/internal/application/risk/financial_summary_exceedance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// 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 risk

import (
"context"
"errors"
"testing"

"github.com/google/uuid"
"github.com/opendefender/openrisk/internal/domain"
"github.com/opendefender/openrisk/pkg/crq"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// The financial page of the redesign (#904): exceedance curve before and after
// the plans, treatment payback, three-year ROSI and the tenant's appetite.

type mockAppetite struct {
v *float64
err error
}

func (m mockAppetite) OrgRiskAppetite(context.Context, uuid.UUID) (*float64, error) {
return m.v, m.err
}

func pageRisks() []domain.Risk {
return []domain.Risk{
{ // ALE 10M, plan 2M at 80 % → reduction 8M/yr, payback 3 months
ID: uuid.New(), Title: "Ransomware", Criticality: domain.RiskCriticalityCritical,
SLEXAF: fp(20_000_000), ARO: fp(0.5),
RemediationCostXAF: fp(2_000_000), MitigationEffectiveness: fp(0.8),
},
{ // ALE 6M, plan 6M at 50 % → reduction 3M/yr, payback 24 months
ID: uuid.New(), Title: "Fraud", Criticality: domain.RiskCriticalityHigh,
SLEXAF: fp(6_000_000), ARO: fp(1),
RemediationCostXAF: fp(6_000_000), MitigationEffectiveness: fp(0.5),
},
{ // a plan with a cost but no effect: treated, never in the payback list
ID: uuid.New(), Title: "Unproven", Criticality: domain.RiskCriticalityMedium,
SLEXAF: fp(1_000_000), ARO: fp(1), RemediationCostXAF: fp(500_000),
},
{ // no plan
ID: uuid.New(), Title: "Untreated", Criticality: domain.RiskCriticalityLow,
SLEXAF: fp(2_000_000), ARO: fp(1),
},
}
}

func newPageUC(risks []domain.Risk) *FinancialSummaryUseCase {
return NewFinancialSummaryUseCase(&mockFinancialLister{risks: risks}, crq.NewQuantifier(600, crq.DefaultReference()))
}

func TestFinancialSummaryPage_Success(t *testing.T) {
appetite := 12_000_000.0
sum, err := newPageUC(pageRisks()).WithAppetiteReader(mockAppetite{v: &appetite}).
Execute(context.Background(), uuid.New())
require.NoError(t, err)

assert.Equal(t, 3, sum.TreatedRisks)
require.Len(t, sum.Treatments, 2, "a plan without effect has no payback")
assert.Equal(t, "Ransomware", sum.Treatments[0].Title)
assert.Equal(t, 3.0, sum.Treatments[0].PaybackMonths)
assert.Equal(t, 8_000_000.0, sum.Treatments[0].Reduction.XAF)
assert.Equal(t, "Fraud", sum.Treatments[1].Title)
assert.Equal(t, 24.0, sum.Treatments[1].PaybackMonths)

// Plan cost 8.5M, annual reduction 11M → (33 − 8.5) / 8.5.
assert.True(t, sum.PortfolioROSI3YOK)
assert.InDelta(t, 2.88, sum.PortfolioROSI3Y, 0.005)

require.NotNil(t, sum.LossExceedance)
inh, res := sum.LossExceedance.Inherent, sum.LossExceedance.Residual
require.Len(t, inh, 101)
require.Len(t, res, 101)
for p := range inh {
assert.LessOrEqual(t, res[p], inh[p], "after the plans a year never costs more (p%d)", p)
}
// The median of the curve is the headline band's P50.
assert.Equal(t, sum.PortfolioLoss.P50.XAF, inh[50])

require.NotNil(t, sum.RiskAppetiteXAF)
assert.Equal(t, appetite, *sum.RiskAppetiteXAF)
}

// An empty register has no curve, no payback and no ROSI, and says so with
// nulls rather than zeros that read like measurements.
func TestFinancialSummaryPage_NotFound(t *testing.T) {
sum, err := newPageUC(nil).Execute(context.Background(), uuid.New())
require.NoError(t, err)
assert.Nil(t, sum.LossExceedance)
assert.Empty(t, sum.Treatments)
assert.False(t, sum.PortfolioROSI3YOK)
assert.Nil(t, sum.RiskAppetiteXAF)
}

// A failing appetite read leaves the appetite unset; the page still loads.
func TestFinancialSummaryPage_AppetiteReadFails(t *testing.T) {
sum, err := newPageUC(pageRisks()).WithAppetiteReader(mockAppetite{err: errors.New("db down")}).
Execute(context.Background(), uuid.New())
require.NoError(t, err)
assert.Nil(t, sum.RiskAppetiteXAF)
assert.NotNil(t, sum.LossExceedance)
}
51 changes: 51 additions & 0 deletions backend/internal/application/risk/set_risk_appetite.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// 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 risk

import (
"context"
"math"

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

// RiskAppetiteWriter stores the tenant's risk appetite. It returns a
// domain NotFound error when the organization does not exist.
type RiskAppetiteWriter interface {
SetOrganizationRiskAppetite(ctx context.Context, tenantID uuid.UUID, appetiteXAF float64) error
}

// maxRiskAppetiteXAF bounds the input to something a balance sheet can hold
// (10^15 XAF); beyond it the number is a typo, not a policy.
const maxRiskAppetiteXAF = 1e15

// SetRiskAppetiteUseCase records the annual loss the board accepts, the
// threshold drawn on the loss-exceedance curve (#904). The amount is in XAF,
// the canonical store; the page converts from the display currency.
type SetRiskAppetiteUseCase struct {
writer RiskAppetiteWriter
}

// NewSetRiskAppetiteUseCase builds the use case.
func NewSetRiskAppetiteUseCase(w RiskAppetiteWriter) *SetRiskAppetiteUseCase {
return &SetRiskAppetiteUseCase{writer: w}
}

// Execute validates and stores the appetite for the caller's tenant.
func (uc *SetRiskAppetiteUseCase) Execute(ctx context.Context, tenantID uuid.UUID, appetiteXAF float64) (float64, error) {
if tenantID == uuid.Nil {
return 0, domain.NewForbiddenError("no organization in the session")
}
if math.IsNaN(appetiteXAF) || math.IsInf(appetiteXAF, 0) || appetiteXAF <= 0 || appetiteXAF > maxRiskAppetiteXAF {
return 0, domain.NewValidationError("risk appetite must be a positive amount")
}
v := math.Round(appetiteXAF)
if err := uc.writer.SetOrganizationRiskAppetite(ctx, tenantID, v); err != nil {
return 0, err
}
return v, nil
}
64 changes: 64 additions & 0 deletions backend/internal/application/risk/set_risk_appetite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// 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 risk

import (
"context"
"errors"
"math"
"testing"

"github.com/google/uuid"
"github.com/opendefender/openrisk/internal/domain"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type fakeAppetiteWriter struct {
tenants map[uuid.UUID]float64
calls int
}

func (f *fakeAppetiteWriter) SetOrganizationRiskAppetite(_ context.Context, tenantID uuid.UUID, v float64) error {
f.calls++
if _, ok := f.tenants[tenantID]; !ok {
return domain.NewNotFoundError("organization", tenantID)
}
f.tenants[tenantID] = v
return nil
}

func TestSetRiskAppetite_Success(t *testing.T) {
tenant := uuid.New()
w := &fakeAppetiteWriter{tenants: map[uuid.UUID]float64{tenant: 0}}
got, err := NewSetRiskAppetiteUseCase(w).Execute(context.Background(), tenant, 80_000_000.4)
require.NoError(t, err)
assert.Equal(t, 80_000_000.0, got)
assert.Equal(t, 80_000_000.0, w.tenants[tenant])
}

func TestSetRiskAppetite_NotFound(t *testing.T) {
w := &fakeAppetiteWriter{tenants: map[uuid.UUID]float64{}}
_, err := NewSetRiskAppetiteUseCase(w).Execute(context.Background(), uuid.New(), 1_000_000)
assert.True(t, errors.Is(err, domain.ErrNotFound))
}

func TestSetRiskAppetite_Unauthorized(t *testing.T) {
w := &fakeAppetiteWriter{tenants: map[uuid.UUID]float64{}}
_, err := NewSetRiskAppetiteUseCase(w).Execute(context.Background(), uuid.Nil, 1_000_000)
assert.True(t, errors.Is(err, domain.ErrForbidden))
assert.Zero(t, w.calls, "nothing is written without a tenant")
}

func TestSetRiskAppetite_Validation(t *testing.T) {
tenant := uuid.New()
w := &fakeAppetiteWriter{tenants: map[uuid.UUID]float64{tenant: 0}}
for _, v := range []float64{0, -5, math.NaN(), math.Inf(1), 2e15} {
_, err := NewSetRiskAppetiteUseCase(w).Execute(context.Background(), tenant, v)
assert.True(t, errors.Is(err, domain.ErrValidation), "value %v", v)
}
assert.Zero(t, w.calls)
}
Loading
Loading