[FEAT] #384 p.2.2/3 fondazione — modello relazionale REQUISITO<->POLICY (mig.059)
Migrazione 059 additiva/idempotente/reversibile: - policies.proc_code VARCHAR(16) NULL: ripristina la chiave di join verso il catalogo canonico (cfg_nis2_procedure/cfg_nis2_requisiti), che andava persa quando le 42 procedure di default venivano copiate nell'elenco Policy (mig.048). - tabella requisito_policy: M:N org-scoped requisito<->policy (un requisito puo' avere piu' policy e una policy piu' requisiti) — base per p.2.2 e p.3. - backfill sicuro: proc_code valorizzato su 42 policy (match esatto titolo) + 116 associazioni di default popolate (INSERT IGNORE, WHERE proc_code IS NULL). - PolicyController::ensureFrameworkProcedures: i nuovi seed conservano proc_code e popolano requisito_policy (try/catch dedicato, non blocca il seed). Nessun impatto utente in questo passo (nessuna superficie legge ancora i nuovi campi). UI multi-valore + link bidirezionali (2.2/3) nei prossimi cicli. v1.23.6. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8a8045904b
commit
02f3e69227
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* migrate_059_requisito_policy.php — Ticket #384 (p.2.2 + p.3): modello
|
||||||
|
* relazionale REQUISITO <-> POLICY. Runner IDEMPOTENTE e GUARDATO (PDO app, TLS).
|
||||||
|
*
|
||||||
|
* TUTTO ADDITIVO (nessun DROP/DELETE):
|
||||||
|
* 1) policies.proc_code VARCHAR(16) NULL (chiave di join verso il catalogo)
|
||||||
|
* 2) tabella requisito_policy (M:N org-scoped requisito<->policy)
|
||||||
|
* 3) backfill SICURO: proc_code per match esatto title==proc_descr +
|
||||||
|
* requisito_policy dalle associazioni di default esistenti (INSERT IGNORE).
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* docker exec nis2-app php .../application/cli/migrate_059_requisito_policy.php
|
||||||
|
* ... --dry-run
|
||||||
|
*/
|
||||||
|
if (PHP_SAPI !== 'cli') { http_response_code(403); exit("CLI only\n"); }
|
||||||
|
require_once __DIR__ . '/../config/env.php';
|
||||||
|
require_once __DIR__ . '/../config/database.php';
|
||||||
|
|
||||||
|
$DRY = in_array('--dry-run', $argv, true);
|
||||||
|
$pdo = Database::getInstance();
|
||||||
|
$db = $pdo->query('SELECT DATABASE()')->fetchColumn();
|
||||||
|
echo "mig.059 — schema=$db | dry-run=" . ($DRY ? 'SI' : 'no') . "\n\n";
|
||||||
|
|
||||||
|
function col1(PDO $p, $s, $a = []) { $st = $p->prepare($s); $st->execute($a); return $st->fetchColumn(); }
|
||||||
|
function colExists(PDO $p, $db, $t, $c) { return (int)col1($p, "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=? AND TABLE_NAME=? AND COLUMN_NAME=?", [$db, $t, $c]) > 0; }
|
||||||
|
function tblExists(PDO $p, $db, $t) { return (int)col1($p, "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=?", [$db, $t]) > 0; }
|
||||||
|
|
||||||
|
$applied = 0; $skipped = 0; $failed = 0;
|
||||||
|
function run(PDO $pdo, bool $DRY, string $label, string $sql) {
|
||||||
|
global $applied, $skipped, $failed;
|
||||||
|
if ($DRY) { echo " [DRY] $label\n $sql\n"; return; }
|
||||||
|
try { $pdo->exec($sql); echo " [OK ] $label\n"; $applied++; }
|
||||||
|
catch (\PDOException $e) {
|
||||||
|
$c = (int)($e->errorInfo[1] ?? 0);
|
||||||
|
if (in_array($c, [1060, 1061, 1826, 1022, 1062, 1050], true)) { echo " [SKIP] $label (gia' presente, code=$c)\n"; $skipped++; }
|
||||||
|
else { echo " [FAIL] $label -> " . $e->getMessage() . "\n"; $failed++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 1) policies.proc_code ── */
|
||||||
|
echo "── 1) policies.proc_code ──\n";
|
||||||
|
if (colExists($pdo, $db, 'policies', 'proc_code')) { echo " [SKIP] policies.proc_code gia' presente\n"; $skipped++; }
|
||||||
|
else run($pdo, $DRY, 'policies ADD COLUMN proc_code VARCHAR(16) NULL',
|
||||||
|
"ALTER TABLE policies ADD COLUMN proc_code VARCHAR(16) NULL AFTER nis2_article");
|
||||||
|
|
||||||
|
/* ── 2) tabella requisito_policy ── */
|
||||||
|
echo "\n── 2) tabella requisito_policy ──\n";
|
||||||
|
if (tblExists($pdo, $db, 'requisito_policy')) { echo " [SKIP] requisito_policy gia' presente\n"; $skipped++; }
|
||||||
|
else run($pdo, $DRY, 'CREATE TABLE requisito_policy',
|
||||||
|
"CREATE TABLE requisito_policy (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
organization_id INT NOT NULL,
|
||||||
|
requisito_id INT NOT NULL,
|
||||||
|
policy_id INT NOT NULL,
|
||||||
|
is_default TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
created_by INT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_req_pol (organization_id, requisito_id, policy_id),
|
||||||
|
KEY idx_rp_org (organization_id),
|
||||||
|
KEY idx_rp_req (requisito_id),
|
||||||
|
KEY idx_rp_pol (policy_id),
|
||||||
|
CONSTRAINT fk_rp_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_rp_req FOREIGN KEY (requisito_id) REFERENCES cfg_nis2_requisiti (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_rp_pol FOREIGN KEY (policy_id) REFERENCES policies (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_rp_user FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||||||
|
|
||||||
|
/* ── 3) backfill SICURO (additivo) ── */
|
||||||
|
echo "\n── 3) backfill (additivo, idempotente) ──\n";
|
||||||
|
if (!$DRY && colExists($pdo, $db, 'policies', 'proc_code') && tblExists($pdo, $db, 'cfg_nis2_procedure')) {
|
||||||
|
// 3a) proc_code per match esatto del titolo (title = mb_substr(proc_descr,0,255))
|
||||||
|
$n1 = $pdo->exec("UPDATE policies p
|
||||||
|
JOIN cfg_nis2_procedure c ON p.title = c.proc_descr
|
||||||
|
SET p.proc_code = c.proc_code
|
||||||
|
WHERE p.proc_code IS NULL");
|
||||||
|
echo " [OK ] proc_code valorizzato su $n1 policy (match esatto titolo)\n"; $applied++;
|
||||||
|
|
||||||
|
// 3b) requisito_policy dalle associazioni di default (per ogni proc_code)
|
||||||
|
if (tblExists($pdo, $db, 'requisito_policy') && tblExists($pdo, $db, 'cfg_nis2_requisiti')) {
|
||||||
|
$n2 = $pdo->exec("INSERT IGNORE INTO requisito_policy (organization_id, requisito_id, policy_id, is_default)
|
||||||
|
SELECT p.organization_id, q.id, p.id, 1
|
||||||
|
FROM policies p
|
||||||
|
JOIN cfg_nis2_requisiti q ON q.proc_code = p.proc_code
|
||||||
|
WHERE p.proc_code IS NOT NULL");
|
||||||
|
echo " [OK ] requisito_policy popolata: $n2 associazioni di default\n"; $applied++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo " [SKIP] backfill (dry-run o prerequisiti assenti)\n"; $skipped++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── verifica finale ── */
|
||||||
|
echo "\n── VERIFICA ──\n";
|
||||||
|
if (!$DRY) {
|
||||||
|
$cWith = (int)col1($pdo, "SELECT COUNT(*) FROM policies WHERE proc_code IS NOT NULL");
|
||||||
|
$cTot = (int)col1($pdo, "SELECT COUNT(*) FROM policies");
|
||||||
|
echo " policies con proc_code: $cWith / $cTot\n";
|
||||||
|
if (tblExists($pdo, $db, 'requisito_policy')) {
|
||||||
|
$cLinks = (int)col1($pdo, "SELECT COUNT(*) FROM requisito_policy");
|
||||||
|
echo " requisito_policy righe: $cLinks\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n── RIEPILOGO ── applicati=$applied skip=$skipped falliti=$failed\n";
|
||||||
|
echo ($failed === 0 ? "ESITO OK\n" : "ESITO: rivedere i [FAIL]\n");
|
||||||
|
exit($failed === 0 ? 0 : 1);
|
||||||
@@ -99,15 +99,29 @@ class PolicyController extends BaseController
|
|||||||
. "\n\nProcedura di default del framework NIS2 (Determinazione ACN n. 164179 del 14 aprile 2025)."
|
. "\n\nProcedura di default del framework NIS2 (Determinazione ACN n. 164179 del 14 aprile 2025)."
|
||||||
. ($p['misure'] ? ' Copre le misure: ' . $p['misure'] . '.' : '')
|
. ($p['misure'] ? ' Copre le misure: ' . $p['misure'] . '.' : '')
|
||||||
. ' Contenuto iniziale personalizzabile e cancellabile.';
|
. ' Contenuto iniziale personalizzabile e cancellabile.';
|
||||||
Database::insert('policies', [
|
$pid = Database::insert('policies', [
|
||||||
'organization_id' => $orgId,
|
'organization_id' => $orgId,
|
||||||
'title' => mb_substr($p['proc_descr'], 0, 255),
|
'title' => mb_substr($p['proc_descr'], 0, 255),
|
||||||
'category' => $cat,
|
'category' => $cat,
|
||||||
'nis2_article' => mb_substr($p['first_misura'] ?? '', 0, 20),
|
'nis2_article' => mb_substr($p['first_misura'] ?? '', 0, 20),
|
||||||
|
'proc_code' => $p['proc_code'],
|
||||||
'content' => $content,
|
'content' => $content,
|
||||||
'status' => 'draft',
|
'status' => 'draft',
|
||||||
'ai_generated' => 0,
|
'ai_generated' => 0,
|
||||||
]);
|
]);
|
||||||
|
// Ticket #384 (p.2.2/p.3): conserva il legame requisito<->policy.
|
||||||
|
// Collega tutti i requisiti che puntano a questa proc_code (M:N org-scoped,
|
||||||
|
// tabella requisito_policy mig.059). try/catch dedicato: un'eventuale assenza
|
||||||
|
// della tabella NON deve far fallire il seed delle procedure di default.
|
||||||
|
try {
|
||||||
|
Database::query(
|
||||||
|
"INSERT IGNORE INTO requisito_policy (organization_id, requisito_id, policy_id, is_default)
|
||||||
|
SELECT ?, q.id, ?, 1 FROM cfg_nis2_requisiti q WHERE q.proc_code = ?",
|
||||||
|
[$orgId, $pid, $p['proc_code']]
|
||||||
|
);
|
||||||
|
} catch (Throwable $eRp) {
|
||||||
|
error_log('[PolicyController] link requisito_policy (org ' . $orgId . ', proc ' . ($p['proc_code'] ?? '?') . '): ' . $eRp->getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Database::query(
|
Database::query(
|
||||||
'UPDATE organizations SET framework_procedures_seeded_at = NOW() WHERE id = ?',
|
'UPDATE organizations SET framework_procedures_seeded_at = NOW() WHERE id = ?',
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 059 — Modello relazionale REQUISITO <-> POLICY (Ticket #384, punti 2.2 + 3)
|
||||||
|
-- =====================================================================
|
||||||
|
-- Fonte spec: ticket #384 (super_admin) + istruzioni.pdf allegato.
|
||||||
|
-- p.2.2 "ogni riga di requisito puo' avere piu' policy e ogni policy puo'
|
||||||
|
-- avere piu' requisiti associati" -> serve una M:N esplicita.
|
||||||
|
-- p.3 "i codici devono funzionare come link attivi" (Policy <-> Misure
|
||||||
|
-- e Requisiti) -> serve poter risalire dalla policy ai requisiti
|
||||||
|
-- e viceversa.
|
||||||
|
--
|
||||||
|
-- PROBLEMA STRUTTURALE risolto qui: quando le 42 procedure di default
|
||||||
|
-- (cfg_nis2_procedure) vengono copiate nell'elenco `policies` di una org
|
||||||
|
-- (PolicyController::ensureFrameworkProcedures, mig.048), il `proc_code`
|
||||||
|
-- canonico NON veniva conservato sulla riga policy -> il legame
|
||||||
|
-- requisito (cfg_nis2_requisiti.proc_code) <-> policy andava perso.
|
||||||
|
--
|
||||||
|
-- COSA FA (tutto ADDITIVO, IDEMPOTENTE, REVERSIBILE — nessun DROP/DELETE):
|
||||||
|
-- 1) policies.proc_code VARCHAR(16) NULL — chiave di join verso il
|
||||||
|
-- catalogo canonico (cfg_nis2_procedure.proc_code / cfg_nis2_requisiti.proc_code).
|
||||||
|
-- 2) Tabella requisito_policy — M:N org-scoped requisito<->policy.
|
||||||
|
-- requisito_id -> cfg_nis2_requisiti.id (canonico, SYSTEM-level)
|
||||||
|
-- policy_id -> policies.id (per-org)
|
||||||
|
-- organization_id -> organizations.id (lo stesso link e' per-org perche'
|
||||||
|
-- le policy sono per-org).
|
||||||
|
-- 3) Backfill SICURO (solo letture->scritture additive): valorizza
|
||||||
|
-- policies.proc_code per match esatto title==proc_descr e popola
|
||||||
|
-- requisito_policy con le associazioni di default gia' esistenti.
|
||||||
|
--
|
||||||
|
-- Runner: application/cli/migrate_059_requisito_policy.php (PDO app, TLS,
|
||||||
|
-- guardato via information_schema, idempotente). NON usa DELIMITER/stored-proc.
|
||||||
|
--
|
||||||
|
-- ROLLBACK:
|
||||||
|
-- DROP TABLE IF EXISTS requisito_policy;
|
||||||
|
-- ALTER TABLE policies DROP COLUMN proc_code;
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- 1) chiave di join sulla riga policy (NULL = policy non legata al catalogo)
|
||||||
|
ALTER TABLE policies
|
||||||
|
ADD COLUMN proc_code VARCHAR(16) NULL AFTER nis2_article;
|
||||||
|
|
||||||
|
-- 2) M:N esplicita requisito <-> policy (org-scoped)
|
||||||
|
CREATE TABLE IF NOT EXISTS requisito_policy (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
organization_id INT NOT NULL,
|
||||||
|
requisito_id INT NOT NULL,
|
||||||
|
policy_id INT NOT NULL,
|
||||||
|
is_default TINYINT(1) NOT NULL DEFAULT 0, -- 1 = derivata dal framework
|
||||||
|
created_by INT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_req_pol (organization_id, requisito_id, policy_id),
|
||||||
|
KEY idx_rp_org (organization_id),
|
||||||
|
KEY idx_rp_req (requisito_id),
|
||||||
|
KEY idx_rp_pol (policy_id),
|
||||||
|
CONSTRAINT fk_rp_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_rp_req FOREIGN KEY (requisito_id) REFERENCES cfg_nis2_requisiti (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_rp_pol FOREIGN KEY (policy_id) REFERENCES policies (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_rp_user FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 3) Backfill (eseguito dal runner, qui documentato):
|
||||||
|
-- UPDATE policies p JOIN cfg_nis2_procedure c ON p.title = c.proc_descr
|
||||||
|
-- SET p.proc_code = c.proc_code WHERE p.proc_code IS NULL;
|
||||||
|
-- INSERT IGNORE INTO requisito_policy (organization_id, requisito_id, policy_id, is_default)
|
||||||
|
-- SELECT p.organization_id, q.id, p.id, 1
|
||||||
|
-- FROM policies p JOIN cfg_nis2_requisiti q ON q.proc_code = p.proc_code
|
||||||
|
-- WHERE p.proc_code IS NOT NULL;
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version": "1.23.5", "build": "2026-06-18-v1.23.5", "date": "2026-06-18", "changelog": "Ticket #384 punto 5 (allegati Policy): nel dettaglio di ogni Policy ora c'e' la sezione 'Allegati' per caricare documenti di supporto (PDF, immagini, Office, txt, csv, zip; max 10 MB), elencarli con autore/data e scaricarli. Hardening sicurezza: aggiunta allowlist server-side delle estensioni sull'endpoint di upload evidenze (rifiuto html/svg/js, anti stored-XSS same-origin). Restano in lavorazione a fasi: unificazione archivi M:N requisito<->policy (punto 2.2), link attivi bidirezionali tra i due menu (punto 3), matrice RACI assegnabile per policy con R e A obbligatorie in approvazione (punto 6)."}
|
{"version": "1.23.6", "build": "2026-06-18-v1.23.6", "date": "2026-06-18", "changelog": "Ticket #384 punto 2.2/3 (fondazione dati): introdotto il modello relazionale REQUISITO<->POLICY (migrazione 059, additiva/reversibile). Ripristinata sulla riga policy la chiave 'proc_code' (persa quando le procedure di default venivano copiate nell'elenco Policy) e creata la tabella requisito_policy per il legame molti-a-molti org-scoped (un requisito puo' avere piu' policy e una policy piu' requisiti). Backfill sicuro: valorizzato proc_code su 42 policy e popolate 116 associazioni di default esistenti. Nessun impatto visibile per l'utente in questo passo: e' la base dati su cui poggeranno l'unificazione multi-valore delle colonne (2.2) e i link attivi bidirezionali tra i due menu (3). Restano in lavorazione a fasi: UI multi-valore + link bidirezionali (2.2/3) e matrice RACI assegnabile per policy con R e A obbligatorie in approvazione (punto 6)."}
|
||||||
|
|||||||
Reference in New Issue
Block a user