From edf0394616144b61bfe0310d8a4a5a301034c51c Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Sat, 30 May 2026 09:29:36 +0200 Subject: [PATCH] [FIX] FAIR/KRI: aggiunti i metodi mancanti in RiskController (commit 1be3bd0 era incompleto) Il commit 1be3bd0 conteneva migrazione 026 + FairService + route ma NON i metodi computeFair/fairRegister/listKri/createKri/updateKri nel controller (Edit fallito per ancora errata). Ora presenti e testati E2E in prod: - FAIR compute ALE Monte Carlo (risk 432: ALE mean 174.806 EUR, deterministico) - fairRegister portfolio ALE, KRI create/update/dashboard con semaforo amber->red Co-Authored-By: Claude Opus 4.8 (1M context) --- application/controllers/RiskController.php | 196 +++++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/application/controllers/RiskController.php b/application/controllers/RiskController.php index d793450..e0c162e 100644 --- a/application/controllers/RiskController.php +++ b/application/controllers/RiskController.php @@ -8,6 +8,7 @@ require_once __DIR__ . '/BaseController.php'; require_once APP_PATH . '/services/AIService.php'; require_once APP_PATH . '/services/WebhookService.php'; +require_once APP_PATH . '/services/FairService.php'; class RiskController extends BaseController { @@ -317,4 +318,199 @@ class RiskController extends BaseController $this->jsonError('Errore AI: ' . $e->getMessage(), 500, 'AI_ERROR'); } } + + // ══════════════════════════════════════════════════════════════════════ + // RISK QUANTITATIVO FAIR (P2) — analisi economica del rischio + // ══════════════════════════════════════════════════════════════════════ + + /** + * POST /api/risks/{id}/fair + * Calcola l'ALE (Annualized Loss Expectancy) via Monte Carlo FAIR e + * persiste parametri + risultati sul rischio. + * Body: { tef_min, tef_ml, tef_max, vuln (0-1), lm_min, lm_ml, lm_max, iterations? } + */ + public function computeFair(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']); + $risk = Database::fetchOne( + 'SELECT id FROM risks WHERE id = ? AND organization_id = ? AND deleted_at IS NULL', + [$id, $this->getCurrentOrgId()] + ); + if (!$risk) { + $this->jsonError('Rischio non trovato', 404, 'NOT_FOUND'); + } + + $params = [ + 'tef_min' => (float) $this->getParam('tef_min', 0), + 'tef_ml' => (float) $this->getParam('tef_ml', 0), + 'tef_max' => (float) $this->getParam('tef_max', 0), + 'vuln' => (float) $this->getParam('vuln', 1), + 'lm_min' => (float) $this->getParam('lm_min', 0), + 'lm_ml' => (float) $this->getParam('lm_ml', 0), + 'lm_max' => (float) $this->getParam('lm_max', 0), + ]; + $iterations = (int) $this->getParam('iterations', FairService::DEFAULT_ITERATIONS); + + $sim = FairService::simulate($params, $iterations); + + Database::query( + 'UPDATE risks SET + fair_tef_min=?, fair_tef_ml=?, fair_tef_max=?, fair_vuln=?, + fair_lm_min=?, fair_lm_ml=?, fair_lm_max=?, + ale_min=?, ale_ml=?, ale_max=?, ale_mean=?, fair_computed_at=NOW() + WHERE id=? AND organization_id=?', + [ + $params['tef_min'], $params['tef_ml'], $params['tef_max'], $params['vuln'], + $params['lm_min'], $params['lm_ml'], $params['lm_max'], + $sim['ale_min'], $sim['ale_ml'], $sim['ale_max'], $sim['ale_mean'], + $id, $this->getCurrentOrgId(), + ] + ); + + $this->logAudit('risk_fair_computed', 'risk', $id, ['ale_mean' => $sim['ale_mean']]); + $this->jsonSuccess(['risk_id' => $id, 'parameters' => $params, 'result' => $sim], 'Analisi FAIR calcolata'); + } + + /** + * GET /api/risks/fairRegister + * Registro quantitativo: rischi con ALE calcolato, ordinati per esposizione + * economica, + ALE totale di portafoglio. + */ + public function fairRegister(): void + { + $this->requireOrgAccess(); + $rows = Database::fetchAll( + 'SELECT id, risk_code, title, category, status, inherent_risk_score, + ale_min, ale_ml, ale_max, ale_mean, fair_computed_at + FROM risks + WHERE organization_id = ? AND deleted_at IS NULL AND ale_mean IS NOT NULL + ORDER BY ale_mean DESC', + [$this->getCurrentOrgId()] + ); + $totalAle = 0.0; + foreach ($rows as $r) { $totalAle += (float) $r['ale_mean']; } + + $this->jsonSuccess([ + 'total_risks_quantified' => count($rows), + 'portfolio_ale_mean' => round($totalAle, 2), + 'currency' => 'EUR', + 'risks' => $rows, + ]); + } + + // ══════════════════════════════════════════════════════════════════════ + // KEY RISK INDICATORS (KRI) — dashboard + // ══════════════════════════════════════════════════════════════════════ + + /** GET /api/risks/kri — lista KRI con stato semaforo ricalcolato. */ + public function listKri(): void + { + $this->requireOrgAccess(); + $rows = Database::fetchAll( + 'SELECT * FROM kri WHERE organization_id = ? ORDER BY FIELD(status,"red","amber","green","unknown"), name', + [$this->getCurrentOrgId()] + ); + foreach ($rows as &$r) { + $r['status'] = self::evalKriStatus($r); + } + unset($r); + + $summary = ['green' => 0, 'amber' => 0, 'red' => 0, 'unknown' => 0]; + foreach ($rows as $r) { $summary[$r['status']]++; } + + $this->jsonSuccess(['summary' => $summary, 'total' => count($rows), 'kris' => $rows]); + } + + /** POST /api/risks/kri — crea un KRI. */ + public function createKri(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']); + $this->validateRequired(['name']); + + $dir = $this->getParam('direction', 'higher_worse'); + if (!in_array($dir, ['higher_worse', 'lower_worse'], true)) $dir = 'higher_worse'; + $cat = $this->getParam('category', 'cyber'); + if (!in_array($cat, ['cyber','operational','compliance','supply_chain','physical','human'], true)) $cat = 'cyber'; + + $data = [ + 'organization_id' => $this->getCurrentOrgId(), + 'name' => trim($this->getParam('name')), + 'description' => $this->getParam('description'), + 'category' => $cat, + 'unit' => $this->getParam('unit'), + 'current_value' => $this->numOrNull($this->getParam('current_value')), + 'target_value' => $this->numOrNull($this->getParam('target_value')), + 'threshold_warning' => $this->numOrNull($this->getParam('threshold_warning')), + 'threshold_critical' => $this->numOrNull($this->getParam('threshold_critical')), + 'direction' => $dir, + 'linked_risk_id' => $this->numOrNull($this->getParam('linked_risk_id')), + 'measured_at' => $this->getParam('current_value') !== null ? date('Y-m-d H:i:s') : null, + ]; + $data['status'] = self::evalKriStatus($data); + + $kriId = Database::insert('kri', $data); + $this->logAudit('kri_created', 'kri', $kriId); + $this->jsonSuccess(['id' => $kriId, 'status' => $data['status']], 'KRI creato', 201); + } + + /** PUT /api/risks/kri/{id} — aggiorna valore/soglie KRI (ricalcola stato). */ + public function updateKri(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']); + $kri = Database::fetchOne('SELECT * FROM kri WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); + if (!$kri) { + $this->jsonError('KRI non trovato', 404, 'NOT_FOUND'); + } + + $fields = ['name', 'description', 'category', 'unit', 'current_value', 'target_value', 'threshold_warning', 'threshold_critical', 'direction', 'linked_risk_id']; + $merged = $kri; + foreach ($fields as $f) { + $v = $this->getParam($f); + if ($v !== null) { + $merged[$f] = in_array($f, ['current_value','target_value','threshold_warning','threshold_critical','linked_risk_id'], true) + ? $this->numOrNull($v) : $v; + } + } + $merged['status'] = self::evalKriStatus($merged); + $measuredAt = $this->getParam('current_value') !== null ? date('Y-m-d H:i:s') : $kri['measured_at']; + + Database::query( + 'UPDATE kri SET name=?, description=?, category=?, unit=?, current_value=?, target_value=?, + threshold_warning=?, threshold_critical=?, direction=?, linked_risk_id=?, status=?, measured_at=? + WHERE id=? AND organization_id=?', + [ + $merged['name'], $merged['description'], $merged['category'], $merged['unit'], + $merged['current_value'], $merged['target_value'], $merged['threshold_warning'], + $merged['threshold_critical'], $merged['direction'], $merged['linked_risk_id'], + $merged['status'], $measuredAt, $id, $this->getCurrentOrgId(), + ] + ); + $this->logAudit('kri_updated', 'kri', $id); + $this->jsonSuccess(['id' => $id, 'status' => $merged['status']], 'KRI aggiornato'); + } + + /** Stato semaforo KRI in base a valore corrente, soglie e direzione. */ + private static function evalKriStatus(array $k): string + { + $cur = $k['current_value'] ?? null; + if ($cur === null || $cur === '') return 'unknown'; + $cur = (float) $cur; + $warn = isset($k['threshold_warning']) && $k['threshold_warning'] !== null ? (float) $k['threshold_warning'] : null; + $crit = isset($k['threshold_critical']) && $k['threshold_critical'] !== null ? (float) $k['threshold_critical'] : null; + $dir = $k['direction'] ?? 'higher_worse'; + + if ($dir === 'higher_worse') { + if ($crit !== null && $cur >= $crit) return 'red'; + if ($warn !== null && $cur >= $warn) return 'amber'; + return 'green'; + } + if ($crit !== null && $cur <= $crit) return 'red'; + if ($warn !== null && $cur <= $warn) return 'amber'; + return 'green'; + } + + private function numOrNull($v) + { + return ($v === null || $v === '') ? null : (float) $v; + } }