Ogni rischio derivato da un requisito puo' ora essere valutato con Probabilita' e Impatto (1-5) e compare nella matrice 5x5. - DB: mig 066 (org_requisito_state += likelihood/impact) + runner idempotente. - Backend: FrameworkController::setState accetta likelihood/impact; RiskController ::derivedList ritorna likelihood/impact/score. Applicata su prod + reload. - Frontend risks.html: colonne Probabilita'/Impatto/Rischio(P×I) + tasto "Valuta" (modale, riservato a super_admin/org_admin/compliance_manager); matrice calcolata client-side dai rischi derivati valutati (esclusi i non_applicabile, caveat nis2-expert) + rischi custom. Framing normativo invariato. - help.js/i18n aggiornati; version 1.25.7; buster help/i18n ?v=20260727b. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
242 lines
11 KiB
PHP
242 lines
11 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile - Framework canonico Misure/Requisiti (Epic C / C1)
|
|
* ----------------------------------------------------------------------------
|
|
* Espone in SOLA LETTURA l'elenco canonico delle misure di sicurezza di base e
|
|
* dei relativi requisiti (Determinazione ACN n. 164179 del 14 aprile 2025), con
|
|
* la procedura e il rischio di default associati a ciascun requisito.
|
|
*
|
|
* Fonte dati: tabelle di CONFIGURAZIONE cfg_nis2_* (system-level), popolate dai
|
|
* file autoritativi del referente compliance (docs/simon/) — codifica NON
|
|
* variabile. La codifica e l'ordine NON sono modificabili dall'utente.
|
|
*
|
|
* STATO/VALUTAZIONE per-org (C1 operativo): per ogni requisito l'organizzazione
|
|
* conserva la "valutazione del rischio" (che parte vuota) e lo stato di
|
|
* conformita' in org_requisito_state (mig.047). È da qui che emerge lo stato di
|
|
* conformita' dell'azienda (sostituisce il modulo Gap ACN, C3). La codifica del
|
|
* framework resta intatta: si modifica SOLO il dato di valutazione dell'org.
|
|
*
|
|
* NOTE strutturali: DB API Database::query/fetchAll/fetchOne (NON Database::execute).
|
|
*/
|
|
|
|
require_once __DIR__ . '/BaseController.php';
|
|
|
|
class FrameworkController extends BaseController
|
|
{
|
|
private const MANAGE_ROLES = ['org_admin', 'compliance_manager'];
|
|
private const STATI = ['da_valutare', 'non_applicabile', 'non_conforme', 'parziale', 'conforme'];
|
|
|
|
/**
|
|
* GET /api/framework/catalog
|
|
* Catalogo canonico (ambiti, misure con requisiti annidati, procedura+rischio
|
|
* di default, flag importanti/essenziali) + lo STATO per-org di ogni requisito
|
|
* (se e' presente un'org corrente) + la classe del soggetto.
|
|
* Ordine 'ord'/'n' = ordine di default immutabile.
|
|
*/
|
|
public function catalog(): void
|
|
{
|
|
$this->requireAuth();
|
|
|
|
// Risoluzione org OPZIONALE (il catalogo è visibile anche senza org), con
|
|
// verifica d'accesso per non esporre lo stato di un'altra org (anti-IDOR).
|
|
$orgId = null;
|
|
$orgClass = null;
|
|
if (!empty($this->isDemo)) {
|
|
$orgId = $this->demoOrgId ?? null;
|
|
} else {
|
|
$candidate = $this->resolveOrgId();
|
|
if ($candidate) {
|
|
if (($this->currentUser['role'] ?? '') === 'super_admin') {
|
|
$orgId = $candidate;
|
|
} else {
|
|
$member = Database::fetchOne(
|
|
'SELECT 1 FROM user_organizations WHERE user_id = ? AND organization_id = ?',
|
|
[$this->getCurrentUserId(), $candidate]
|
|
);
|
|
if ($member) { $orgId = $candidate; }
|
|
}
|
|
}
|
|
}
|
|
if ($orgId) {
|
|
$row = Database::fetchOne('SELECT entity_type FROM organizations WHERE id = ?', [$orgId]);
|
|
$orgClass = $row['entity_type'] ?? null; // essential | important | not_applicable
|
|
}
|
|
|
|
// Stato per-org dei requisiti (mappa requisito_id => stato)
|
|
$stateMap = [];
|
|
if ($orgId) {
|
|
foreach (Database::fetchAll(
|
|
'SELECT requisito_id, stato, valutazione_rischio, note, updated_at
|
|
FROM org_requisito_state WHERE organization_id = ?',
|
|
[$orgId]
|
|
) as $s) {
|
|
$stateMap[(int) $s['requisito_id']] = [
|
|
'stato' => $s['stato'],
|
|
'valutazione_rischio' => $s['valutazione_rischio'],
|
|
'note' => $s['note'],
|
|
'updated_at' => $s['updated_at'],
|
|
];
|
|
}
|
|
}
|
|
|
|
// Ticket #384 p.2.2/p.3 — policy d'organizzazione collegate a ciascun requisito
|
|
// (M:N org-scoped, tabella requisito_policy mig.059). try/catch: l'eventuale assenza
|
|
// della tabella NON deve rompere il catalogo (degrada al solo proc_code di default).
|
|
$policiesByReq = [];
|
|
if ($orgId) {
|
|
try {
|
|
foreach (Database::fetchAll(
|
|
'SELECT rp.requisito_id, p.id AS policy_id, p.title, p.proc_code, p.status
|
|
FROM requisito_policy rp
|
|
JOIN policies p ON p.id = rp.policy_id
|
|
WHERE rp.organization_id = ?
|
|
ORDER BY (p.proc_code IS NULL), p.proc_code, p.title',
|
|
[$orgId]
|
|
) as $rp) {
|
|
$policiesByReq[(int) $rp['requisito_id']][] = [
|
|
'id' => (int) $rp['policy_id'],
|
|
'proc_code' => $rp['proc_code'],
|
|
'title' => $rp['title'],
|
|
'status' => $rp['status'],
|
|
];
|
|
}
|
|
} catch (\Throwable $e) {
|
|
error_log('[FrameworkController] requisito_policy lookup (org ' . $orgId . '): ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
$ambiti = Database::fetchAll('SELECT nist_code, nist_descr FROM cfg_nis2_ambiti ORDER BY nist_code ASC');
|
|
|
|
$misure = Database::fetchAll(
|
|
'SELECT misura_code, nist_code, area_politica, misura_descr, ord,
|
|
applies_important, applies_essential
|
|
FROM cfg_nis2_misure ORDER BY ord ASC'
|
|
);
|
|
|
|
$reqs = Database::fetchAll(
|
|
'SELECT q.id, q.n, q.misura_code, q.requisito_code, q.requisito_descr,
|
|
q.proc_code, p.proc_descr, q.risk_code, r.risk_descr,
|
|
q.applies_important, q.applies_essential
|
|
FROM cfg_nis2_requisiti q
|
|
LEFT JOIN cfg_nis2_procedure p ON p.proc_code = q.proc_code
|
|
LEFT JOIN cfg_nis2_rischi r ON r.risk_code = q.risk_code
|
|
ORDER BY q.n ASC, q.id ASC'
|
|
);
|
|
|
|
$byMis = [];
|
|
foreach ($reqs as $q) {
|
|
$rid = (int) $q['id'];
|
|
$st = $stateMap[$rid] ?? null;
|
|
$byMis[$q['misura_code']][] = [
|
|
'id' => $rid,
|
|
'n' => $q['n'] !== null ? (int) $q['n'] : null,
|
|
'requisito_code' => $q['requisito_code'],
|
|
'requisito_descr' => $q['requisito_descr'],
|
|
'proc_code' => $q['proc_code'],
|
|
'proc_descr' => $q['proc_descr'],
|
|
'policies' => $policiesByReq[$rid] ?? [], // #384 p.2.2/p.3 (multi-valore)
|
|
'risk_code' => $q['risk_code'],
|
|
'risk_descr' => $q['risk_descr'],
|
|
'applies_important' => (int) $q['applies_important'],
|
|
'applies_essential' => (int) $q['applies_essential'],
|
|
'stato' => $st['stato'] ?? 'da_valutare',
|
|
'valutazione_rischio' => $st['valutazione_rischio'] ?? null,
|
|
'note' => $st['note'] ?? null,
|
|
];
|
|
}
|
|
|
|
$measures = [];
|
|
foreach ($misure as $m) {
|
|
$measures[] = [
|
|
'misura_code' => $m['misura_code'],
|
|
'nist_code' => $m['nist_code'],
|
|
'area_politica' => $m['area_politica'],
|
|
'misura_descr' => $m['misura_descr'],
|
|
'ord' => (int) $m['ord'],
|
|
'applies_important' => (int) $m['applies_important'],
|
|
'applies_essential' => (int) $m['applies_essential'],
|
|
'requisiti' => $byMis[$m['misura_code']] ?? [],
|
|
];
|
|
}
|
|
|
|
$this->jsonSuccess([
|
|
'org_class' => $orgClass,
|
|
'has_org' => $orgId !== null,
|
|
'stati' => self::STATI,
|
|
'ambiti' => $ambiti,
|
|
'measures' => $measures,
|
|
'totals' => ['misure' => count($measures), 'requisiti' => count($reqs)],
|
|
'readonly' => true,
|
|
'source' => 'Determinazione ACN n. 164179 del 14 aprile 2025',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* POST /api/framework/state
|
|
* Upsert della valutazione/stato di UN requisito per l'org corrente.
|
|
* Body: {requisito_id*, stato?, valutazione_rischio?, note?}
|
|
* La codifica del framework NON e' toccata: si scrive solo org_requisito_state.
|
|
*/
|
|
public function setState(): void
|
|
{
|
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
|
$orgId = $this->getCurrentOrgId();
|
|
$body = $this->getJsonBody();
|
|
|
|
$reqId = isset($body['requisito_id']) ? (int) $body['requisito_id'] : 0;
|
|
if ($reqId <= 0) { $this->jsonError('requisito_id mancante o non valido', 400); return; }
|
|
|
|
// anti-IDOR: il requisito deve esistere nel catalogo canonico
|
|
$exists = Database::fetchOne('SELECT id FROM cfg_nis2_requisiti WHERE id = ?', [$reqId]);
|
|
if (!$exists) { $this->jsonError('Requisito inesistente', 404); return; }
|
|
|
|
$stato = $body['stato'] ?? null;
|
|
if ($stato !== null && !in_array($stato, self::STATI, true)) {
|
|
$this->jsonError('Stato non valido', 400); return;
|
|
}
|
|
$valutazione = array_key_exists('valutazione_rischio', $body)
|
|
? ($body['valutazione_rischio'] !== '' ? (string) $body['valutazione_rischio'] : null) : null;
|
|
$note = array_key_exists('note', $body)
|
|
? ($body['note'] !== '' ? (string) $body['note'] : null) : null;
|
|
|
|
// Probabilita/impatto 1-5 (opzione B ticket #500, matrice 5x5). null = non valutato.
|
|
$scale = static function ($v) {
|
|
if ($v === null || $v === '') return null;
|
|
$n = (int) $v;
|
|
return ($n >= 1 && $n <= 5) ? $n : null;
|
|
};
|
|
$likelihood = array_key_exists('likelihood', $body) ? $scale($body['likelihood']) : null;
|
|
$impact = array_key_exists('impact', $body) ? $scale($body['impact']) : null;
|
|
|
|
// upsert org-scoped (UNIQUE org+requisito)
|
|
Database::query(
|
|
'INSERT INTO org_requisito_state
|
|
(organization_id, requisito_id, stato, valutazione_rischio, note, likelihood, impact, updated_by)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE
|
|
stato = VALUES(stato),
|
|
valutazione_rischio = VALUES(valutazione_rischio),
|
|
note = VALUES(note),
|
|
likelihood = VALUES(likelihood),
|
|
impact = VALUES(impact),
|
|
updated_by = VALUES(updated_by)',
|
|
[$orgId, $reqId, $stato ?? 'da_valutare', $valutazione, $note, $likelihood, $impact, $this->getCurrentUserId()]
|
|
);
|
|
|
|
$row = Database::fetchOne(
|
|
'SELECT requisito_id, stato, valutazione_rischio, note, likelihood, impact, updated_at
|
|
FROM org_requisito_state WHERE organization_id = ? AND requisito_id = ?',
|
|
[$orgId, $reqId]
|
|
);
|
|
$this->jsonSuccess([
|
|
'requisito_id' => (int) $row['requisito_id'],
|
|
'stato' => $row['stato'],
|
|
'valutazione_rischio' => $row['valutazione_rischio'],
|
|
'note' => $row['note'],
|
|
'likelihood' => $row['likelihood'] !== null ? (int) $row['likelihood'] : null,
|
|
'impact' => $row['impact'] !== null ? (int) $row['impact'] : null,
|
|
'updated_at' => $row['updated_at'],
|
|
], 'Valutazione salvata');
|
|
}
|
|
}
|