[FEAT] Epic C / C1 operativo — valutazione/stato di conformità per requisito (mig.047)

org_requisito_state (org x requisito -> stato + valutazione del rischio + note),
la valutazione 'parte vuota'. È da qui che emerge lo stato di conformità (sostituirà il Gap ACN, C3).
- FrameworkController: catalog ora include lo stato per-org di ogni requisito (risoluzione org
  OPZIONALE con verifica accesso = anti-IDOR); nuovo POST /framework/state (upsert, requireOrgRole
  org_admin/compliance_manager, validazione stato + requisito esistente).
- misure-requisiti.html: colonna Stato/Valutazione (se org) + modale di valutazione (stato,
  valutazione del rischio, note). api.frameworkSetState.
Smoke prod: catalog has_org/org_class OK; upsert non_conforme persistito e riletto; ISOLAMENTO
multi-tenant verificato (org 152 non visibile a org 151). Additivo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-16 17:41:16 +02:00
co-authored by Claude Opus 4.8
parent 92d101b6f4
commit b34bf3f9f6
5 changed files with 286 additions and 38 deletions
+133 -36
View File
@@ -3,19 +3,18 @@
* 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.
* 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 forniti dal referente compliance (docs/simon/) — codifica
* NON variabile. Il dato NON e' org-scoped (e' framework normativo condiviso);
* l'unico contributo dell'org e' la sua CLASSE (essential/important), usata dal
* frontend per "spegnere" le righe non applicabili al soggetto.
* file autoritativi del referente compliance (docs/simon/) — codifica NON
* variabile. La codifica e l'ordine NON sono modificabili dall'utente.
*
* Sola lettura: nessun endpoint di scrittura. L'utente NON puo' modificare la
* codifica; la configurazione del framework e' riservata a super_admin/sistema
* (gestione futura). Vista doppia (per misura / per area politica a..p) gestita
* lato frontend sugli stessi dati.
* 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).
*/
@@ -24,37 +23,72 @@ 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
* Ritorna ambiti NIST, misure (con requisiti annidati, procedura+rischio di
* default e flag di applicabilita' importanti/essenziali) e la classe dell'org
* corrente (se disponibile). Ordine 'ord'/'n' = ordine di default immutabile.
* 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();
// Classe del soggetto (per evidenziare/spegnere le righe) — opzionale.
// 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;
$orgId = $this->getCurrentOrgId();
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
}
$ambiti = Database::fetchAll(
'SELECT nist_code, nist_descr FROM cfg_nis2_ambiti ORDER BY nist_code ASC'
);
// 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'],
];
}
}
$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'
FROM cfg_nis2_misure ORDER BY ord ASC'
);
$reqs = Database::fetchAll(
'SELECT q.n, q.misura_code, q.requisito_code, q.requisito_descr,
'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
@@ -65,16 +99,22 @@ class FrameworkController extends BaseController
$byMis = [];
foreach ($reqs as $q) {
$rid = (int) $q['id'];
$st = $stateMap[$rid] ?? null;
$byMis[$q['misura_code']][] = [
'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'],
'risk_code' => $q['risk_code'],
'risk_descr' => $q['risk_descr'],
'applies_important' => (int) $q['applies_important'],
'applies_essential' => (int) $q['applies_essential'],
'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'],
'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,
];
}
@@ -93,12 +133,69 @@ class FrameworkController extends BaseController
}
$this->jsonSuccess([
'org_class' => $orgClass,
'ambiti' => $ambiti,
'measures' => $measures,
'totals' => ['misure' => count($measures), 'requisiti' => count($reqs)],
'readonly' => true,
'source' => 'Determinazione ACN n. 164179 del 14 aprile 2025',
'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;
// upsert org-scoped (UNIQUE org+requisito)
Database::query(
'INSERT INTO org_requisito_state
(organization_id, requisito_id, stato, valutazione_rischio, note, updated_by)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
stato = VALUES(stato),
valutazione_rischio = VALUES(valutazione_rischio),
note = VALUES(note),
updated_by = VALUES(updated_by)',
[$orgId, $reqId, $stato ?? 'da_valutare', $valutazione, $note, $this->getCurrentUserId()]
);
$row = Database::fetchOne(
'SELECT requisito_id, stato, valutazione_rischio, note, 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'],
'updated_at' => $row['updated_at'],
], 'Valutazione salvata');
}
}