[DEMO] Avatar prodotto: read-only guard (BaseController) + seeder dataset demo 996001/996002

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 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-13 08:19:53 +02:00
co-authored by Claude Opus 4.8
parent 4dfab2a391
commit e04eba62c1
2 changed files with 175 additions and 0 deletions
@@ -16,6 +16,11 @@ class BaseController
protected ?int $currentOrgId = null; protected ?int $currentOrgId = null;
protected ?string $currentOrgRole = 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 // RISPOSTE JSON
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -250,8 +255,78 @@ class BaseController
/** /**
* Richiede autenticazione JWT * 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 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(); $token = $this->getBearerToken();
if (!$token) { if (!$token) {
@@ -351,6 +426,13 @@ class BaseController
{ {
$this->requireAuth(); $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(); $orgId = $this->resolveOrgId();
if (!$orgId) { if (!$orgId) {
@@ -385,6 +467,12 @@ class BaseController
{ {
$this->requireOrgAccess(); $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') { if ($this->currentOrgRole === 'super_admin') {
return; return;
} }
+87
View File
@@ -0,0 +1,87 @@
<?php
/**
* NIS2 Agile - Seed dataset DEMO isolato (Avatar di prodotto / formazione)
* ----------------------------------------------------------------------------
* Clona la golden org DataCore (#151) negli org del range demo riservato:
* - 996001 → dataset DEMO (servito read-only via demo_jwt scope=demo:read-only)
* - 996002 → SANDBOX scrivibile isolata per i "prova tu" della formazione
*
* Idempotente: cancella e ricrea i dati del range demo ad ogni run. Richiamabile
* da DemoController::resetDataset (reset dataset demo) e da TrainingSandbox.
*
* ESEGUIRE SU HETZNER (richiede DB container + SSL runtime). NB: nis2-app monta
* SOLO application/+public/ → per girare via `docker exec` copiare sotto application/:
* cp scripts/seed-demo-dataset.php application/_seed.php
* docker exec -i nis2-app php /var/www/nis2-agile/application/_seed.php
* rm application/_seed.php
* Opzioni: --only=996001 (un solo org)
* ============================================================================
*/
if (PHP_SAPI !== 'cli') { fwrite(STDERR, "Solo CLI\n"); exit(1); }
// env reale del processo FPM (vault) se in docker exec; altrimenti .env via env.php
$e=@file_get_contents('/proc/1/environ');
if($e) foreach(explode("\0",$e) as $p){ if(strpos($p,'=')!==false){ putenv($p);[$k,$v]=explode('=',$p,2);$_ENV[$k]=$v;$_SERVER[$k]=$v; } }
if(!defined('BASE_PATH')) define('BASE_PATH', dirname(__DIR__));
if(!defined('APP_PATH')) define('APP_PATH', BASE_PATH.'/application');
require_once APP_PATH.'/config/env.php'; require_once APP_PATH.'/config/config.php'; require_once APP_PATH.'/config/database.php';
$pdo=Database::getInstance();
$opts=getopt('',['only::']);
$SRC=151;
$ALL=[996001=>'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";