From b34bf3f9f601d193b5e6ae6fad200b033f62e3a1 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Tue, 16 Jun 2026 17:41:16 +0200 Subject: [PATCH] =?UTF-8?q?[FEAT]=20Epic=20C=20/=20C1=20operativo=20?= =?UTF-8?q?=E2=80=94=20valutazione/stato=20di=20conformit=C3=A0=20per=20re?= =?UTF-8?q?quisito=20(mig.047)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../controllers/FrameworkController.php | 169 ++++++++++++++---- docs/sql/047_org_requisito_state.sql | 30 ++++ public/index.php | 1 + public/js/api.js | 1 + public/misure-requisiti.html | 123 ++++++++++++- 5 files changed, 286 insertions(+), 38 deletions(-) create mode 100644 docs/sql/047_org_requisito_state.sql diff --git a/application/controllers/FrameworkController.php b/application/controllers/FrameworkController.php index efc1172..203588f 100644 --- a/application/controllers/FrameworkController.php +++ b/application/controllers/FrameworkController.php @@ -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'); + } } diff --git a/docs/sql/047_org_requisito_state.sql b/docs/sql/047_org_requisito_state.sql new file mode 100644 index 0000000..82539c5 --- /dev/null +++ b/docs/sql/047_org_requisito_state.sql @@ -0,0 +1,30 @@ +-- ===================================================================== +-- 047 — Stato/valutazione per-org di ogni REQUISITO (Epic C / C1 operativo) +-- ===================================================================== +-- Per ogni organizzazione e per ogni requisito del framework (cfg_nis2_requisiti) +-- conserva la VALUTAZIONE DEL RISCHIO (che "parte vuota") e lo STATO di conformita'. +-- È da qui che emerge lo stato di conformita' dell'azienda (sostituisce il modulo +-- "Analisi GAP ACN", C3). Org-scoped, additivo, reversibile (DROP TABLE). +-- +-- Runner-safe: solo CREATE TABLE IF NOT EXISTS (no DELIMITER/stored-proc). +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS org_requisito_state ( + id INT NOT NULL AUTO_INCREMENT, + organization_id INT NOT NULL, + requisito_id INT NOT NULL, + stato ENUM('da_valutare','non_applicabile','non_conforme','parziale','conforme') + NOT NULL DEFAULT 'da_valutare', + valutazione_rischio TEXT NULL, + note TEXT NULL, + updated_by INT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_org_req (organization_id, requisito_id), + KEY idx_ors_org (organization_id), + KEY idx_ors_req (requisito_id), + CONSTRAINT fk_ors_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE, + CONSTRAINT fk_ors_req FOREIGN KEY (requisito_id) REFERENCES cfg_nis2_requisiti (id) ON DELETE CASCADE, + CONSTRAINT fk_ors_user FOREIGN KEY (updated_by) REFERENCES users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/public/index.php b/public/index.php index 820c08a..2457c3a 100644 --- a/public/index.php +++ b/public/index.php @@ -255,6 +255,7 @@ $actionMap = [ // rischio di default per requisito). Nessuna scrittura (codifica non variabile). 'framework' => [ 'GET:catalog' => 'catalog', + 'POST:state' => 'setState', // upsert valutazione/stato per requisito (org-scoped) ], // ── AssessmentController ──────────────────────── diff --git a/public/js/api.js b/public/js/api.js index 12ea64b..bf1c440 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -294,6 +294,7 @@ class NIS2API { // Catalogo cfg_nis2_* (43 misure / 116 requisiti + procedura+rischio default). // ═══════════════════════════════════════════════════════════════════ frameworkCatalog() { return this._acn(this.get('/framework/catalog')); } + frameworkSetState(d) { return this._acn(this.post('/framework/state', d || {})); } // ═══════════════════════════════════════════════════════════════════ // Stakeholder estesi (A4 Fase 4.5) — riusa suppliers (GV.SC-02). diff --git a/public/misure-requisiti.html b/public/misure-requisiti.html index f4fbd3f..5eaa4b5 100644 --- a/public/misure-requisiti.html +++ b/public/misure-requisiti.html @@ -37,6 +37,30 @@ .mr-row.spento { opacity:.4; } .mr-row.spento td { font-style:italic; } .mr-empty { text-align:center; padding:34px 16px; color:var(--gray-500,#6b7280); } + .sv-badge { display:inline-block; font-size:.72rem; font-weight:800; padding:3px 10px; border-radius:20px; border:none; cursor:pointer; } + .sv-badge.sv-todo { background:#f1f5f9; color:#475569; } + .sv-badge.sv-na { background:#e5e7eb; color:#6b7280; } + .sv-badge.sv-ko { background:#fee2e2; color:#991b1b; } + .sv-badge.sv-mid { background:#fef3c7; color:#92400e; } + .sv-badge.sv-ok { background:#dcfce7; color:#166534; } + .sv-val { font-size:.76rem; color:#64748b; margin-top:4px; max-width:300px; } + /* Modale valutazione */ + .rm-overlay { display:none; position:fixed; inset:0; background:rgba(15,23,42,.5); z-index:1050; align-items:flex-start; justify-content:center; overflow:auto; padding:40px 16px; } + .rm-overlay.open { display:flex; } + .rm-dialog { background:#fff; border-radius:12px; width:100%; max-width:600px; box-shadow:0 20px 50px rgba(0,0,0,.25); } + .rm-head { display:flex; justify-content:space-between; align-items:flex-start; padding:16px 20px; border-bottom:1px solid var(--gray-100,#f3f4f6); } + .rm-head h3 { margin:0; font-size:1.02rem; } + .rm-head .sub { font-size:.8rem; color:var(--gray-500,#6b7280); margin-top:4px; } + .rm-body { padding:18px 20px; } + .rm-foot { padding:14px 20px; border-top:1px solid var(--gray-100,#f3f4f6); display:flex; justify-content:flex-end; gap:10px; } + .rm-field { margin-bottom:14px; } + .rm-field label { display:block; font-weight:600; font-size:.86rem; margin-bottom:4px; } + .rm-field select, .rm-field textarea { width:100%; padding:9px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.9rem; } + .rm-field textarea { min-height:80px; resize:vertical; } + .rm-ctx { background:#f8fafc; border:1px solid var(--gray-100,#f3f4f6); border-radius:8px; padding:10px 12px; font-size:.83rem; margin-bottom:14px; line-height:1.5; } + .rm-ctx .pill { display:inline-block; font-size:.7rem; font-weight:700; color:#3730a3; background:#eef2ff; border-radius:6px; padding:1px 6px; margin-right:4px; } + .rm-err { color:#b91c1c; font-size:.82rem; min-height:18px; } + .rm-close { background:none; border:none; font-size:1.4rem; line-height:1; cursor:pointer; color:var(--gray-400,#9ca3af); } @@ -104,6 +128,40 @@ + + + @@ -117,11 +175,20 @@ * Dati da api.frameworkCatalog() (tabelle cfg_nis2_*). Codifica non modificabile. * Le righe si "spengono" per la classe del soggetto (importante/essenziale). */ - let MR = { measures: [], org_class: null }; + let MR = { measures: [], org_class: null, has_org: false, stati: [], byId: {} }; function el(id) { return document.getElementById(id); } function esc(s) { const d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; } + const STATO_META = { + da_valutare: { label: 'Da valutare', cls: 'sv-todo' }, + non_applicabile: { label: 'Non applicabile', cls: 'sv-na' }, + non_conforme: { label: 'Non conforme', cls: 'sv-ko' }, + parziale: { label: 'Parziale', cls: 'sv-mid' }, + conforme: { label: 'Conforme', cls: 'sv-ok' } + }; + function statoMeta(s) { return STATO_META[s] || STATO_META.da_valutare; } + document.addEventListener('DOMContentLoaded', async function () { if (typeof checkAuth === 'function' && !checkAuth()) return; if (window.I18n && I18n.init) I18n.init('it'); @@ -135,6 +202,11 @@ const data = await api.frameworkCatalog(); MR.measures = (data && data.measures) || []; MR.org_class = (data && data.org_class) || null; + MR.has_org = !!(data && data.has_org); + MR.stati = (data && data.stati) || Object.keys(STATO_META); + // indicizza i requisiti per id (per la modale) + MR.byId = {}; + MR.measures.forEach(function (m) { (m.requisiti || []).forEach(function (q) { MR.byId[q.id] = q; }); }); // se conosco la classe dell'org, parto in modalità "auto"; altrimenti mostro tutto if (!MR.org_class) el('mr-class').value = 'all'; el('mr-stat-mis').textContent = (data && data.totals && data.totals.misure) || MR.measures.length; @@ -212,7 +284,8 @@ html += ''; html += '' + '' + - ''; + '' + + (MR.has_org ? '' : '') + ''; g.rows.forEach(function (r) { const req = r.req, m = r.m; html += ''; @@ -226,12 +299,58 @@ html += ''; html += ''; html += ''; + if (MR.has_org) { + const sm = statoMeta(req.stato); + const val = req.valutazione_rischio ? esc(req.valutazione_rischio).slice(0, 90) + (req.valutazione_rischio.length > 90 ? '…' : '') : ''; + html += ''; + } html += ''; }); html += '
RequisitoDescrizioneProcedura (default)Rischio (default)Imp.Ess.
Imp.Ess.Stato / Valutazione
' + (req.risk_code ? '' + esc(req.risk_code) + ' ' + esc(req.risk_descr || '') : '—') + '' + (req.applies_important ? '✓' : '—') + '' + (req.applies_essential ? '✓' : '—') + '' + + (val ? '
' + val + '
' : '') + '
'; }); el('mr-list').innerHTML = html; } + + // ── Valutazione per requisito (org-scoped) ────────────────────────── + function mrEdit(reqId) { + const q = MR.byId[reqId]; + if (!q) return; + el('mr-eval-id').value = reqId; + el('mr-modal-sub').textContent = (q.requisito_code || '') + ' · ' + (q.proc_code || '') + ' / ' + (q.risk_code || ''); + let ctx = '
' + esc(q.requisito_descr || '') + '
'; + if (q.risk_code) ctx += '
' + esc(q.risk_code) + ' ' + esc(q.risk_descr || '') + '
'; + el('mr-eval-ctx').innerHTML = ctx; + el('mr-eval-stato').value = q.stato || 'da_valutare'; + el('mr-eval-val').value = q.valutazione_rischio || ''; + el('mr-eval-note').value = q.note || ''; + el('mr-eval-err').textContent = ''; + el('mr-modal').classList.add('open'); + } + function mrCloseEval() { el('mr-modal').classList.remove('open'); } + async function mrSaveEval() { + const reqId = parseInt(el('mr-eval-id').value, 10); + const btn = el('mr-eval-save'); + el('mr-eval-err').textContent = ''; + btn.disabled = true; + try { + const res = await api.frameworkSetState({ + requisito_id: reqId, + stato: el('mr-eval-stato').value, + valutazione_rischio: el('mr-eval-val').value.trim(), + note: el('mr-eval-note').value.trim() + }); + const q = MR.byId[reqId]; + if (q && res) { q.stato = res.stato; q.valutazione_rischio = res.valutazione_rischio; q.note = res.note; } + mrCloseEval(); + mrRender(); + const hint = el('mr-hint'); if (hint) { hint.textContent = 'Valutazione salvata'; setTimeout(function () { hint.textContent = ''; }, 2500); } + } catch (e) { + el('mr-eval-err').textContent = (e && e.message) ? e.message : 'Errore nel salvataggio.'; + } finally { btn.disabled = false; } + } + document.addEventListener('keydown', function (e) { if (e.key === 'Escape') mrCloseEval(); }); + (function () { const ov = el('mr-modal'); if (ov) ov.addEventListener('click', function (e) { if (e.target === ov) mrCloseEval(); }); })();