452 lines
21 KiB
PHP
452 lines
21 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;
|
|
|
|
// 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;
|
|
}
|
|
|
|
$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) ?? [];
|
|
}
|
|
}
|