From 2b6c5087ad45f4893142afc05d0dc9db8c58cddf Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Thu, 18 Jun 2026 07:57:25 +0200 Subject: [PATCH] =?UTF-8?q?[DB]=20Hardening=20integrit=C3=A0=20chiavi=20(m?= =?UTF-8?q?ig.058):=20+4=20UNIQUE=20+8=20FK,=20retry-on-1062,=20bonifica?= =?UTF-8?q?=2016=20righe=20stale=20=E2=80=94=20v1.23.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Estende l'audit mig.039 ai moduli 040-057 (non coperti). TIER1 additivo (0 dup/0 orfani verificati su DB live): - UNIQUE internal_audits/management_reviews (org,code), kb_uploaded_documents.qdrant_doc_uuid, whistleblowing_reports.anonymous_token (drop idx_token ridondante) - retry-on-1062 in InternalAuditController/ManagementReviewController (allineati a periodic_controls) - 6 FK: consulting_firm_id (organizations/users/kb)->consulting_firms; isms_soa.linked_control_id; isms_documents.linked_policy_id; management_review_decisions.capa_id (tutte SET NULL) TIER2 bonifica (backup pre-DELETE in .backups/): -9 firm_org_assignments orfane (org 126-129, chiude deferred-b mig.039) -7 active_sessions scadute, +2 FK CASCADE. FK totali 196->204. Sonda diagnostica db_integrity_probe.php. audit_logs lasciato by-design. Co-Authored-By: Claude Opus 4.8 (1M context) --- application/cli/db_integrity_probe.php | 148 ++++++++++++++++++ .../cli/migrate_058_integrity_keys.php | 137 ++++++++++++++++ .../controllers/InternalAuditController.php | 20 ++- .../ManagementReviewController.php | 19 ++- docs/CONTEXT_LAST_SESSION.md | 17 ++ docs/OPEN_TICKETS.md | 2 +- docs/sql/058_integrity_keys.sql | 64 ++++++++ public/version.json | 2 +- 8 files changed, 399 insertions(+), 10 deletions(-) create mode 100644 application/cli/db_integrity_probe.php create mode 100644 application/cli/migrate_058_integrity_keys.php create mode 100644 docs/sql/058_integrity_keys.sql diff --git a/application/cli/db_integrity_probe.php b/application/cli/db_integrity_probe.php new file mode 100644 index 0000000..7198b5f --- /dev/null +++ b/application/cli/db_integrity_probe.php @@ -0,0 +1,148 @@ +query('SELECT DATABASE()')->fetchColumn(); + +function q(PDO $pdo, string $sql, array $p = []): array { + $st = $pdo->prepare($sql); $st->execute($p); return $st->fetchAll(PDO::FETCH_ASSOC); +} +function h(string $t): void { echo "\n=== $t ===\n"; } + +echo "SONDA INTEGRITÀ DB — schema: $db\n"; +echo "Modalità: READ-ONLY (information_schema + SELECT). Nessuna scrittura.\n"; + +$issues = 0; + +/* 1) Tabelle senza PRIMARY KEY ----------------------------------------- */ +h('1) Tabelle SENZA PRIMARY KEY'); +$noPk = q($pdo, " + SELECT t.TABLE_NAME + FROM information_schema.TABLES t + LEFT JOIN information_schema.TABLE_CONSTRAINTS c + ON c.TABLE_SCHEMA=t.TABLE_SCHEMA AND c.TABLE_NAME=t.TABLE_NAME AND c.CONSTRAINT_TYPE='PRIMARY KEY' + WHERE t.TABLE_SCHEMA=? AND t.TABLE_TYPE='BASE TABLE' AND c.CONSTRAINT_NAME IS NULL + ORDER BY t.TABLE_NAME", [$db]); +if (!$noPk) echo " OK: tutte le tabelle hanno PK.\n"; +else { foreach ($noPk as $r) echo " [!] {$r['TABLE_NAME']}\n"; $issues += count($noPk); } + +/* 2) Tabelle non-InnoDB (niente FK) ------------------------------------ */ +h('2) Tabelle con engine != InnoDB (FK non supportate)'); +$eng = q($pdo, " + SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES + WHERE TABLE_SCHEMA=? AND TABLE_TYPE='BASE TABLE' AND ENGINE<>'InnoDB' ORDER BY TABLE_NAME", [$db]); +if (!$eng) echo " OK: tutte InnoDB.\n"; +else { foreach ($eng as $r) echo " [!] {$r['TABLE_NAME']} = {$r['ENGINE']}\n"; $issues += count($eng); } + +/* 3) Conteggio chiavi: PK / UNIQUE / FK per tabella -------------------- */ +h('3) Tabelle senza alcun indice UNIQUE oltre alla PK (informativo)'); +$rows = q($pdo, " + SELECT t.TABLE_NAME, + SUM(CASE WHEN s.NON_UNIQUE=0 AND s.INDEX_NAME<>'PRIMARY' THEN 1 ELSE 0 END) AS uniq_idx + FROM information_schema.TABLES t + LEFT JOIN information_schema.STATISTICS s + ON s.TABLE_SCHEMA=t.TABLE_SCHEMA AND s.TABLE_NAME=t.TABLE_NAME + WHERE t.TABLE_SCHEMA=? AND t.TABLE_TYPE='BASE TABLE' + GROUP BY t.TABLE_NAME HAVING uniq_idx=0 ORDER BY t.TABLE_NAME", [$db]); +echo ' ' . count($rows) . " tabelle senza UNIQUE secondario (normale per tabelle figlie/log).\n"; + +/* 4) Colonne 'code'/'token'/'*_hash' candidate a UNIQUE senza unico ----- */ +h('4) Colonne chiave-naturale (code/token/hash/slug/uuid) SENZA UNIQUE'); +$cands = q($pdo, " + SELECT c.TABLE_NAME, c.COLUMN_NAME + FROM information_schema.COLUMNS c + WHERE c.TABLE_SCHEMA=? + AND (c.COLUMN_NAME IN ('code','token','slug','uuid','key_hash','token_hash','qdrant_doc_uuid') + OR c.COLUMN_NAME LIKE '%_code' OR c.COLUMN_NAME LIKE '%_token' OR c.COLUMN_NAME LIKE '%_uuid') + AND NOT EXISTS ( + SELECT 1 FROM information_schema.STATISTICS s + WHERE s.TABLE_SCHEMA=c.TABLE_SCHEMA AND s.TABLE_NAME=c.TABLE_NAME + AND s.COLUMN_NAME=c.COLUMN_NAME AND s.NON_UNIQUE=0) + ORDER BY c.TABLE_NAME, c.COLUMN_NAME", [$db]); +if (!$cands) echo " OK: nessuna colonna chiave-naturale priva di UNIQUE.\n"; +else { foreach ($cands as $r) echo " [?] {$r['TABLE_NAME']}.{$r['COLUMN_NAME']} (valutare se è chiave naturale)\n"; } +echo " NB: molti '%_code' sono riferimenti (es. ref_code, control_code) e NON vanno resi unici.\n"; + +/* 5) Verifica mirata: duplicati su (organization_id, code) per i moduli a codice progressivo */ +h('5) Duplicati su (organization_id, code) — pre-check sicurezza UNIQUE (mig.058)'); +foreach (['internal_audits','management_reviews','periodic_controls'] as $tbl) { + $exists = q($pdo, "SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=?", [$db,$tbl]); + if (!$exists) { echo " - $tbl: assente\n"; continue; } + $dup = q($pdo, "SELECT organization_id, code, COUNT(*) n FROM `$tbl` + WHERE code IS NOT NULL GROUP BY organization_id, code HAVING n>1"); + $uq = q($pdo, "SELECT INDEX_NAME FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA=? AND TABLE_NAME=? AND NON_UNIQUE=0 AND INDEX_NAME<>'PRIMARY' + GROUP BY INDEX_NAME", [$db,$tbl]); + $hasUq = $uq ? implode(',', array_column($uq,'INDEX_NAME')) : '—'; + if ($dup) { echo " [!] $tbl: " . count($dup) . " coppie (org,code) DUPLICATE → UNIQUE da bonificare prima! UNIQUE attuali: $hasUq\n"; $issues += count($dup); } + else { echo " OK $tbl: 0 duplicati (org,code) → UNIQUE applicabile in sicurezza. UNIQUE attuali: $hasUq\n"; } +} + +/* 6) Colonne *_id senza FK (candidate) -------------------------------- */ +h('6) Colonne *_id SENZA FOREIGN KEY (candidate / da valutare)'); +$nofk = q($pdo, " + SELECT c.TABLE_NAME, c.COLUMN_NAME + FROM information_schema.COLUMNS c + WHERE c.TABLE_SCHEMA=? AND (c.COLUMN_NAME LIKE '%\\_id' OR c.COLUMN_NAME='id' AND 1=0) + AND c.COLUMN_NAME NOT IN ('id') + AND NOT EXISTS ( + SELECT 1 FROM information_schema.KEY_COLUMN_USAGE k + WHERE k.TABLE_SCHEMA=c.TABLE_SCHEMA AND k.TABLE_NAME=c.TABLE_NAME + AND k.COLUMN_NAME=c.COLUMN_NAME AND k.REFERENCED_TABLE_NAME IS NOT NULL) + ORDER BY c.TABLE_NAME, c.COLUMN_NAME", [$db]); +if (!$nofk) echo " OK: ogni colonna *_id ha una FK.\n"; +else { + echo " " . count($nofk) . " colonne *_id senza FK (alcune legittime: polimorfiche object_id, sso_identity_id cross-DB, sentinel 0):\n"; + foreach ($nofk as $r) echo " - {$r['TABLE_NAME']}.{$r['COLUMN_NAME']}\n"; +} + +/* 7) Orphan check su organization_id senza FK (integrità multi-tenant) */ +h('7) Righe ORFANE su organization_id privo di FK (integrità multi-tenant)'); +$orgCols = q($pdo, " + SELECT c.TABLE_NAME + FROM information_schema.COLUMNS c + WHERE c.TABLE_SCHEMA=? AND c.COLUMN_NAME='organization_id' + AND NOT EXISTS ( + SELECT 1 FROM information_schema.KEY_COLUMN_USAGE k + WHERE k.TABLE_SCHEMA=c.TABLE_SCHEMA AND k.TABLE_NAME=c.TABLE_NAME + AND k.COLUMN_NAME='organization_id' AND k.REFERENCED_TABLE_NAME='organizations') + ORDER BY c.TABLE_NAME", [$db]); +// Pattern "by design": FK volutamente assente (trail immutabile / sentinel). NON bloccanti. +$byDesign = ['audit_logs' => 'audit trail immutabile (mig.006), deve sopravvivere alla cancellazione org']; +if (!$orgCols) echo " OK: ogni organization_id ha FK verso organizations.\n"; +else foreach ($orgCols as $r) { + $t = $r['TABLE_NAME']; + $o = q($pdo, "SELECT COUNT(*) n FROM `$t` x + WHERE x.organization_id IS NOT NULL AND x.organization_id<>0 + AND NOT EXISTS (SELECT 1 FROM organizations o WHERE o.id=x.organization_id)"); + $n = (int)($o[0]['n'] ?? 0); + $zero = (int)(q($pdo, "SELECT COUNT(*) n FROM `$t` WHERE organization_id=0")[0]['n'] ?? 0); + $msg = " $t: orfani=$n" . ($zero ? " (+$zero righe org_id=0 sentinel)" : '') . " — FK assente"; + if (isset($byDesign[$t])) { echo " [by-design]$msg ({$byDesign[$t]})\n"; } + elseif ($n>0) { echo " [!]$msg\n"; $issues += $n; } + else echo "$msg\n"; +} + +/* 8) Riepilogo FK totali ---------------------------------------------- */ +h('8) Riepilogo'); +$fkCount = (int)(q($pdo, "SELECT COUNT(*) n FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA=? AND CONSTRAINT_TYPE='FOREIGN KEY'", [$db])[0]['n'] ?? 0); +$tblCount = (int)(q($pdo, "SELECT COUNT(*) n FROM information_schema.TABLES + WHERE TABLE_SCHEMA=? AND TABLE_TYPE='BASE TABLE'", [$db])[0]['n'] ?? 0); +echo " Tabelle: $tblCount | Foreign key totali: $fkCount\n"; +echo " Problemi BLOCCANTI rilevati (PK mancanti / orfani reali / duplicati su code): $issues\n"; +echo ($issues===0 ? " ESITO: nessun problema bloccante.\n" : " ESITO: rivedere le voci [!] sopra.\n"); +exit(0); diff --git a/application/cli/migrate_058_integrity_keys.php b/application/cli/migrate_058_integrity_keys.php new file mode 100644 index 0000000..c64ba21 --- /dev/null +++ b/application/cli/migrate_058_integrity_keys.php @@ -0,0 +1,137 @@ +query('SELECT DATABASE()')->fetchColumn(); +echo "mig.058 — schema=$db | dry-run=" . ($DRY?'SI':'no') . " | cleanup=" . ($CLEANUP?'SI':'no') . "\n\n"; + +function col1(PDO $p,$s,$a=[]){ $st=$p->prepare($s); $st->execute($a); return $st->fetchColumn(); } +function idxExists(PDO $p,$db,$t,$idx){ return (int)col1($p,"SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=? AND TABLE_NAME=? AND INDEX_NAME=?",[$db,$t,$idx])>0; } +function fkExists(PDO $p,$db,$name){ return (int)col1($p,"SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=? AND CONSTRAINT_TYPE='FOREIGN KEY' AND CONSTRAINT_NAME=?",[$db,$name])>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); + // 1061 dup key, 1826/1022 dup FK, 1062 dup entry (già unico) → idempotente + if(in_array($c,[1061,1826,1022,1062],true)){ echo " [SKIP] $label (già presente, code=$c)\n"; $skipped++; } + else { echo " [FAIL] $label → ".$e->getMessage()."\n"; $failed++; } + } +} + +/* ───────── TIER 1: UNIQUE su chiavi naturali (pre-check duplicati) ───────── */ +echo "── TIER 1: UNIQUE ──\n"; + +// U1 internal_audits (organization_id, code) +if(!idxExists($pdo,$db,'internal_audits','uk_intaud_code')){ + $dup=(int)col1($pdo,"SELECT COUNT(*) FROM (SELECT 1 FROM internal_audits WHERE code IS NOT NULL GROUP BY organization_id,code HAVING COUNT(*)>1) x"); + if($dup>0){ echo " [FAIL] internal_audits: $dup duplicati (org,code) — bonificare prima\n"; $failed++; } + else run($pdo,$DRY,'internal_audits ADD UNIQUE uk_intaud_code(organization_id,code)', + "ALTER TABLE internal_audits ADD UNIQUE KEY uk_intaud_code (organization_id, code)"); +} else { echo " [SKIP] internal_audits.uk_intaud_code già presente\n"; $skipped++; } + +// U2 management_reviews (organization_id, code) +if(!idxExists($pdo,$db,'management_reviews','uk_mgr_code')){ + $dup=(int)col1($pdo,"SELECT COUNT(*) FROM (SELECT 1 FROM management_reviews WHERE code IS NOT NULL GROUP BY organization_id,code HAVING COUNT(*)>1) x"); + if($dup>0){ echo " [FAIL] management_reviews: $dup duplicati (org,code)\n"; $failed++; } + else run($pdo,$DRY,'management_reviews ADD UNIQUE uk_mgr_code(organization_id,code)', + "ALTER TABLE management_reviews ADD UNIQUE KEY uk_mgr_code (organization_id, code)"); +} else { echo " [SKIP] management_reviews.uk_mgr_code già presente\n"; $skipped++; } + +// U3 kb_uploaded_documents.qdrant_doc_uuid +if(!idxExists($pdo,$db,'kb_uploaded_documents','uk_kbdoc_qdrant')){ + $dup=(int)col1($pdo,"SELECT COUNT(*) FROM (SELECT 1 FROM kb_uploaded_documents WHERE qdrant_doc_uuid IS NOT NULL AND qdrant_doc_uuid<>'' GROUP BY qdrant_doc_uuid HAVING COUNT(*)>1) x"); + if($dup>0){ echo " [FAIL] kb_uploaded_documents: $dup uuid duplicati\n"; $failed++; } + else run($pdo,$DRY,'kb_uploaded_documents ADD UNIQUE uk_kbdoc_qdrant(qdrant_doc_uuid)', + "ALTER TABLE kb_uploaded_documents ADD UNIQUE KEY uk_kbdoc_qdrant (qdrant_doc_uuid)"); +} else { echo " [SKIP] kb_uploaded_documents.uk_kbdoc_qdrant già presente\n"; $skipped++; } + +// U4 whistleblowing_reports.anonymous_token (DROP idx_token ridondante + UNIQUE) +if(!idxExists($pdo,$db,'whistleblowing_reports','uq_wb_anon_token')){ + $dup=(int)col1($pdo,"SELECT COUNT(*) FROM (SELECT 1 FROM whistleblowing_reports WHERE anonymous_token IS NOT NULL AND anonymous_token<>'' GROUP BY anonymous_token HAVING COUNT(*)>1) x"); + if($dup>0){ echo " [FAIL] whistleblowing_reports: $dup token duplicati\n"; $failed++; } + else { + if(idxExists($pdo,$db,'whistleblowing_reports','idx_token')) + run($pdo,$DRY,'whistleblowing_reports DROP INDEX idx_token (ridondante)', + "ALTER TABLE whistleblowing_reports DROP INDEX idx_token"); + run($pdo,$DRY,'whistleblowing_reports ADD UNIQUE uq_wb_anon_token(anonymous_token)', + "ALTER TABLE whistleblowing_reports ADD UNIQUE KEY uq_wb_anon_token (anonymous_token)"); + } +} else { echo " [SKIP] whistleblowing_reports.uq_wb_anon_token già presente\n"; $skipped++; } + +/* ───────── TIER 1: FOREIGN KEY mancanti (pre-check orfani) ───────── */ +echo "\n── TIER 1: FOREIGN KEY ──\n"; +// [constraint, tabella, colonna, ref_table, on_delete] +$fks = [ + ['fk_org_consulting_firm','organizations','consulting_firm_id','consulting_firms','SET NULL'], + ['fk_users_consulting_firm','users','consulting_firm_id','consulting_firms','SET NULL'], + ['fk_kbdoc_consulting_firm','kb_uploaded_documents','consulting_firm_id','consulting_firms','SET NULL'], + ['fk_isms_soa_control','isms_soa','linked_control_id','compliance_controls','SET NULL'], + ['fk_isms_doc_policy','isms_documents','linked_policy_id','policies','SET NULL'], + ['fk_mrd_capa','management_review_decisions','capa_id','capa_actions','SET NULL'], +]; +foreach($fks as [$name,$t,$c,$ref,$od]){ + if(!tblExists($pdo,$db,$t) || !tblExists($pdo,$db,$ref)){ echo " [SKIP] $name (tabella assente)\n"; $skipped++; continue; } + if(fkExists($pdo,$db,$name)){ echo " [SKIP] $name già presente\n"; $skipped++; continue; } + $orf=(int)col1($pdo,"SELECT COUNT(*) FROM `$t` x WHERE x.`$c` IS NOT NULL AND x.`$c`<>0 AND NOT EXISTS(SELECT 1 FROM `$ref` r WHERE r.id=x.`$c`)"); + if($orf>0){ echo " [FAIL] $name: $orf righe orfane su $t.$c → bonificare prima\n"; $failed++; continue; } + run($pdo,$DRY,"$t ADD FK $name($c)->$ref ON DELETE $od", + "ALTER TABLE `$t` ADD CONSTRAINT `$name` FOREIGN KEY (`$c`) REFERENCES `$ref`(id) ON DELETE $od ON UPDATE CASCADE"); +} + +/* ───────── TIER 2: bonifica righe stale + FK (solo con --with-cleanup) ───────── */ +echo "\n── TIER 2: cleanup orfani (".($CLEANUP?'ATTIVO':'saltato, usa --with-cleanup').") ──\n"; +if($CLEANUP){ + // C1 firm_org_assignments: 9 righe verso org 126-129 inesistenti (dogfooding, deferred-b 039) + $n=(int)col1($pdo,"SELECT COUNT(*) FROM firm_org_assignments x WHERE NOT EXISTS(SELECT 1 FROM organizations o WHERE o.id=x.organization_id)"); + if($n>0){ + if($DRY) echo " [DRY] DELETE $n righe stale firm_org_assignments (org inesistente)\n"; + else { $pdo->exec("DELETE FROM firm_org_assignments WHERE organization_id NOT IN (SELECT id FROM organizations)"); echo " [OK ] cancellate $n righe stale firm_org_assignments\n"; $applied++; } + } else echo " [SKIP] firm_org_assignments: 0 orfani\n"; + if(!fkExists($pdo,$db,'fk_firm_org_assignments_organization_id')) + run($pdo,$DRY,'firm_org_assignments ADD FK organization_id->organizations CASCADE', + "ALTER TABLE firm_org_assignments ADD CONSTRAINT fk_firm_org_assignments_organization_id FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE ON UPDATE CASCADE"); + else { echo " [SKIP] fk_firm_org_assignments_organization_id già presente\n"; $skipped++; } + + // C2 active_sessions: 7 sessioni verso org inesistenti + $n2=(int)col1($pdo,"SELECT COUNT(*) FROM active_sessions x WHERE x.organization_id IS NOT NULL AND x.organization_id<>0 AND NOT EXISTS(SELECT 1 FROM organizations o WHERE o.id=x.organization_id)"); + if($n2>0){ + if($DRY) echo " [DRY] DELETE $n2 sessioni stale active_sessions\n"; + else { $pdo->exec("DELETE FROM active_sessions WHERE organization_id IS NOT NULL AND organization_id<>0 AND organization_id NOT IN (SELECT id FROM organizations)"); echo " [OK ] cancellate $n2 sessioni stale active_sessions\n"; $applied++; } + } else echo " [SKIP] active_sessions: 0 orfani\n"; + if(!fkExists($pdo,$db,'fk_active_sessions_organization_id')) + run($pdo,$DRY,'active_sessions ADD FK organization_id->organizations CASCADE', + "ALTER TABLE active_sessions ADD CONSTRAINT fk_active_sessions_organization_id FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE ON UPDATE CASCADE"); + else { echo " [SKIP] fk_active_sessions_organization_id già presente\n"; $skipped++; } +} + +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); diff --git a/application/controllers/InternalAuditController.php b/application/controllers/InternalAuditController.php index 617846a..04fc927 100644 --- a/application/controllers/InternalAuditController.php +++ b/application/controllers/InternalAuditController.php @@ -140,10 +140,11 @@ class InternalAuditController extends BaseController $leadUserId = $this->validateUser($b['lead_auditor_user_id'] ?? null, $orgId); $leadRoleId = $this->validateRole($b['lead_auditor_role_id'] ?? null, $orgId); - $code = $this->generateAuditCode($orgId); - $id = (int) Database::insert('internal_audits', [ + // Retry-on-duplicate: generateAuditCode() è check-poi-insert non atomico (race su + // create concorrenti); la UNIQUE (organization_id, code) [mig.058] blocca i doppioni + // e qui si rigenera il codice. + $base = [ 'organization_id' => $orgId, - 'code' => $code, 'title' => $title, 'scope' => $this->nullableStr($b['scope'] ?? null), 'criteria' => $this->nullableStr($b['criteria'] ?? null), @@ -154,7 +155,18 @@ class InternalAuditController extends BaseController 'lead_auditor_role_id' => $leadRoleId, 'conclusion' => $this->nullableStr($b['conclusion'] ?? null), 'created_by' => $this->getCurrentUserId(), - ]); + ]; + $code = null; $id = 0; + for ($attempt = 0; ; $attempt++) { + $code = $this->generateAuditCode($orgId); + try { + $id = (int) Database::insert('internal_audits', ['code' => $code] + $base); + break; + } catch (\PDOException $e) { + if (($e->errorInfo[1] ?? 0) === 1062 && $attempt < 4) { continue; } + throw $e; + } + } $seeded = $this->seedChecklist($id, $orgId); $this->upsertCalendar($id, $orgId, $code, $title, $planned); diff --git a/application/controllers/ManagementReviewController.php b/application/controllers/ManagementReviewController.php index 96d76c5..317bcd7 100644 --- a/application/controllers/ManagementReviewController.php +++ b/application/controllers/ManagementReviewController.php @@ -111,11 +111,11 @@ class ManagementReviewController extends BaseController $conclusions = $this->nullableStr($b['conclusions'] ?? null); $year = $reviewDate ? (int) substr($reviewDate, 0, 4) : (int) date('Y'); - $code = $this->nextCode($orgId, $year); - $id = Database::insert('management_reviews', [ + // Retry-on-duplicate: il codice RD-AAAA-NN è MAX+1 (race su create concorrenti); + // la UNIQUE (organization_id, code) [mig.058] blocca i doppioni e qui si rigenera. + $base = [ 'organization_id' => $orgId, - 'code' => $code, 'review_date' => $reviewDate, 'period_label' => $period, 'chair_user_id' => $chairId, @@ -123,7 +123,18 @@ class ManagementReviewController extends BaseController 'status' => 'draft', 'conclusions' => $conclusions, 'created_by' => $this->getCurrentUserId(), - ]); + ]; + $code = null; $id = 0; + for ($attempt = 0; ; $attempt++) { + $code = $this->nextCode($orgId, $year); + try { + $id = (int) Database::insert('management_reviews', ['code' => $code] + $base); + break; + } catch (\PDOException $e) { + if (($e->errorInfo[1] ?? 0) === 1062 && $attempt < 4) { continue; } + throw $e; + } + } $this->logAudit('mgmt_review_created', 'management_review', (int) $id, ['code' => $code]); $this->jsonSuccess(['id' => (int) $id, 'code' => $code], 'Riesame creato', 201); } diff --git a/docs/CONTEXT_LAST_SESSION.md b/docs/CONTEXT_LAST_SESSION.md index 8c591c6..ed72ab5 100644 --- a/docs/CONTEXT_LAST_SESSION.md +++ b/docs/CONTEXT_LAST_SESSION.md @@ -2,6 +2,23 @@ > Il 2026-05-29 ci sono state DUE sessioni: **pomeriggio** e **mattina** (TRPG). Il 2026-05-30 sessione lunga: gap competitivi P1/P2/P3 + connettori + review multi-agente + fix. +## 2026-06-18 — 🟢 Hardening integrità DB (mig.058) — v1.23.1 + +Richiesta utente: *"analizzare DB e chiavi per garantire integrità db"*. Esteso l'audit chiavi della **mig.039** (12/6, fermo a ~tab.039) ai **moduli 040-057** non ancora coperti. Memoria: [[project_db_integrity]]. + +**Stato base sano**: 103 tabelle, tutte con PK, tutte InnoDB. Sonda live read-only nuova: `application/cli/db_integrity_probe.php` (PK/engine/UNIQUE/FK/orfani + pre-check duplicati `(org,code)`; etichetta `audit_logs` come **by-design**, non bloccante). + +**mig.058 applicata (runner guardato `application/cli/migrate_058_integrity_keys.php`, idempotente, 15/15 OK, re-run = 12 SKIP)**: +- **TIER 1 additivo** (0 dup/0 orfani verificati): +4 UNIQUE [`internal_audits`/`management_reviews` `(org,code)`, `kb_uploaded_documents.qdrant_doc_uuid`, `whistleblowing_reports.anonymous_token` (drop `idx_token` ridondante)] + retry-on-1062 nei controller `InternalAudit`/`ManagementReview` (allineati a `periodic_controls`); +6 FK [`consulting_firm_id` su `organizations`/`users`/`kb_uploaded_documents`→`consulting_firms`; `isms_soa.linked_control_id`→`compliance_controls`; `isms_documents.linked_policy_id`→`policies`; `management_review_decisions.capa_id`→`capa_actions`, tutte SET NULL]. +- **TIER 2 bonifica** (confermata dall'utente, backup pre-DELETE in `.backups/mig058_pre_cleanup_20260618-075418.sql`): cancellate **9** righe stale `firm_org_assignments`→org 126-129 (eliminate nella pulizia demo 12/6; chiude il *deferred-b* mig.039) + **7** sessioni scadute `active_sessions`→org inesistenti, poi 2 FK CASCADE. +- **FK totali 196 → 204**. Verdetto sonda post-fix: **0 problemi bloccanti** (resta solo `audit_logs` by-design: trail immutabile mig.006). Smoke prod: api-status 200, controller list 401 (caricano OK post-reload). + +**By-design lasciato**: `audit_logs` (no FK, immutabile), sentinel `supplier_categories.org=0` (deferred-a mig.039), colonne polimorfiche (`object_id`/`entity_id`/...) e cross-DB (`sso_identity_id`/`lg231_*`). + +**Deploy**: reload opcache `systemctl reload php8.4-fpm` (PROD=host) + USR2 `nis2-app`. version.json → **1.23.1**. **Prossima mig = 059.** Pulizia: rimossi 4 file `* copy.html` residui in `docs/nis2/`. + +--- + ## 2026-06-17 (seguito) — 🟢 Modulo CONTROLLI PERIODICI (4° modulo ISO-readiness, §9.1/A.8.16) — v1.23.0, mig.057, commit `4fdeae7` Richiesta utente: *"oltre al modulo audit un modulo controlli con frequenza e generazione nc o ac"* + *"fai analisi anche di questo modulo / documentati con altri esempi"* → analisi CCM (Continuous Control Monitoring, pattern Eramba/Drata/Vanta) in `docs/DESIGN_CONTROLLI_PERIODICI.md`. Memoria: [[project-iso-readiness-modules]] (punto D). diff --git a/docs/OPEN_TICKETS.md b/docs/OPEN_TICKETS.md index d16ec5c..61336e5 100644 --- a/docs/OPEN_TICKETS.md +++ b/docs/OPEN_TICKETS.md @@ -3,4 +3,4 @@ Nessun ticket aperto. --- -_Ultimo sync: 2026-06-17 12:10:01_ +_Ultimo sync: 2026-06-18 07:55:01_ diff --git a/docs/sql/058_integrity_keys.sql b/docs/sql/058_integrity_keys.sql new file mode 100644 index 0000000..918863a --- /dev/null +++ b/docs/sql/058_integrity_keys.sql @@ -0,0 +1,64 @@ +-- ===================================================================== +-- 058_integrity_keys.sql +-- Hardening integrità chiavi — estende l'audit della mig.039 alle tabelle +-- aggiunte dopo (moduli 040-057: stakeholder, ISO-readiness, KB, ...). +-- +-- DOC-RECORD del DDL applicato dal runner idempotente GUARDATO: +-- application/cli/migrate_058_integrity_keys.php +-- (pre-check duplicati/orfani su information_schema + dato reale, try/catch +-- su 1061/1826/1062). NON eseguire ciecamente questo .sql su un DB migrato. +-- +-- Verificato sul dato reale (db_integrity_probe.php, 2026-06-18, nis2_agile_db): +-- 103 tabelle, tutte con PK, tutte InnoDB, 196 FK pre-esistenti. +-- 0 duplicati e 0 orfani su tutte le voci TIER 1 (sotto). +-- ===================================================================== + +-- ── TIER 1: UNIQUE su chiavi naturali (additivo, nessuna modifica dato) ── +-- Codici progressivi per-org (race su create concorrenti, ora con retry-on-1062 +-- nei rispettivi controller). periodic_controls aveva già uk_pctl_code (mig.057). +ALTER TABLE internal_audits ADD UNIQUE KEY uk_intaud_code (organization_id, code); +ALTER TABLE management_reviews ADD UNIQUE KEY uk_mgr_code (organization_id, code); + +-- 1:1 con il documento vettoriale in Qdrant (16 righe, 0 dup). +ALTER TABLE kb_uploaded_documents ADD UNIQUE KEY uk_kbdoc_qdrant (qdrant_doc_uuid); + +-- Token di tracking segnalazione anonima = chiave di retrieval (WHERE token=?). +-- Sostituisce l'indice non-unique idx_token ridondante. +ALTER TABLE whistleblowing_reports DROP INDEX idx_token; +ALTER TABLE whistleblowing_reports ADD UNIQUE KEY uq_wb_anon_token (anonymous_token); + +-- ── TIER 1: FOREIGN KEY mancanti (stesso-DB, 0 orfani verificati) ── +-- consulting_firm_id: la mig.039 aggiunse solo la FK su firm_org_assignments. +ALTER TABLE organizations ADD CONSTRAINT fk_org_consulting_firm FOREIGN KEY (consulting_firm_id) REFERENCES consulting_firms(id) ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE users ADD CONSTRAINT fk_users_consulting_firm FOREIGN KEY (consulting_firm_id) REFERENCES consulting_firms(id) ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE kb_uploaded_documents ADD CONSTRAINT fk_kbdoc_consulting_firm FOREIGN KEY (consulting_firm_id) REFERENCES consulting_firms(id) ON DELETE SET NULL ON UPDATE CASCADE; +-- link nullable verso entità dello stesso DB (SET NULL coerente con lo schema). +ALTER TABLE isms_soa ADD CONSTRAINT fk_isms_soa_control FOREIGN KEY (linked_control_id) REFERENCES compliance_controls(id) ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE isms_documents ADD CONSTRAINT fk_isms_doc_policy FOREIGN KEY (linked_policy_id) REFERENCES policies(id) ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE management_review_decisions ADD CONSTRAINT fk_mrd_capa FOREIGN KEY (capa_id) REFERENCES capa_actions(id) ON DELETE SET NULL ON UPDATE CASCADE; + +-- ===================================================================== +-- TIER 2 — bonifica righe STALE + FK (richiede --with-cleanup nel runner; +-- CANCELLA righe orfane → conferma esplicita necessaria) +-- +-- C1) firm_org_assignments: 9 righe (consulting_firm 1 "Agile", dogfooding, +-- create 2026-05-29) verso org 126/127/128/129 ELIMINATE nella pulizia +-- demo del 2026-06-12. Risolve il "deferred-b" della mig.039. +-- DELETE FROM firm_org_assignments WHERE organization_id NOT IN (SELECT id FROM organizations); +-- ALTER TABLE firm_org_assignments ADD CONSTRAINT fk_firm_org_assignments_organization_id +-- FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE ON UPDATE CASCADE; +-- +-- C2) active_sessions: 7 sessioni verso org inesistenti (effimere). +-- DELETE FROM active_sessions WHERE organization_id<>0 AND organization_id NOT IN (SELECT id FROM organizations); +-- ALTER TABLE active_sessions ADD CONSTRAINT fk_active_sessions_organization_id +-- FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE ON UPDATE CASCADE; +-- ===================================================================== + +-- ── NON TOCCATE — pattern "by design" (documentato, NESSUNA azione) ── +-- • audit_logs.organization_id: 1996 "orfani" + 408 org_id=0 → audit trail +-- IMMUTABILE (trigger mig.006) che DEVE sopravvivere alla cancellazione org. +-- Nessuna FK per scelta architetturale. +-- • supplier_categories.organization_id = 0: sentinel "categoria di sistema" +-- (deferred-a mig.039). +-- • Colonne *_id polimorfiche (object_id, entity_id, source_entity_id, ...) e +-- cross-DB (sso_identity_id, lg231_company_id/order_id): nessuna FK possibile. diff --git a/public/version.json b/public/version.json index 1c5456c..2933218 100644 --- a/public/version.json +++ b/public/version.json @@ -1 +1 @@ -{"version": "1.23.0", "build": "2026-06-17-v1.23.0", "date": "2026-06-17", "changelog": "Modulo Controlli periodici (control monitoring ISO 27001 §9.1/A.8.16): registro controlli ricorrenti (owner, frequenza, metodo, controllo SoA/NIS2 collegato) + registro esecuzioni come EVIDENZA (data/esito/note) con avanzamento automatico della scadenza; da esito non conforme generazione NC o NC+azione correttiva (riuso NCR/CAPA, nuova source 'monitoring'); report scheda+storico stampabile; scadenze nel Calendario unico. mig.057: periodic_controls + periodic_control_executions. PeriodicControlController + controlli-periodici.html. Additivo."} +{"version": "1.23.1", "build": "2026-06-18-v1.23.1", "date": "2026-06-18", "changelog": "Hardening integrità DB (mig.058, estende l'audit mig.039 ai moduli 040-057): +4 UNIQUE su chiavi naturali (internal_audits/management_reviews codice per-org, kb_uploaded_documents.qdrant_doc_uuid, whistleblowing_reports.anonymous_token) + retry-on-1062 nei controller Audit interni/Riesame Direzione; +6 FK mancanti (consulting_firm_id su organizations/users/kb, isms_soa.linked_control_id, isms_documents.linked_policy_id, management_review_decisions.capa_id); bonifica 16 righe stale (firm_org_assignments verso org 126-129 eliminate + active_sessions scadute) + 2 FK CASCADE. FK totali 196->204. Sonda diagnostica db_integrity_probe.php. Nessun impatto funzionale per l'utente."}