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; // Dati destinatario (pre-compilano il form di registrazione) $recipient = []; if (!empty($body['recipient_first_name'])) $recipient['first_name'] = substr(trim($body['recipient_first_name']), 0, 100); if (!empty($body['recipient_last_name'])) $recipient['last_name'] = substr(trim($body['recipient_last_name']), 0, 100); if (!empty($body['recipient_email'])) $recipient['email'] = filter_var(trim($body['recipient_email']), FILTER_VALIDATE_EMAIL) ?: null; if (!empty($body['recipient_company'])) $recipient['company'] = substr(trim($body['recipient_company']), 0, 255); if (!empty($body['recipient_vat'])) $recipient['vat'] = preg_replace('/[^0-9]/', '', $body['recipient_vat']); $metadata = !empty($recipient) ? json_encode(['recipient' => $recipient]) : 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, metadata) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', [ $prefix, $tokenHash, $plan, $durationMonths, $label, $notes, $maxUses, $maxUsersPerOrg, $priceEur, $resellerName, $expiresAt, $channel, $issuedTo, $issuedBy, $restrictVat ?: null, $restrictEmail, $metadata, ] ); $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 . '/register.html?invite=' . urlencode($rawToken), 'recipient' => !empty($recipient) ? $recipient : null, '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); $countParams = array_slice($params, 0, -2); // rimuovi limit e offset $total = (int) Database::fetchOne('SELECT COUNT(*) AS c FROM invites WHERE ' . implode(' AND ', $where), $countParams)['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; } // Parsa metadata per esporre recipient strutturato if (!empty($row['metadata'])) { $meta = json_decode($row['metadata'], true) ?? []; $row['metadata_recipient'] = $meta['recipient'] ?? null; } $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) ?? []; } }