diff --git a/application/controllers/InviteController.php b/application/controllers/InviteController.php new file mode 100644 index 0000000..83854c6 --- /dev/null +++ b/application/controllers/InviteController.php @@ -0,0 +1,376 @@ +requireAuth(); + $this->requireRole(['super_admin']); + + $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; + $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::execute( + 'INSERT INTO invites + (token_prefix, token_hash, plan, duration_months, label, notes, + max_uses, expires_at, channel, issued_to, issued_by, + restrict_vat, restrict_email) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)', + [ + $prefix, $tokenHash, $plan, $durationMonths, + $label, $notes, $maxUses, $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, + '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, + ]); + } + + $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->requireAuth(); + $this->requireRole(['super_admin']); + + $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::execute( + "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->requireAuth(); + $this->requireRole(['super_admin']); + + $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->requireAuth(); + $this->requireRole(['super_admin']); + $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::execute("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->requireAuth(); + $this->requireRole(['super_admin']); + $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::execute( + "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'], + '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::execute("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::execute( + "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']; + } + + // ── helper ─────────────────────────────────────────────────────────── + private function getBody(): array + { + return json_decode(file_get_contents('php://input'), true) ?? []; + } +} diff --git a/application/controllers/ServicesController.php b/application/controllers/ServicesController.php index 08d1efc..3fe9d2f 100644 --- a/application/controllers/ServicesController.php +++ b/application/controllers/ServicesController.php @@ -477,22 +477,51 @@ class ServicesController extends BaseController { $this->setServiceHeaders(); - // ── 1. Auth via PROVISION_SECRET ──────────────────────────────── - $secret = $_SERVER['HTTP_X_PROVISION_SECRET'] - ?? ($_SERVER['HTTP_AUTHORIZATION'] ? str_replace('Provision ', '', $_SERVER['HTTP_AUTHORIZATION']) : null) - ?? null; - - if (!$secret || !hash_equals(PROVISION_SECRET, $secret)) { - $this->jsonError('Provision secret mancante o non valido', 401, 'INVALID_PROVISION_SECRET'); - } - - // ── 2. Leggi e valida body ─────────────────────────────────────── + // ── 1. Leggi body (serve per invite_token) ─────────────────────── $body = json_decode(file_get_contents('php://input'), true) ?? []; $company = $body['company'] ?? []; $admin = $body['admin'] ?? []; $license = $body['license'] ?? []; $caller = $body['caller'] ?? []; + // ── 2. Auth: invite_token (canale B2B) O X-Provision-Secret (admin diretto) ── + $inviteToken = trim($body['invite_token'] ?? ''); + $resolvedInvite = null; + + if ($inviteToken) { + // Percorso B2B tramite invito (lg231, e-commerce, partner) + require_once __DIR__ . '/InviteController.php'; + $result = InviteController::resolveInvite($inviteToken); + if (!$result['valid']) { + $this->jsonError('Invito non valido: ' . $result['error'], 401, 'INVALID_INVITE_' . $result['code']); + } + $resolvedInvite = $result['invite']; + + // Verifica restrizione P.IVA se presente nell'invito + $partitaIvaCheck = preg_replace('/[^0-9]/', '', $company['partita_iva'] ?? ''); + if ($resolvedInvite['restrict_vat'] && $resolvedInvite['restrict_vat'] !== $partitaIvaCheck) { + $this->jsonError('Invito non valido per questa P.IVA', 403, 'INVITE_VAT_MISMATCH'); + } + // Verifica restrizione email admin se presente + $adminEmailCheck = trim($admin['email'] ?? ''); + if ($resolvedInvite['restrict_email'] && strcasecmp($resolvedInvite['restrict_email'], $adminEmailCheck) !== 0) { + $this->jsonError('Invito non valido per questa email admin', 403, 'INVITE_EMAIL_MISMATCH'); + } + + // Piano e durata vengono dall'invito (non dal body) + $license['plan'] = $resolvedInvite['plan']; + $license['duration_months'] = (int)$resolvedInvite['duration_months']; + } else { + // Percorso diretto admin/e-commerce via secret header + $secret = $_SERVER['HTTP_X_PROVISION_SECRET'] + ?? (isset($_SERVER['HTTP_AUTHORIZATION']) + ? str_replace('Provision ', '', $_SERVER['HTTP_AUTHORIZATION']) + : null); + if (!$secret || !hash_equals(PROVISION_SECRET, $secret)) { + $this->jsonError('invite_token o X-Provision-Secret richiesti', 401, 'AUTH_REQUIRED'); + } + } + $ragioneSociale = trim($company['ragione_sociale'] ?? ''); $partitaIva = preg_replace('/[^0-9]/', '', $company['partita_iva'] ?? ''); $adminEmail = trim($admin['email'] ?? ''); @@ -660,7 +689,16 @@ class ServicesController extends BaseController performedBy: ($caller['system'] ?? 'external') . ':provision' ); - // ── 9. Callback a lg231 (asincrono, non bloccante) ─────────────── + // ── 9. Segna invito come usato (se presente) ──────────────────── + if ($resolvedInvite) { + InviteController::markUsed( + (int)$resolvedInvite['id'], + $orgId, + $_SERVER['REMOTE_ADDR'] ?? '' + ); + } + + // ── 10. Callback a lg231 (asincrono, non bloccante) ────────────── $callbackUrl = $caller['callback_url'] ?? null; if ($callbackUrl) { $callbackPayload = json_encode([ @@ -707,6 +745,10 @@ class ServicesController extends BaseController // Link 'dashboard_url' => APP_URL . '/dashboard.html', 'settings_url' => APP_URL . '/settings.html', + // Info invito (se usato) + 'invite_id' => $resolvedInvite ? (int)$resolvedInvite['id'] : null, + 'invite_plan' => $resolvedInvite ? $resolvedInvite['plan'] : ($license['plan'] ?? 'professional'), + 'license_expires_at' => $expiresAt, ]); } diff --git a/docs/sql/012_invites.sql b/docs/sql/012_invites.sql new file mode 100644 index 0000000..f92053d --- /dev/null +++ b/docs/sql/012_invites.sql @@ -0,0 +1,58 @@ +-- ============================================================ +-- NIS2 Agile — Migration 012: Sistema Inviti / Licenze +-- Inviti a tempo per provisioning automatico da e-commerce, +-- lg231 e altri sistemi Agile partner. +-- ============================================================ + +USE nis2_agile_db; + +CREATE TABLE IF NOT EXISTS invites ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + + -- Token (visibile al chiamante, formato: inv_XXXX...) + token_prefix VARCHAR(16) NOT NULL, -- es: inv_a1b2c3 + token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 del token completo + + -- Configurazione licenza + plan ENUM('essentials','professional','enterprise') NOT NULL DEFAULT 'professional', + duration_months TINYINT UNSIGNED NOT NULL DEFAULT 12, + label VARCHAR(255) NULL, -- es: "lg231 Partner Q1 2026" + notes TEXT NULL, + + -- Limiti utilizzo + max_uses TINYINT UNSIGNED NOT NULL DEFAULT 1, -- 1 = one-shot, N = multi-use + used_count TINYINT UNSIGNED NOT NULL DEFAULT 0, + + -- Scadenza invito (indipendente dalla scadenza licenza) + expires_at DATETIME NOT NULL, + + -- Canale di distribuzione + channel VARCHAR(64) NULL, -- 'lg231', 'ecommerce', 'direct', 'manual' + issued_to VARCHAR(255) NULL, -- email o nome destinatario + issued_by INT UNSIGNED NULL, -- user_id NIS2 admin che ha generato + + -- Utilizzo + status ENUM('pending','used','expired','revoked') NOT NULL DEFAULT 'pending', + used_at DATETIME NULL, + used_by_org_id INT UNSIGNED NULL, -- org creata dall'invito + used_by_ip VARCHAR(45) NULL, + + -- Restrizioni facoltative + restrict_vat VARCHAR(11) NULL, -- solo per questa P.IVA + restrict_email VARCHAR(255) NULL, -- solo per questo admin email + + -- Metadata extra (JSON) + metadata JSON NULL, -- dati extra (es: lg231 order_id) + + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + PRIMARY KEY (id), + INDEX idx_invites_token_hash (token_hash), + INDEX idx_invites_status (status), + INDEX idx_invites_channel (channel), + INDEX idx_invites_expires (expires_at), + INDEX idx_invites_org (used_by_org_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +SELECT 'Migration 012 invites completata.' AS stato; diff --git a/public/index.php b/public/index.php index 8076652..dd95123 100644 --- a/public/index.php +++ b/public/index.php @@ -99,6 +99,7 @@ $controllerMap = [ 'onboarding' => 'OnboardingController', 'ncr' => 'NonConformityController', 'services' => 'ServicesController', + 'invites' => 'InviteController', 'webhooks' => 'WebhookController', 'whistleblowing'=> 'WhistleblowingController', 'normative' => 'NormativeController', @@ -296,6 +297,17 @@ $actionMap = [ 'POST:webhook' => 'webhook', ], + // ── InviteController (licenze/inviti B2B) ────── + 'invites' => [ + 'POST:create' => 'create', + 'GET:index' => 'index', + 'GET:list' => 'index', + 'GET:{id}' => 'show', + 'DELETE:{id}' => 'revoke', + 'POST:{id}/regenerate' => 'regenerate', + 'GET:validate' => 'validate', // ?token=inv_... + ], + // ── ServicesController (API pubblica) ────────── 'services' => [ 'POST:provision' => 'provision', diff --git a/public/integrazioniext.html b/public/integrazioniext.html index af4fbb2..faf68f5 100644 --- a/public/integrazioniext.html +++ b/public/integrazioniext.html @@ -85,6 +85,13 @@ padding: .15rem .4rem; border-radius: 3px; font-family: monospace; } +.method-delete { + display: inline-block; + background: rgba(239,68,68,.15); color: #ef4444; + font-size: .65rem; font-weight: 700; + padding: .15rem .4rem; border-radius: 3px; + font-family: monospace; +} .code-block { background: #0f172a; @@ -247,6 +254,7 @@
+ @@ -310,6 +318,153 @@
+ +
+
+ Sistema Inviti / Licenze B2B: NIS2 Agile genera token di invito che abilitano + il provisioning automatico di organizzazioni e utenti. L'e-commerce o il partner riceve l'invito, + lo consegna al cliente finale (es. lg231), che lo usa per attivarsi automaticamente. + L'accesso si interrompe alla scadenza dell'invito. +
+ +
Flusso completo
+
+1. GENERAZIONE NIS2 Admin (super_admin) + POST /api/invites → { token: "inv_abc123...", expires_at, plan, max_uses } + Il token è visibile UNA SOLA VOLTA. Solo hash SHA-256 in DB. + +2. DISTRIBUZIONE NIS2 → E-commerce → lg231 + NIS2 Admin passa inv_abc123... all'e-commerce + E-commerce lo consegna al cliente con l'ordine + lg231 lo riceve (es. in metadata ordine) + +3. VALIDAZIONE lg231 prima del provisioning + GET /api/invites/validate?token=inv_abc123... + → { valid: true, plan: "professional", expires_at, remaining_uses, plan_features } + +4. PROVISIONING lg231 attiva automaticamente + POST /api/services/provision (body include "invite_token": "inv_abc123...") + → { org_id, api_key, access_token, license_expires_at, temp_password } + NIS2 marca l'invito come usato (used_count++) + +5. SCADENZA Accesso automaticamente revocato + Quando expires_at raggiunto: + - API key rimane ma licenza_expires_at è nel passato + - GET /api/invites/validate → { valid: false, code: "EXPIRED" } + - Rinnovo: nuovo invito dal NIS2 Admin → nuovo provisioning +
+ +
API Inviti (solo super_admin)
+ + + + + + + + + + +
MetodoEndpointAuthDescrizione
POST/api/invites/createJWT super_adminGenera 1..50 inviti in batch
GET/api/invites/listJWT super_adminLista con filtri status/channel, auto-scade pending
GET/api/invites/{id}JWT super_adminDettaglio singolo + org usante
DELETE/api/invites/{id}JWT super_adminRevoca (non cancella — solo status=revoked)
POST/api/invites/{id}/regenerateJWT super_adminNuovo token, stessa configurazione
GET/api/invites/validate?token=inv_...Anteprima pubblica: piano, scadenza, usi rimasti
+ +
Generare un invito (NIS2 Admin)
+
POST https://nis2.agile.software/api/invites/create +Authorization: Bearer {jwt_super_admin} +Content-Type: application/json + +{ + "plan": "professional", // essentials | professional | enterprise + "duration_months": 12, // durata licenza dopo attivazione + "invite_expires_days": 30, // giorni di validità dell'invito stesso + "max_uses": 1, // 1=one-shot, N=batch (es: 10 per reseller) + "label": "lg231 Partner Q1 2026", + "channel": "lg231", // lg231 | ecommerce | direct | manual + "issued_to": "partner@lg231.it", + "notes": "Ordine OPP-2026-042" +} + +// Risposta — token visibile UNA SOLA VOLTA: +{ + "invites": [{ + "id": 7, + "token": "inv_a1b2c3d4e5f6...", ← SALVARE SUBITO, non recuperabile + "plan": "professional", + "expires_at": "2026-04-06 12:00:00", + "invite_url": "https://nis2.agile.software/onboarding.html?invite=inv_..." + }], + "warning": "Salva i token subito — non saranno più visibili in chiaro." +}
+ +
Provisioning con invito (lg231 → NIS2)
+
// lg231 usa l'invite_token ricevuto per attivare automaticamente il cliente: +POST https://nis2.agile.software/api/services/provision +Content-Type: application/json + +{ + "invite_token": "inv_a1b2c3d4...", ← sostituisce X-Provision-Secret + "company": { + "ragione_sociale": "Acme S.r.l.", + "partita_iva": "02345678901", + "ateco_code": "62.01.00", + "sector": "ict" + }, + "admin": { + "email": "ciso@acme.it", + "first_name": "Marco", + "last_name": "Rossi" + }, + "caller": { + "system": "lg231", + "company_id": 142, + "callback_url": "https://lg231.agile.software/api/integrations/nis2-callback" + } +} + +// Risposta (il piano e la durata vengono dall'invito, non dal body): +{ + "provisioned": true, + "org_id": 8, + "api_key": "nis2_abcdef...", ← salvare in lg231 metadata + "access_token": "eyJ...", ← JWT 2h per apertura immediata + "temp_password": "NIS2_xxxxxx", ← cambio obbligatorio al primo login + "invite_plan": "professional", + "license_expires_at": "2027-03-07 12:00:00", + "dashboard_url": "https://nis2.agile.software/dashboard.html" +}
+ +
Validazione preventiva (opzionale)
+
# lg231 può validare l'invito prima del provisioning: +curl "https://nis2.agile.software/api/invites/validate?token=inv_a1b2c3..." + +// Se valido: +{ "valid": true, "plan": "professional", "duration_months": 12, + "expires_at": "2026-04-06T12:00:00", "remaining_uses": 1, + "plan_features": ["Policy Management + AI", "Supply Chain Assessment", ...] } + +// Se scaduto: +{ "valid": false, "error": "Invito scaduto il 2026-03-01 00:00:00", "code": "EXPIRED" } + +// Se già usato: +{ "valid": false, "error": "Invito già utilizzato", "code": "ALREADY_USED" }
+ +
Gestione ciclo di vita
+ + + + + + + + +
Stato invitoSignificatoAzione
pendingValido, non ancora usatoUsabile per provision
usedRaggiunto max_usesProvisioning bloccato
expiredexpires_at superatoProvisioning bloccato, rinnovare
revokedRevocato da adminProvisioning bloccato permanente
+ +
+ Sicurezza token: il token inv_ viene restituito in chiaro UNA SOLA VOLTA + alla creazione. Nel DB è conservato solo il hash SHA-256. Il token_prefix (es: inv_a1b2c3...) + è visibile nell'admin panel per identificazione visiva. +
+
+
@@ -427,10 +582,32 @@ $ssoResp = Nis2Client::post('/sso', $apiKey, [ if (ssoToken) { localStorage.setItem('nis2_token', ssoToken); location.hash = ''; }
+
5. Provisioning automatico dal portale lg231
+
+ Quando un cliente lg231 acquista una licenza NIS2 Agile, lg231 riceve un invite_token + dall'e-commerce. Con questo token lg231 chiama POST /api/services/provision passando + i dati aziendali del cliente. NIS2 crea automaticamente org, admin e API key. Il piano e la + durata sono definiti dall'invito (non modificabili dal chiamante). +
+
// Salva in lg231 companies.metadata dopo provisioning: +$meta['nis2_api_key'] = $response['api_key']; +$meta['nis2_org_id'] = $response['org_id']; +$meta['nis2_invite_id'] = $response['invite_id']; +$meta['nis2_license_plan'] = $response['invite_plan']; +$meta['nis2_expires_at'] = $response['license_expires_at']; +$meta['nis2_enabled'] = true; + +// Controlla scadenza prima di ogni chiamata: +if (strtotime($meta['nis2_expires_at'] ?? '1970-01-01') < time()) { + // licenza scaduta → mostrare banner rinnovo, non chiamare NIS2 + return ['success' => false, 'reason' => 'license_expired']; +}
+
Checklist implementazione lg231