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)
+
+
Metodo
Endpoint
Auth
Descrizione
+
+
POST
/api/invites/create
JWT super_admin
Genera 1..50 inviti in batch
+
GET
/api/invites/list
JWT super_admin
Lista con filtri status/channel, auto-scade pending
+
GET
/api/invites/{id}
JWT super_admin
Dettaglio singolo + org usante
+
DELETE
/api/invites/{id}
JWT super_admin
Revoca (non cancella — solo status=revoked)
+
POST
/api/invites/{id}/regenerate
JWT super_admin
Nuovo 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 invito
Significato
Azione
+
+
pending
Valido, non ancora usato
Usabile per provision
+
used
Raggiunto max_uses
Provisioning bloccato
+
expired
expires_at superato
Provisioning bloccato, rinnovare
+
revoked
Revocato da admin
Provisioning 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.
+
+ 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
-
company-ms: aggiungere nis2_api_key, nis2_org_id, nis2_enabled a provider-config
-
shared: creare Nis2Client (curl wrapper leggero)
+
company-ms: aggiungere nis2_api_key, nis2_org_id, nis2_enabled, nis2_invite_id, nis2_license_plan, nis2_expires_at a provider-config
+
shared: creare Nis2Client (curl wrapper leggero) con check scadenza licenza
+
company-ms: endpoint provisioning — ricevi invite_token, chiama POST /api/services/provision, salva risposta in metadata
UI: widget NIS2 score nella company detail (gauge + badge + rischi high)
risk-ms: import rischi cyber durante assessment (categoria art.24-bis)