From e04eba62c1190442bf43304c28eb104773a5f19c Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Sat, 13 Jun 2026 08:19:53 +0200 Subject: [PATCH] [DEMO] Avatar prodotto: read-only guard (BaseController) + seeder dataset demo 996001/996002 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increment backend formazione-first (product-demo-protocol v1.0.1): - BaseController::applyDemoGuard() — SAFE-BY-CONSTRUCTION (flusso auth normale invariato): riconosce demo_jwt scope=demo:read-only (blocca scritture 403 DEMO_READ_ONLY) e training:sandbox (scritture solo su org sandbox); contesto sintetico user=0 ruolo compliance_manager su org range 996xxx; mai super_admin. Short-circuit in requireAuth/ requireOrgAccess/requireOrgRole. php -l OK. DA DEPLOYARE (USR2) + testare quando host disponibile. - scripts/seed-demo-dataset.php — clona golden DataCore #151 in 996001 (demo RO) + 996002 (sandbox scrivibile), idempotente, richiamabile da resetDataset. php -l OK. DA ESEGUIRE su host. NB: chiave ssh host revocata a meta-sessione → seed/USR2/push in attesa di ri-provisioning. Co-Authored-By: Claude Opus 4.8 --- application/controllers/BaseController.php | 88 ++++++++++++++++++++++ scripts/seed-demo-dataset.php | 87 +++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 scripts/seed-demo-dataset.php diff --git a/application/controllers/BaseController.php b/application/controllers/BaseController.php index 1ff9ada..7443183 100644 --- a/application/controllers/BaseController.php +++ b/application/controllers/BaseController.php @@ -16,6 +16,11 @@ class BaseController protected ?int $currentOrgId = null; protected ?string $currentOrgRole = null; + // ── Contesto Avatar di prodotto (demo/sandbox) — vedi applyDemoGuard() ── + protected bool $isDemo = false; // sessione demo attiva (read-only o sandbox) + protected bool $isSandbox = false; // scope training:sandbox (scritture su org sandbox) + protected ?int $demoOrgId = null; // org del range riservato demo (996000-996999) + // ═══════════════════════════════════════════════════════════════════════ // RISPOSTE JSON // ═══════════════════════════════════════════════════════════════════════ @@ -250,8 +255,78 @@ class BaseController /** * Richiede autenticazione JWT */ + /** + * Read-only guard Avatar di prodotto (product-demo-protocol v1.0.1). + * + * SAFE-BY-CONSTRUCTION: ritorna false (e NON tocca nulla) per qualsiasi JWT + * applicativo normale. Si attiva SOLO con un demo_jwt firmato che porta + * scope=demo:read-only oppure training:sandbox, validato contro demo_sessions. + * + * - demo:read-only → blocca OGNI scrittura (POST/PUT/PATCH/DELETE) con 403. + * - training:sandbox → consente scritture, ma SOLO sull'org sandbox della sessione. + * - Contesto sintetico: user id=0, ruolo compliance_manager, org = org del range + * demo riservato (996000-996999). Mai super_admin → admin resta irraggiungibile. + * + * @return bool true se ha gestito un contesto demo (il chiamante deve return). + */ + protected function applyDemoGuard(): bool + { + $token = $this->getBearerToken(); + if (!$token) return false; + + $payload = $this->verifyJWT($token); + if (!$payload) return false; + + $scope = $payload['scope'] ?? ''; + if ($scope !== 'demo:read-only' && $scope !== 'training:sandbox') { + return false; // JWT applicativo normale → flusso invariato + } + + $orgId = (int) ($payload['org_id'] ?? 0); + if ($orgId < 996000 || $orgId > 996999) { + $this->jsonError('Contesto demo non valido', 401, 'DEMO_CTX_INVALID'); + } + + $sid = (string) ($payload['demo_session_id'] ?? ''); + $sess = $sid !== '' ? Database::fetchOne('SELECT * FROM demo_sessions WHERE session_id = ?', [$sid]) : null; + if (!$sess || strtotime($sess['expires_at']) < time()) { + $this->jsonError('Sessione demo non valida o scaduta', 401, 'DEMO_SESSION_INVALID'); + } + + $isWrite = in_array($this->getMethod(), ['POST', 'PUT', 'PATCH', 'DELETE'], true); + if ($scope === 'demo:read-only' && $isWrite) { + $this->jsonError('Modalità demo in sola lettura: azione non disponibile', 403, 'DEMO_READ_ONLY'); + } + if ($scope === 'training:sandbox' && $isWrite) { + // Scritture consentite SOLO sull'org sandbox della sessione (anti-spoof X-Organization-Id). + $reqOrgRaw = $_SERVER['HTTP_X_ORGANIZATION_ID'] ?? $this->getParam('org_id'); + $reqOrg = ($reqOrgRaw !== null && $reqOrgRaw !== '') ? (int) $reqOrgRaw : null; + if ($reqOrg !== null && $reqOrg !== $orgId) { + $this->jsonError('Sandbox: scritture consentite solo sull\'organizzazione sandbox', 403, 'SANDBOX_ORG_LOCKED'); + } + } + + // Contesto sintetico read (o sandbox). Nessun accesso al DB utenti reali. + $this->isDemo = true; + $this->isSandbox = ($scope === 'training:sandbox'); + $this->demoOrgId = $orgId; + $this->currentUser = [ + 'id' => 0, 'email' => 'demo@nis2-demo.local', + 'full_name' => 'Avatar Demo', 'role' => 'compliance_manager', + 'consulting_firm_id' => null, + ]; + $this->currentOrgId = $orgId; + $this->currentOrgRole = 'compliance_manager'; + return true; + } + protected function requireAuth(): void { + // Avatar di prodotto: se è un demo_jwt valido, applica il guard e termina qui. + if ($this->applyDemoGuard()) { + return; + } + $token = $this->getBearerToken(); if (!$token) { @@ -351,6 +426,13 @@ class BaseController { $this->requireAuth(); + // Avatar di prodotto: contesto org già fissato dal guard sull'org demo/sandbox. + if ($this->isDemo) { + $this->currentOrgId = $this->demoOrgId; + $this->currentOrgRole = 'compliance_manager'; + return; + } + $orgId = $this->resolveOrgId(); if (!$orgId) { @@ -385,6 +467,12 @@ class BaseController { $this->requireOrgAccess(); + // Avatar di prodotto: le scritture sono già filtrate dal guard (demo=block, + // sandbox=solo org sandbox). Le letture passano i gate di ruolo. + if ($this->isDemo) { + return; + } + if ($this->currentOrgRole === 'super_admin') { return; } diff --git a/scripts/seed-demo-dataset.php b/scripts/seed-demo-dataset.php new file mode 100644 index 0000000..8bbee49 --- /dev/null +++ b/scripts/seed-demo-dataset.php @@ -0,0 +1,87 @@ +'DataCore Demo S.r.l.', 996002=>'NIS2 Sandbox Formazione S.r.l.']; +$TARGETS = isset($opts['only']) ? array_intersect_key($ALL,[(int)$opts['only']=>1]) : $ALL; + +$USERCOLS=['owner_user_id','responsible_user_id','assigned_to','reported_by','created_by','uploaded_by','relevance_assessed_by','assessed_by','approved_by','reviewed_by','closed_by','user_id']; +function cols($pdo,$t){ return $pdo->query("SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='$t' ORDER BY ORDINAL_POSITION")->fetchAll(PDO::FETCH_COLUMN); } +function tableExists($pdo,$t){ return (int)$pdo->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='$t'")->fetchColumn()>0; } +function copyRow($pdo,$table,$row,$overrides,$uid,$USERCOLS){ + static $cc=[]; if(!isset($cc[$table])) $cc[$table]=cols($pdo,$table); + $data=[]; + foreach($cc[$table] as $c){ + if($c==='id'){ if(array_key_exists('id',$overrides)) $data['id']=$overrides['id']; continue; } + if(array_key_exists($c,$overrides)){ $data[$c]=$overrides[$c]; continue; } + $v=$row[$c]??null; if(in_array($c,$USERCOLS,true)&&$v!==null) $v=$uid; $data[$c]=$v; + } + $cl=implode(',',array_map(fn($x)=>"`$x`",array_keys($data))); $ph=implode(',',array_fill(0,count($data),'?')); + $pdo->prepare("INSERT INTO `$table` ($cl) VALUES ($ph)")->execute(array_values($data)); + return (int)$pdo->lastInsertId(); +} + +$PC=[['assessments','assessment_responses','assessment_id'],['risks','risk_treatments','risk_id'],['incidents','incident_timeline','incident_id'],['training_courses','training_assignments','course_id'],['policies','policy_versions','policy_id']]; +$SIMPLE=['compliance_controls','suppliers','assets']; + +$pdo->exec("SET FOREIGN_KEY_CHECKS=0"); +foreach($TARGETS as $T=>$NAME){ + $pdo->beginTransaction(); + try { + foreach($PC as [$par,$chi,$fk]){ if(tableExists($pdo,$chi)) $pdo->exec("DELETE FROM `$chi` WHERE `$fk` IN (SELECT id FROM `$par` WHERE organization_id=$T)"); } + foreach(array_merge(array_column($PC,0),$SIMPLE) as $t){ if(tableExists($pdo,$t)) $pdo->exec("DELETE FROM `$t` WHERE organization_id=$T"); } + $pdo->exec("DELETE FROM user_organizations WHERE organization_id=$T"); + $pdo->exec("DELETE FROM users WHERE email='demo-$T@nis2-demo.local'"); + $pdo->exec("DELETE FROM organizations WHERE id=$T"); + + $org=Database::fetchOne("SELECT * FROM organizations WHERE id=?",[$SRC]); + copyRow($pdo,'organizations',$org,['id'=>$T,'name'=>$NAME],0,$USERCOLS); + $uid=(int)copyRow($pdo,'users',['email'=>"demo-$T@nis2-demo.local",'full_name'=>"Demo NIS2 $T",'role'=>'org_admin','password_hash'=>password_hash(bin2hex(random_bytes(12)),PASSWORD_DEFAULT),'is_active'=>1],['email'=>"demo-$T@nis2-demo.local"],0,$USERCOLS); + $pdo->prepare("INSERT INTO user_organizations (user_id,organization_id,role) VALUES (?,?,'org_admin')")->execute([$uid,$T]); + + foreach($PC as [$par,$chi,$fk]){ + if(!tableExists($pdo,$par)) continue; + foreach(Database::fetchAll("SELECT * FROM `$par` WHERE organization_id=? ORDER BY id",[$SRC]) as $r){ + $newPar=copyRow($pdo,$par,$r,['organization_id'=>$T],$uid,$USERCOLS); + if(tableExists($pdo,$chi)) foreach(Database::fetchAll("SELECT * FROM `$chi` WHERE `$fk`=? ORDER BY id",[$r['id']]) as $cr){ + $ov=[$fk=>$newPar]; if(in_array('organization_id',cols($pdo,$chi),true)) $ov['organization_id']=$T; + copyRow($pdo,$chi,$cr,$ov,$uid,$USERCOLS); + } + } + } + foreach($SIMPLE as $t){ if(!tableExists($pdo,$t)) continue; + foreach(Database::fetchAll("SELECT * FROM `$t` WHERE organization_id=? ORDER BY id",[$SRC]) as $r) copyRow($pdo,$t,$r,['organization_id'=>$T],$uid,$USERCOLS); + } + $pdo->commit(); + echo "ORG $T ($NAME): seed OK (uid demo=$uid)\n"; + } catch(Throwable $ex){ $pdo->rollBack(); echo "ORG $T ERRORE ROLLBACK: ".$ex->getMessage()."\n"; } +} +$pdo->exec("SET FOREIGN_KEY_CHECKS=1"); +echo "Seed dataset demo completato (".implode(',',array_keys($TARGETS)).").\n";