From d50955d95a52d2ba5ac5d667a6c76141ff30a8d0 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Mon, 15 Jun 2026 08:10:26 +0200 Subject: [PATCH] [FEAT] Client CLI sicuro provisioning tester/admin (dry-run default, --expect guard, idempotente, SSO-sync-safe) Co-Authored-By: Claude Opus 4.8 (1M context) --- application/cli/provision_testers.php | 168 ++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 application/cli/provision_testers.php diff --git a/application/cli/provision_testers.php b/application/cli/provision_testers.php new file mode 100644 index 0000000..7a16028 --- /dev/null +++ b/application/cli/provision_testers.php @@ -0,0 +1,168 @@ +" + * + * Sicurezza by design: + * - DRY-RUN di default: senza --apply NON scrive nulla, stampa solo il piano. + * - Target per email/cognome ESATTI (IN parametrico), mai UPDATE ciechi/LIKE. + * - --expect=N: se il match non risolve ESATTAMENTE N utenti -> ABORT (anti grant accidentale). + * - Password NON in argv: letta da env PROV_PASSWORD (non compare in `ps`). + * - password_version lasciato invariato -> la sync SSO non sovrascrive (vedi sso-password-sync.sh). + * - must_change_password=0; membership idempotente (UNIQUE user+org). + * - Tutte le scritture in UNA transazione (rollback on error). Nessun segreto hardcoded. + * + * Args: + * --match=Cognome1,Cognome2 cognomi da cercare (CSV) [opz.] + * --emails=a@b,c@d email esatte (CSV) [opz.] + * --role=super_admin users.role da impostare [opz.; se assente non tocca il ruolo] + * --orgs=151:primary,152 membership da garantire (org[:primary]) CSV [opz.] + * --set-password imposta password_hash da env PROV_PASSWORD [opz.] + * --expect=N n. atteso di utenti risolti (safety) [opz. ma consigliato] + * --apply esegue le scritture (default: dry-run) + */ + +error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE); + +/* --- prime DB_* da PID1 (creds vault-injected non presenti nell'env di docker exec) --- */ +$environ = @file_get_contents('/proc/1/environ'); +if ($environ) { + foreach (explode("\0", $environ) as $kv) { + if ($kv === '' || strpos($kv, '=') === false) continue; + [$k, $v] = explode('=', $kv, 2); + if (strncmp($k, 'DB_', 3) === 0) { putenv("$k=$v"); $_ENV[$k] = $v; $_SERVER[$k] = $v; } + } +} + +require_once '/var/www/nis2-agile/application/config/env.php'; +require_once '/var/www/nis2-agile/application/config/database.php'; + +/* --- arg parsing --- */ +$args = []; +foreach (array_slice($argv, 1) as $a) { + if (preg_match('/^--([^=]+)(?:=(.*))?$/', $a, $m)) $args[$m[1]] = $m[2] ?? true; +} +$matchNames = isset($args['match']) ? array_filter(array_map('trim', explode(',', $args['match']))) : []; +$emails = isset($args['emails']) ? array_filter(array_map('trim', explode(',', $args['emails']))) : []; +$role = $args['role'] ?? null; +$orgsRaw = isset($args['orgs']) ? array_filter(array_map('trim', explode(',', $args['orgs']))) : []; +$setPw = isset($args['set-password']); +$expect = isset($args['expect']) ? (int)$args['expect'] : null; +$apply = isset($args['apply']); + +if (!$matchNames && !$emails) { fwrite(STDERR, "ERRORE: specifica --match e/o --emails\n"); exit(2); } + +$validRoles = ['super_admin','org_admin','compliance_manager','board_member','auditor','employee','consultant']; +if ($role !== null && !in_array($role, $validRoles, true)) { fwrite(STDERR, "ERRORE: role '$role' non valido\n"); exit(2); } + +$pw = null; +if ($setPw) { + $pw = getenv('PROV_PASSWORD') ?: ''; + if ($pw === '') { fwrite(STDERR, "ERRORE: --set-password ma env PROV_PASSWORD vuota\n"); exit(2); } +} + +/* parse orgs: ['151'=>true(primary), '152'=>false] */ +$orgs = []; +foreach ($orgsRaw as $o) { + $parts = explode(':', $o); + $orgs[(int)$parts[0]] = (isset($parts[1]) && strtolower($parts[1]) === 'primary'); +} + +echo "==================================================================\n"; +echo " provision_testers.php — " . ($apply ? "*** APPLY (scrive) ***" : "DRY-RUN (sola lettura)") . "\n"; +echo "==================================================================\n"; + +/* --- risoluzione utenti (esatti) --- */ +$conds = []; $params = []; +if ($matchNames) { + $like = []; + foreach ($matchNames as $n) { $like[] = 'full_name LIKE ?'; $params[] = '%' . $n . '%'; } + $conds[] = '(' . implode(' OR ', $like) . ')'; +} +if ($emails) { $conds[] = 'email IN (' . implode(',', array_fill(0, count($emails), '?')) . ')'; $params = array_merge($params, $emails); } +$sql = "SELECT id,email,full_name,role,is_active,sso_identity_id,password_version,must_change_password + FROM users WHERE " . implode(' OR ', $conds) . " ORDER BY id"; +$users = Database::fetchAll($sql, $params); + +if (!$users) { fwrite(STDERR, "Nessun utente risolto.\n"); exit(1); } + +echo "\nUTENTI RISOLTI (" . count($users) . "):\n"; +foreach ($users as $u) { + $mem = Database::fetchAll( + "SELECT organization_id, role, is_primary FROM user_organizations WHERE user_id=?", [$u['id']] + ); + $u['_mem'] = $mem; + echo sprintf(" #%-4d %-38s %-22s role=%-16s active=%s sso=%-5s pwver=%-3s mcp=%s orgs=%s\n", + $u['id'], $u['email'], $u['full_name'], + $u['role'], $u['is_active'] ?? '-', $u['sso_identity_id'] ?? '-', $u['password_version'] ?? '-', + $u['must_change_password'] ?? '-', + $mem ? implode(',', array_map(fn($m)=>$m['organization_id'].($m['is_primary']?'(P)':''), $mem)) : 'NESSUNA(!! onboarding)'); +} + +/* --- safety: expect --- */ +if ($expect !== null && count($users) !== $expect) { + fwrite(STDERR, "\nABORT: risolti " . count($users) . " utenti ma --expect=$expect. Niente modifiche.\n"); + exit(3); +} + +/* --- piano --- */ +echo "\nPIANO:\n"; +echo $role ? " - role -> '$role' (dove diverso)\n" : " - role: invariato\n"; +echo $setPw ? " - password_hash -> (da PROV_PASSWORD), password_version INVARIATO, must_change_password=0\n" : " - password: invariata\n"; +echo $orgs ? " - membership garantita su org: " . implode(', ', array_map(fn($k)=>$k.($orgs[$k]?':primary':''), array_keys($orgs))) . " (role org_admin, idempotente)\n" : " - membership: invariata\n"; + +if (!$apply) { echo "\n[DRY-RUN] nessuna scrittura. Aggiungi --apply per eseguire.\n"; exit(0); } + +/* --- APPLY (transazione) --- */ +$pwHash = $setPw ? password_hash($pw, PASSWORD_DEFAULT) : null; +$changes = []; +try { + Database::beginTransaction(); + foreach ($users as $u) { + $uid = (int)$u['id']; $line = []; + if ($role !== null && $u['role'] !== $role) { + Database::update('users', ['role'=>$role], 'id=?', [$uid]); $line[] = "role {$u['role']}->$role"; + } + if ((int)$u['is_active'] !== 1) { + Database::update('users', ['is_active'=>1], 'id=?', [$uid]); $line[] = "is_active 0->1"; + } + if ($setPw) { + Database::update('users', ['password_hash'=>$pwHash, 'must_change_password'=>0], 'id=?', [$uid]); $line[] = "password set (ver invariato)"; + } + foreach ($orgs as $oid => $isPrimary) { + // idempotente: UNIQUE (user_id, organization_id) + Database::query( + "INSERT INTO user_organizations (user_id, organization_id, role, is_primary) + VALUES (?,?, 'org_admin', ?) + ON DUPLICATE KEY UPDATE role=VALUES(role), is_primary=VALUES(is_primary)", + [$uid, (int)$oid, $isPrimary ? 1 : 0] + ); + $line[] = "org $oid".($isPrimary?'(P)':''); + } + $changes[$uid] = $line ? implode('; ', $line) : 'nessuna modifica (gia conforme)'; + } + Database::commit(); +} catch (\Throwable $e) { + Database::rollback(); + fwrite(STDERR, "\nROLLBACK — errore: " . $e->getMessage() . "\n"); + exit(4); +} + +echo "\nAPPLICATO (commit ok):\n"; +foreach ($changes as $uid => $c) echo " #$uid: $c\n"; + +/* --- verifica post --- */ +echo "\nVERIFICA POST:\n"; +$ids = implode(',', array_map(fn($u)=>(int)$u['id'], $users)); +$after = Database::fetchAll("SELECT id,email,role,is_active,must_change_password, + (SELECT COUNT(*) FROM user_organizations uo WHERE uo.user_id=users.id) AS org_count + FROM users WHERE id IN ($ids) ORDER BY id"); +foreach ($after as $a) { + echo sprintf(" #%-4d %-38s role=%-14s active=%s mcp=%s org_count=%d %s\n", + $a['id'], $a['email'], $a['role'], $a['is_active'], $a['must_change_password'], $a['org_count'], + $a['org_count'] > 0 ? 'OK (no onboarding)' : '!! ANCORA 0 ORG'); +} +echo "\nFatto.\n";