[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) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-15 08:10:26 +02:00
co-authored by Claude Opus 4.8
parent eeeb014c49
commit d50955d95a
+168
View File
@@ -0,0 +1,168 @@
<?php
/**
* provision_testers.php — Client CLI sicuro per provisioning account tester/admin NIS2.
*
* DEVE girare DENTRO nis2-app (unico container che raggiunge nis2-db via TLS):
* ssh root@172.18.0.1 "docker exec [-e PROV_PASSWORD=...] nis2-app \
* php /var/www/nis2-agile/application/cli/provision_testers.php <args>"
*
* 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";