nis2-agile/application/controllers/InviteController.php
DevEnv nis2-agile 9ccf2a72b5 [FIX] Database::execute() → Database::query() in 5 controller
Database non ha metodo execute() — corretto in:
InviteController, ServicesController, WebhookController,
NormativeController, WhistleblowingController.
Causa del HTTP 500 su tutti gli endpoint /api/invites/*.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 16:49:58 +01:00

441 lines
20 KiB
PHP

<?php
/**
* NIS2 Agile — Invite Controller
*
* Sistema inviti / licenze a tempo per provisioning automatico B2B.
* Gli inviti vengono generati da NIS2 Admin (o via API admin),
* distribuiti dall'e-commerce, usati da lg231 e altri sistemi Agile.
*
* Endpoints admin (richiedono JWT super_admin OPPURE API Key con scope admin:licenses):
* POST /api/invites/create → genera invito
* GET /api/invites/list → lista inviti
* GET /api/invites/{id} → dettaglio invito
* DELETE /api/invites/{id} → revoca invito
* POST /api/invites/{id}/regenerate → rigenera token mantenendo config
*
* Endpoints pubblici (nessuna auth):
* GET /api/invites/validate?token= → valida invito (preview piano, scadenza)
*
* Auth da sistemi esterni (es. mktg-agile):
* Header: X-API-Key: nis2_xxxx...
* Scope richiesto: admin:licenses (oppure read:all per sola lettura)
* Genera la chiave in: NIS2 Agile → Settings → API Keys
*/
require_once __DIR__ . '/BaseController.php';
class InviteController extends BaseController
{
// Prefisso token invito
private const TOKEN_PREFIX = 'inv_';
// Lunghezza parte random del token (bytes → hex)
private const TOKEN_BYTES = 24;
// ══════════════════════════════════════════════════════════════════════
// ADMIN — Generazione e gestione inviti
// ══════════════════════════════════════════════════════════════════════
/**
* POST /api/invites
* Genera uno o più inviti. Solo super_admin.
*
* Body:
* {
* "plan": "professional", // essentials/professional/enterprise
* "duration_months": 12, // durata licenza attivata dall'invito
* "invite_expires_days": 30, // giorni di validità dell'invito stesso (default 30)
* "max_uses": 1, // 1=one-shot, N=multi-use (es. batch lg231)
* "quantity": 1, // genera N inviti in batch (default 1)
* "label": "lg231 Partner Q1 2026", // etichetta descrittiva
* "channel": "lg231", // 'lg231'|'ecommerce'|'direct'|'manual'
* "issued_to": "partner@lg231.it", // destinatario (facoltativo)
* "restrict_vat": "02345678901", // limita a P.IVA specifica (facoltativo)
* "restrict_email": "admin@co.it", // limita a email admin specifica (facoltativo)
* "notes": "Ordine OPP-2026-042" // note interne
* }
*
* Response: { invites: [ {id, token, token_prefix, plan, expires_at, ...} ] }
*/
public function create(): void
{
$this->requireLicenseAuth(writeRequired: true);
$body = $this->getBody();
$plan = in_array($body['plan'] ?? '', ['essentials','professional','enterprise'])
? $body['plan'] : 'professional';
$durationMonths = max(1, min(60, (int)($body['duration_months'] ?? 12)));
$expiresDays = max(1, min(365, (int)($body['invite_expires_days'] ?? 30)));
$maxUses = max(1, min(100, (int)($body['max_uses'] ?? 1)));
$quantity = max(1, min(50, (int)($body['quantity'] ?? 1)));
$label = substr(trim($body['label'] ?? ''), 0, 255) ?: null;
$channel = substr(trim($body['channel'] ?? 'manual'), 0, 64);
$issuedTo = substr(trim($body['issued_to'] ?? ''), 0, 255) ?: null;
$restrictVat = preg_replace('/[^0-9]/', '', $body['restrict_vat'] ?? '');
$restrictEmail = filter_var($body['restrict_email'] ?? '', FILTER_VALIDATE_EMAIL) ?: null;
$notes = trim($body['notes'] ?? '') ?: null;
$maxUsersPerOrg = isset($body['max_users_per_org']) ? max(1, min(9999, (int)$body['max_users_per_org'])) : null;
$priceEur = isset($body['price_eur']) ? round((float)$body['price_eur'], 2) : null;
$resellerName = substr(trim($body['reseller_name'] ?? ''), 0, 128) ?: null;
$issuedBy = $this->getCurrentUserId();
$expiresAt = date('Y-m-d H:i:s', strtotime("+{$expiresDays} days"));
$created = [];
for ($i = 0; $i < $quantity; $i++) {
$rawToken = self::TOKEN_PREFIX . bin2hex(random_bytes(self::TOKEN_BYTES));
$tokenHash = hash('sha256', $rawToken);
$prefix = substr($rawToken, 0, 10) . '...';
Database::query(
'INSERT INTO invites
(token_prefix, token_hash, plan, duration_months, label, notes,
max_uses, max_users_per_org, price_eur, reseller_name,
expires_at, channel, issued_to, issued_by,
restrict_vat, restrict_email)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
[
$prefix, $tokenHash, $plan, $durationMonths,
$label, $notes, $maxUses, $maxUsersPerOrg, $priceEur, $resellerName,
$expiresAt, $channel, $issuedTo, $issuedBy,
$restrictVat ?: null, $restrictEmail,
]
);
$inviteId = (int) Database::lastInsertId();
$created[] = [
'id' => $inviteId,
'token' => $rawToken, // SOLO qui viene restituito in chiaro
'token_prefix' => $prefix,
'plan' => $plan,
'duration_months' => $durationMonths,
'max_uses' => $maxUses,
'max_users_per_org' => $maxUsersPerOrg,
'price_eur' => $priceEur,
'expires_at' => $expiresAt,
'channel' => $channel,
'issued_to' => $issuedTo,
'label' => $label,
'invite_url' => APP_URL . '/onboarding.html?invite=' . urlencode($rawToken),
'provision_hint' => 'POST /api/services/provision con invite_token: "' . $rawToken . '"',
];
// Audit
$this->logAudit('invite.created', 'invite', $inviteId, [
'plan' => $plan, 'channel' => $channel,
'expires_at' => $expiresAt, 'max_uses' => $maxUses,
'max_users_per_org' => $maxUsersPerOrg,
]);
}
$this->jsonSuccess([
'invites' => $created,
'count' => count($created),
'warning' => 'Salva i token subito — non saranno più visibili in chiaro.',
], 201);
}
/**
* GET /api/invites
* Lista inviti con filtri. Solo super_admin.
*/
public function index(): void
{
$this->requireLicenseAuth(writeRequired: false);
$status = $_GET['status'] ?? null; // pending/used/expired/revoked
$channel = $_GET['channel'] ?? null;
$limit = min(100, max(1, (int)($_GET['limit'] ?? 50)));
$offset = max(0, (int)($_GET['offset'] ?? 0));
$where = ['1=1'];
$params = [];
if ($status) { $where[] = 'status = ?'; $params[] = $status; }
if ($channel) { $where[] = 'channel = ?'; $params[] = $channel; }
// Auto-scaduti: aggiorna status se expires_at passato
Database::query(
"UPDATE invites SET status='expired' WHERE status='pending' AND expires_at < NOW()"
);
$sql = 'SELECT id, token_prefix, plan, duration_months, label, max_uses, used_count,
expires_at, channel, issued_to, status, used_at, used_by_org_id,
restrict_vat, restrict_email, notes, created_at
FROM invites
WHERE ' . implode(' AND ', $where) .
' ORDER BY created_at DESC LIMIT ? OFFSET ?';
$params[] = $limit;
$params[] = $offset;
$rows = Database::fetchAll($sql, $params);
$total = (int) Database::fetchOne('SELECT COUNT(*) AS c FROM invites WHERE ' . implode(' AND ', array_slice($where, 0, -0)), array_slice($params, 0, -2))['c'];
// Aggiungi org_name per inviti usati
foreach ($rows as &$row) {
if ($row['used_by_org_id']) {
$org = Database::fetchOne('SELECT name FROM organizations WHERE id=? LIMIT 1', [$row['used_by_org_id']]);
$row['used_by_org_name'] = $org['name'] ?? null;
}
}
$this->jsonSuccess(['invites' => $rows, 'total' => $total, 'limit' => $limit, 'offset' => $offset]);
}
/**
* GET /api/invites/{id}
* Dettaglio singolo invito. Solo super_admin.
*/
public function show(int $id = 0): void
{
$this->requireLicenseAuth(writeRequired: false);
$row = Database::fetchOne('SELECT * FROM invites WHERE id=? LIMIT 1', [$id]);
if (!$row) $this->jsonError('Invito non trovato', 404, 'NOT_FOUND');
unset($row['token_hash']); // non esporre mai l'hash
if ($row['used_by_org_id']) {
$org = Database::fetchOne('SELECT name, sector, nis2_entity_type FROM organizations WHERE id=?', [$row['used_by_org_id']]);
$row['used_by_org'] = $org;
}
$this->jsonSuccess($row);
}
/**
* DELETE /api/invites/{id}
* Revoca invito (non cancella — solo status=revoked). Solo super_admin.
*/
public function revoke(int $id = 0): void
{
$this->requireLicenseAuth(writeRequired: true);
$row = Database::fetchOne('SELECT id, status FROM invites WHERE id=? LIMIT 1', [$id]);
if (!$row) $this->jsonError('Invito non trovato', 404, 'NOT_FOUND');
if ($row['status'] === 'used') $this->jsonError('Invito già usato — non revocabile', 422, 'ALREADY_USED');
Database::query("UPDATE invites SET status='revoked', updated_at=NOW() WHERE id=?", [$id]);
$this->logAudit('invite.revoked', 'invite', $id);
$this->jsonSuccess(['revoked' => true, 'id' => $id]);
}
/**
* POST /api/invites/{id}/regenerate
* Rigenera il token mantenendo config (piano, durata, scadenza). Solo super_admin.
* Utile se il token è stato compromesso PRIMA dell'uso.
*/
public function regenerate(int $id = 0): void
{
$this->requireLicenseAuth(writeRequired: true);
$row = Database::fetchOne('SELECT * FROM invites WHERE id=? LIMIT 1', [$id]);
if (!$row) $this->jsonError('Invito non trovato', 404, 'NOT_FOUND');
if ($row['status'] === 'used') $this->jsonError('Invito già usato — impossibile rigenerare', 422, 'ALREADY_USED');
$rawToken = self::TOKEN_PREFIX . bin2hex(random_bytes(self::TOKEN_BYTES));
$tokenHash = hash('sha256', $rawToken);
$prefix = substr($rawToken, 0, 10) . '...';
Database::query(
"UPDATE invites SET token_prefix=?, token_hash=?, status='pending', updated_at=NOW() WHERE id=?",
[$prefix, $tokenHash, $id]
);
$this->logAudit('invite.regenerated', 'invite', $id);
$this->jsonSuccess([
'id' => $id,
'token' => $rawToken,
'token_prefix' => $prefix,
'warning' => 'Nuovo token generato. Vecchio token invalidato.',
]);
}
// ══════════════════════════════════════════════════════════════════════
// PUBBLICO — Validazione invito (anteprima)
// ══════════════════════════════════════════════════════════════════════
/**
* GET /api/invites/validate/{token}
* Valida un invito e restituisce il piano incluso.
* Nessuna auth richiesta — usato da lg231 prima del provision
* e dalla pagina onboarding.html per mostrare l'anteprima.
*/
public function validate(): void
{
$token = $_GET['token'] ?? '';
if (!$token) $this->jsonError('Token mancante', 400, 'MISSING_TOKEN');
$result = self::resolveInvite($token);
if (!$result['valid']) {
$this->jsonError($result['error'], 422, $result['code']);
}
$inv = $result['invite'];
$this->jsonSuccess([
'valid' => true,
'plan' => $inv['plan'],
'duration_months' => (int) $inv['duration_months'],
'expires_at' => $inv['expires_at'],
'remaining_uses' => (int)$inv['max_uses'] - (int)$inv['used_count'],
'max_users_per_org' => $inv['max_users_per_org'] !== null ? (int)$inv['max_users_per_org'] : null,
'channel' => $inv['channel'],
'label' => $inv['label'],
'plan_features' => self::planFeatures($inv['plan']),
]);
}
// ══════════════════════════════════════════════════════════════════════
// HELPERS STATICI (usati anche da ServicesController::provision)
// ══════════════════════════════════════════════════════════════════════
/**
* Valida e restituisce l'invito dal token raw.
* @return array { valid: bool, invite: array|null, error: string|null, code: string|null }
*/
public static function resolveInvite(string $rawToken): array
{
if (!str_starts_with($rawToken, self::TOKEN_PREFIX)) {
return ['valid' => false, 'invite' => null, 'error' => 'Formato token non valido', 'code' => 'INVALID_FORMAT'];
}
$hash = hash('sha256', $rawToken);
$invite = Database::fetchOne('SELECT * FROM invites WHERE token_hash=? LIMIT 1', [$hash]);
if (!$invite) {
return ['valid' => false, 'invite' => null, 'error' => 'Invito non trovato', 'code' => 'NOT_FOUND'];
}
if ($invite['status'] === 'revoked') {
return ['valid' => false, 'invite' => $invite, 'error' => 'Invito revocato', 'code' => 'REVOKED'];
}
if ($invite['status'] === 'used' && (int)$invite['used_count'] >= (int)$invite['max_uses']) {
return ['valid' => false, 'invite' => $invite, 'error' => 'Invito già utilizzato', 'code' => 'ALREADY_USED'];
}
if (strtotime($invite['expires_at']) < time()) {
Database::query("UPDATE invites SET status='expired' WHERE id=?", [$invite['id']]);
return ['valid' => false, 'invite' => $invite, 'error' => 'Invito scaduto il ' . $invite['expires_at'], 'code' => 'EXPIRED'];
}
return ['valid' => true, 'invite' => $invite, 'error' => null, 'code' => null];
}
/**
* Segna l'invito come usato (chiamato dopo provisioning riuscito).
*/
public static function markUsed(int $inviteId, int $orgId, string $ip): void
{
Database::query(
"UPDATE invites
SET used_count = used_count + 1,
status = CASE WHEN used_count + 1 >= max_uses THEN 'used' ELSE 'pending' END,
used_at = COALESCE(used_at, NOW()),
used_by_org_id = COALESCE(used_by_org_id, ?),
used_by_ip = ?,
updated_at = NOW()
WHERE id = ?",
[$orgId, $ip, $inviteId]
);
}
/**
* Feature list per piano (usata nella preview invito).
*/
public static function planFeatures(string $plan): array
{
$features = [
'essentials' => [
'Gap Analysis Art.21 (80 domande)',
'Registro Rischi (ISO 27005)',
'Gestione Incidenti Art.23',
'Audit Trail SHA-256',
'Max 3 utenti',
'API read-only',
],
'professional' => [
'Tutto Essentials',
'Policy Management + AI',
'Supply Chain Assessment',
'Formazione & Compliance',
'Whistleblowing anonimo',
'Webhook outbound',
'API read+write',
'Max 10 utenti',
'SSO federato',
],
'enterprise' => [
'Tutto Professional',
'Organizzazioni illimitate',
'Utenti illimitati',
'API admin:org completa',
'Provisioning automatico',
'Benchmark settoriale',
'SLA prioritario',
'White-label disponibile',
],
];
return $features[$plan] ?? $features['professional'];
}
// ══════════════════════════════════════════════════════════════════════
// AUTH HELPER — JWT super_admin OR API Key con scope admin:licenses
// ══════════════════════════════════════════════════════════════════════
/**
* Accetta:
* a) JWT super_admin (header Authorization: Bearer eyJ...)
* b) API Key con scope admin:licenses o read:all (header X-API-Key: nis2_...)
*
* Se nessuno dei due è valido → 401.
* Se $writeRequired=true e la chiave ha solo read:all → 403.
*/
private function requireLicenseAuth(bool $writeRequired = false): void
{
// ── tenta API Key ────────────────────────────────────────────────
$rawKey = $_SERVER['HTTP_X_API_KEY']
?? (isset($_SERVER['HTTP_AUTHORIZATION']) && str_starts_with($_SERVER['HTTP_AUTHORIZATION'], 'ApiKey ')
? substr($_SERVER['HTTP_AUTHORIZATION'], 7) : null);
if ($rawKey) {
if (!str_starts_with($rawKey, 'nis2_')) {
$this->jsonError('Formato API Key non valido', 401, 'INVALID_API_KEY_FORMAT');
}
$hash = hash('sha256', $rawKey);
$key = Database::fetchOne(
'SELECT id, organization_id, scopes, is_active, expires_at
FROM api_keys WHERE key_hash=? LIMIT 1',
[$hash]
);
if (!$key || !$key['is_active']) {
$this->jsonError('API Key non valida o disattivata', 401, 'INVALID_API_KEY');
}
if ($key['expires_at'] && strtotime($key['expires_at']) < time()) {
$this->jsonError('API Key scaduta', 401, 'EXPIRED_API_KEY');
}
$scopes = json_decode($key['scopes'] ?? '[]', true);
$hasAdmin = in_array('admin:licenses', $scopes, true);
$hasRead = in_array('read:all', $scopes, true) || in_array('admin:licenses', $scopes, true);
if (!$hasRead) {
$this->jsonError('Scope insufficiente (richiesto: admin:licenses o read:all)', 403, 'INSUFFICIENT_SCOPE');
}
if ($writeRequired && !$hasAdmin) {
$this->jsonError('Scope insufficiente per scrittura (richiesto: admin:licenses)', 403, 'WRITE_SCOPE_REQUIRED');
}
// Aggiorna last_used_at
Database::query('UPDATE api_keys SET last_used_at=NOW() WHERE id=?', [$key['id']]);
return;
}
// ── fallback: JWT super_admin ────────────────────────────────────
$this->requireAuth();
$this->requireRole(['super_admin']);
}
// ── helper ───────────────────────────────────────────────────────────
private function getBody(): array
{
return json_decode(file_get_contents('php://input'), true) ?? [];
}
}