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
26 changes: 23 additions & 3 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1199,16 +1199,20 @@ func main() {
// Initialize clean architecture risk module
riskRepo := repository.NewGormRiskRepository(database.DB)
riskControlMappingRepo := repository.NewGormRiskControlMappingRepository(database.DB)
riskAssetStore := repository.NewGormRiskAssetStore(database.DB)
createRiskUseCase := risk.NewCreateRiskUseCase(riskRepo).
WithActivation(activationRecorder).
WithOwnership(ownershipService)
WithOwnership(ownershipService).
WithAssets(riskAssetStore)
getRiskUseCase := risk.NewGetRiskUseCase(riskRepo).
WithMappings(riskControlMappingRepo).
WithOwnership(ownershipService)
listRisksUseCase := risk.NewListRisksUseCase(riskRepo).
WithMappings(riskControlMappingRepo).
WithOwnership(ownershipService)
updateRiskUseCase := risk.NewUpdateRiskUseCase(riskRepo).WithOwnership(ownershipService)
updateRiskUseCase := risk.NewUpdateRiskUseCase(riskRepo).
WithOwnership(ownershipService).
WithAssets(riskAssetStore)
deleteRiskUseCase := risk.NewDeleteRiskUseCase(riskRepo)
// Cyber Risk Quantification: XAF→USD rate configurable via XAF_USD_RATE
// (default ≈ 600 FCFA/USD). Reference ALE bands match the board ExposureModel.
Expand Down Expand Up @@ -1247,7 +1251,20 @@ func main() {
// implementation accepted a performedBy and discarded it, so a supervisor
// asking "who reassigned these and when" had no answer. auditChainRepo is
// the same hash-chained, append-only store the rest of the trail uses.
WithBulkAction(risk.NewBulkActionUseCase(riskRepo, auditChainRepo))
WithBulkAction(risk.NewBulkActionUseCase(riskRepo, auditChainRepo)).
// #755 — CSV import: every row validated first, then all of them written
// in one transaction through CreateRiskUseCase, or none. The plan cap is
// checked against the whole file, not just the first row.
WithImport(risk.NewImportRisksUseCase(repository.RunRiskTx(database.DB)).
WithAssets(repository.ListImportAssetRefs(database.DB)).
WithActivation(activationRecorder).
WithCapacity(func(ctx context.Context, tenant uuid.UUID) (int, error) {
_, limit, used, _, err := entitlementService.Capacity(ctx, tenant, ent.LimitRisks)
if err != nil || limit == ent.Unlimited || used < 0 {
return -1, err
}
return max(limit-used, 0), nil
}))

// Financial Risk Quantification (spec §9): tenant-wide CFO/CISO dashboard
// (portfolio FAIR-lite P10/P50/P90, ALE, worst-case, residual, remediation
Expand Down Expand Up @@ -1318,6 +1335,9 @@ func main() {
protected.Post("/risks/bulk", riskUpdate, riskHandler.BulkAction)

protected.Post("/risks", riskCreate, capRisks, riskHandler.CreateRisk)
// #755 — CSV import. capRisks refuses a tenant already at its limit; the use
// case then refuses a file that would carry it past.
protected.Post("/risks/import", riskCreate, capRisks, riskHandler.ImportRisks)
protected.Patch("/risks/:id", riskUpdate, riskHandler.UpdateRisk)
protected.Post("/risks/:id/review", riskUpdate, riskHandler.MarkReviewed)
protected.Post("/risks/:id/transfer-owner", riskUpdate, ownershipTransferHandler.TransferRiskOwner)
Expand Down
4 changes: 1 addition & 3 deletions backend/internal/api/http/handlers/risks.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,8 @@
CreatedAt: r.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: r.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
if r.AssignedTo != nil {

Check failure on line 139 in backend/internal/api/http/handlers/risks.go

View workflow job for this annotation

GitHub Actions / Backend Lint & Format

SA1019: r.AssignedTo is deprecated: superseded by Ownership.AssigneeID. Kept (and backfilled FROM, migration 0044) so pre-existing filters and the RiskQuery.AssignedTo facet keep answering while callers migrate. (staticcheck)
s := r.AssignedTo.String()

Check failure on line 140 in backend/internal/api/http/handlers/risks.go

View workflow job for this annotation

GitHub Actions / Backend Lint & Format

SA1019: r.AssignedTo is deprecated: superseded by Ownership.AssigneeID. Kept (and backfilled FROM, migration 0044) so pre-existing filters and the RiskQuery.AssignedTo facet keep answering while callers migrate. (staticcheck)
resp.AssignedTo = &s
}
if r.ReviewerID != nil {
Expand Down Expand Up @@ -517,8 +517,6 @@
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "file is required"})
}

format := risk.ImportFormat(c.FormValue("format", "json"))

// Read file content
openFile, err := file.Open()
if err != nil {
Expand All @@ -533,7 +531,7 @@
}

// Execute import
result, err := h.importUC.Execute(c.Context(), tenantID, buffer, format, userID)
result, err := h.importUC.Execute(c.Context(), tenantID, risk.ImportRisksInput{CSV: buffer, ImportedBy: userID})
if err != nil {
h.logger.Error().Err(err).Msg("failed to import risks")
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to import risks"})
Expand Down
53 changes: 41 additions & 12 deletions backend/internal/application/risk/create_risk.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

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

// CreateRiskInput represents the input for creating a risk.
Expand All @@ -32,6 +33,11 @@ type CreateRiskInput struct {
// may be unclassified, and forcing a pick at creation only teaches people to
// choose the first entry.
CategoryID *uuid.UUID
// AssetIDs links the risk to the tenant's assets at creation. Their
// criticality is a term of the score, so they are linked before the score
// is computed and in the same write. Ids that are not the tenant's assets
// are dropped, as the handler always did.
AssetIDs []uuid.UUID
Source string // parsed into domain.RiskSource in Execute()
ExternalID string
CreatedBy uuid.UUID // the authenticated user creating the risk
Expand Down Expand Up @@ -62,11 +68,20 @@ type CreateRiskUseCase struct {
riskRepo domain.RiskRepository
activation ActivationRecorder
ownership OwnershipManager
assets RiskAssetStore
engine pkgscoring.Engine
}

// NewCreateRiskUseCase creates a new CreateRiskUseCase.
func NewCreateRiskUseCase(riskRepo domain.RiskRepository) *CreateRiskUseCase {
return &CreateRiskUseCase{riskRepo: riskRepo}
return &CreateRiskUseCase{riskRepo: riskRepo, engine: pkgscoring.NewEngine()}
}

// WithAssets attaches the asset store that resolves and links input.AssetIDs.
// Without it, AssetIDs is ignored and the risk is scored with no asset.
func (uc *CreateRiskUseCase) WithAssets(s RiskAssetStore) *CreateRiskUseCase {
uc.assets = s
return uc
}

// WithActivation attaches the optional activation recorder. Nil-safe.
Expand Down Expand Up @@ -156,20 +171,34 @@ func (uc *CreateRiskUseCase) Execute(ctx context.Context, orgID uuid.UUID, input
risk.AssignedTo = risk.AssigneeID
}

// 3. Compute score (Claude.md formula: P × I, score engine can override later)
risk.Score = risk.Impact * risk.Probability
// Band the score synchronously so the create response is self-consistent
// (score and criticality agree) instead of returning the default 'low' until
// the async ScoreWorker runs ~2s later. The worker refines it once asset
// criticality is folded in; both move together (audit-2026 #246).
risk.Criticality = domain.CriticalityFromScore(risk.Score)
// 3. Resolve the linked assets: their criticality is a term of the score.
var linked []*domain.Asset
if uc.assets != nil && len(input.AssetIDs) > 0 {
linked, err = uc.assets.FindByIDs(ctx, orgID, input.AssetIDs)
if err != nil {
return nil, domain.NewInternalError(fmt.Sprintf("failed to resolve assets: %v", err))
}
risk.Assets = linked
}

// 4. Score through the Score Engine, asset criticality included, so the
// stored score is the formula's from the first write — not P × I waiting
// for a Redis event that may never come (#792).
if err := applyScore(uc.engine, risk); err != nil {
return nil, err
}

// 4. Persist
if err := uc.riskRepo.Create(ctx, risk); err != nil {
// 5. Persist the risk and its asset links together.
if len(linked) > 0 {
err = uc.assets.SaveWithAssets(ctx, risk, linked, true)
} else {
err = uc.riskRepo.Create(ctx, risk)
}
if err != nil {
return nil, domain.NewInternalError(fmt.Sprintf("failed to create risk: %v", err))
}

// 5. Note the activation milestone. Every creation records an event; only the
// 6. Note the activation milestone. Every creation records an event; only the
// FIRST one ticks the checklist (the read model takes MIN(occurred_at)), so
// no counting or de-duplication is needed here.
if uc.activation != nil {
Expand All @@ -179,7 +208,7 @@ func (uc *CreateRiskUseCase) Execute(ctx context.Context, orgID uuid.UUID, input
})
}

// 6. Announce assignments made at creation (never to the creator themselves).
// 7. Announce assignments made at creation (never to the creator themselves).
if uc.ownership != nil && len(ownershipChanges) > 0 {
uc.ownership.Notify(ctx, orgID, ownershipChanges, domain.OwnershipSubject{
ResourceType: "risk",
Expand Down
16 changes: 4 additions & 12 deletions backend/internal/application/risk/get_score_breakdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,10 @@ func (uc *GetScoreBreakdownUseCase) Execute(ctx context.Context, tenantID uuid.U
return nil, domain.NewNotFoundError("risk", riskID)
}

// 2. Calculate asset criticality — average domain.AssetCriticality.ScoreFactor()
// across every linked asset (not just the first one), consistent with how
// GormRiskRepository.GetRisksByAssetID/RiskHandler now derive it. Defaults
// to MEDIUM's factor (1.5) if no asset is linked.
assetCriticality := domain.CriticalityMedium.ScoreFactor()
if len(risk.Assets) > 0 {
var sum float64
for _, a := range risk.Assets {
sum += a.Criticality.ScoreFactor()
}
assetCriticality = sum / float64(len(risk.Assets))
}
// 2. Asset criticality — the one derivation every score writer uses
// (domain.RiskAssetCriticality): average factor of the linked assets,
// neutral when none is linked (#792).
assetCriticality := domain.RiskAssetCriticality(domain.AssetCriticalities(risk.Assets))

// 3. Use Score Engine to compute breakdown
// IMPORTANT: All score calculations go through the Score Engine
Expand Down
7 changes: 4 additions & 3 deletions backend/internal/application/risk/get_score_breakdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ func TestGetScoreBreakdown_Success_NoLinkedAssets(t *testing.T) {
breakdown, err := uc.Execute(context.Background(), tenantID, riskID)

require.NoError(t, err)
// No linked assets → defaults to MEDIUM's factor (1.5): 0.5 * 8.0 * 1.5 = 6.0
assert.Equal(t, 6.0, breakdown.Score)
assert.Equal(t, 1.5, breakdown.AssetCriticality)
// No linked assets → the neutral factor every score writer stores (#792):
// 0.5 * 8.0 * 1.0 = 4.0
assert.Equal(t, 4.0, breakdown.Score)
assert.Equal(t, domain.NoAssetCriticalityFactor, breakdown.AssetCriticality)
}

func TestGetScoreBreakdown_AveragesAcrossAllLinkedAssets(t *testing.T) {
Expand Down
31 changes: 12 additions & 19 deletions backend/internal/application/risk/get_score_working.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,26 +76,19 @@ func (uc *GetScoreWorkingUseCase) Execute(ctx context.Context, tenantID, riskID
SourcesVisible: canReadAudit && uc.audit != nil,
}

// Asset criticality: the average factor over every linked asset, medium when
// none — the same derivation the Score Engine is fed (GetScoreBreakdown,
// GormRiskRepository.GetRisksByAssetID).
ac := domain.CriticalityMedium.ScoreFactor()
if len(r.Assets) > 0 {
var sum float64
for _, a := range r.Assets {
f := a.Criticality.ScoreFactor()
sum += f
w.Assets = append(w.Assets, domain.ScoreWorkingAsset{
ID: a.ID.String(),
Name: a.Name,
Criticality: string(a.Criticality),
Factor: f,
})
}
ac = sum / float64(len(r.Assets))
} else {
w.AssetCriticalityDefaulted = true
// Asset criticality: the same derivation every score writer uses
// (domain.RiskAssetCriticality) — the average factor over the linked
// assets, neutral when none is linked (#792).
for _, a := range r.Assets {
w.Assets = append(w.Assets, domain.ScoreWorkingAsset{
ID: a.ID.String(),
Name: a.Name,
Criticality: string(a.Criticality),
Factor: a.Criticality.ScoreFactor(),
})
}
w.AssetCriticalityDefaulted = len(r.Assets) == 0
ac := domain.RiskAssetCriticality(domain.AssetCriticalities(r.Assets))

b, err := uc.engine.Breakdown(r.Probability, r.Impact, ac, nil)
if err != nil {
Expand Down
4 changes: 2 additions & 2 deletions backend/internal/application/risk/get_score_working_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,14 @@ func TestGetScoreWorking_InconsistentStoredScoreIsSaidSo(t *testing.T) {
assert.InDelta(t, 6.0, w.Computed, 1e-9)
}

func TestGetScoreWorking_NoAssetUsesTheDocumentedDefault(t *testing.T) {
func TestGetScoreWorking_NoAssetUsesTheNeutralFactor(t *testing.T) {
tenant, r, trail, _ := scoreWorkingFixture(t)
r.Assets = nil
w, err := NewGetScoreWorkingUseCase(repoReturning(r), trail, pkgscoring.NewEngine()).
Execute(context.Background(), tenant, r.ID, true)
require.NoError(t, err)
assert.True(t, w.AssetCriticalityDefaulted)
assert.InDelta(t, domain.CriticalityMedium.ScoreFactor(), w.Terms[2].Value, 1e-9)
assert.InDelta(t, domain.NoAssetCriticalityFactor, w.Terms[2].Value, 1e-9)
assert.Nil(t, w.Terms[2].Source)
}

Expand Down
Loading
Loading