[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 ?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;
}