[INVITE] Sistema inviti/licenze B2B + provisioning con invite_token

- InviteController: CRUD inviti (gen, list, show, revoke, rigenera, validate)
- Token inv_* sha256-hashed, one-shot o multi-use, canali, scadenza
- ServicesController::provision() accetta invite_token al posto di X-Provision-Secret
  Piano e durata forzati dall'invito, markUsed() chiaamto dopo provisioning riuscito
- index.php: routing /api/invites/* aggiunto (controller + action map)
- integrazioniext.html: nuovo tab "Inviti & Licenze" con flow completo, endpoints,
  esempi curl/php, guida lg231 aggiornata con sezione provisioning automatico

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-03-07 15:22:25 +01:00
parent 6933e1d3fb
commit 612befd66d
5 changed files with 679 additions and 14 deletions

View File

@ -0,0 +1,376 @@
<?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):
* POST /api/invites genera invito
* GET /api/invites 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)
*/
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->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) ?? [];
}
}

View File

@ -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,
]);
}

58
docs/sql/012_invites.sql Normal file
View File

@ -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;

View File

@ -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',

View File

@ -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 @@
<!-- Tabs -->
<div class="tab-nav">
<button class="tab-btn active" onclick="showTab('api')">Services API</button>
<button class="tab-btn" onclick="showTab('invites')">Inviti &amp; Licenze</button>
<button class="tab-btn" onclick="showTab('lg231')">Guida lg231</button>
<button class="tab-btn" onclick="showTab('webhook')">Webhook</button>
<button class="tab-btn" onclick="showTab('quickstart')">Quick Start</button>
@ -310,6 +318,153 @@
</table>
</div>
<!-- TAB: Inviti & Licenze -->
<div id="tab-invites" class="tab-pane">
<div class="callout">
<strong>Sistema Inviti / Licenze B2B:</strong> 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.
</div>
<div class="section-title">Flusso completo</div>
<div class="code-block" style="font-family:monospace;line-height:1.8;font-size:.82rem">
<span style="color:#22c55e">1. GENERAZIONE</span> NIS2 Admin (super_admin)
POST /api/invites → { token: "inv_abc123...", expires_at, plan, max_uses }
<span class="cmt">Il token è visibile UNA SOLA VOLTA. Solo hash SHA-256 in DB.</span>
<span style="color:#06b6d4">2. DISTRIBUZIONE</span> 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)
<span style="color:#a78bfa">3. VALIDAZIONE</span> lg231 prima del provisioning
GET /api/invites/validate?token=inv_abc123...
→ { valid: true, plan: "professional", expires_at, remaining_uses, plan_features }
<span style="color:#f59e0b">4. PROVISIONING</span> 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++)
<span style="color:#ef4444">5. SCADENZA</span> 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
</div>
<div class="section-title">API Inviti (solo super_admin)</div>
<table class="api-table">
<thead><tr><th>Metodo</th><th>Endpoint</th><th>Auth</th><th>Descrizione</th></tr></thead>
<tbody>
<tr><td><span class="method-post">POST</span></td><td><code>/api/invites/create</code></td><td>JWT super_admin</td><td>Genera 1..50 inviti in batch</td></tr>
<tr><td><span class="method-get">GET</span></td><td><code>/api/invites/list</code></td><td>JWT super_admin</td><td>Lista con filtri status/channel, auto-scade pending</td></tr>
<tr><td><span class="method-get">GET</span></td><td><code>/api/invites/{id}</code></td><td>JWT super_admin</td><td>Dettaglio singolo + org usante</td></tr>
<tr><td><span class="method-delete">DELETE</span></td><td><code>/api/invites/{id}</code></td><td>JWT super_admin</td><td>Revoca (non cancella — solo status=revoked)</td></tr>
<tr><td><span class="method-post">POST</span></td><td><code>/api/invites/{id}/regenerate</code></td><td>JWT super_admin</td><td>Nuovo token, stessa configurazione</td></tr>
<tr><td><span class="method-get">GET</span></td><td><code>/api/invites/validate?token=inv_...</code></td><td></td><td>Anteprima pubblica: piano, scadenza, usi rimasti</td></tr>
</tbody>
</table>
<div class="section-title">Generare un invito (NIS2 Admin)</div>
<div class="code-block"><span class="method-post">POST</span> https://nis2.agile.software/api/invites/create
Authorization: Bearer {jwt_super_admin}
Content-Type: application/json
{
<span class="key">"plan"</span>: <span class="str">"professional"</span>, <span class="cmt">// essentials | professional | enterprise</span>
<span class="key">"duration_months"</span>: 12, <span class="cmt">// durata licenza dopo attivazione</span>
<span class="key">"invite_expires_days"</span>: 30, <span class="cmt">// giorni di validità dell'invito stesso</span>
<span class="key">"max_uses"</span>: 1, <span class="cmt">// 1=one-shot, N=batch (es: 10 per reseller)</span>
<span class="key">"label"</span>: <span class="str">"lg231 Partner Q1 2026"</span>,
<span class="key">"channel"</span>: <span class="str">"lg231"</span>, <span class="cmt">// lg231 | ecommerce | direct | manual</span>
<span class="key">"issued_to"</span>: <span class="str">"partner@lg231.it"</span>,
<span class="key">"notes"</span>: <span class="str">"Ordine OPP-2026-042"</span>
}
<span class="cmt">// Risposta — token visibile UNA SOLA VOLTA:</span>
{
<span class="key">"invites"</span>: [{
<span class="key">"id"</span>: 7,
<span class="key">"token"</span>: <span class="str">"inv_a1b2c3d4e5f6..."</span>, <span class="cmt">← SALVARE SUBITO, non recuperabile</span>
<span class="key">"plan"</span>: <span class="str">"professional"</span>,
<span class="key">"expires_at"</span>: <span class="str">"2026-04-06 12:00:00"</span>,
<span class="key">"invite_url"</span>: <span class="str">"https://nis2.agile.software/onboarding.html?invite=inv_..."</span>
}],
<span class="key">"warning"</span>: <span class="str">"Salva i token subito — non saranno più visibili in chiaro."</span>
}</div>
<div class="section-title">Provisioning con invito (lg231 → NIS2)</div>
<div class="code-block"><span class="cmt">// lg231 usa l'invite_token ricevuto per attivare automaticamente il cliente:</span>
<span class="method-post">POST</span> https://nis2.agile.software/api/services/provision
Content-Type: application/json
{
<span class="key">"invite_token"</span>: <span class="str">"inv_a1b2c3d4..."</span>, <span class="cmt">← sostituisce X-Provision-Secret</span>
<span class="key">"company"</span>: {
<span class="key">"ragione_sociale"</span>: <span class="str">"Acme S.r.l."</span>,
<span class="key">"partita_iva"</span>: <span class="str">"02345678901"</span>,
<span class="key">"ateco_code"</span>: <span class="str">"62.01.00"</span>,
<span class="key">"sector"</span>: <span class="str">"ict"</span>
},
<span class="key">"admin"</span>: {
<span class="key">"email"</span>: <span class="str">"ciso@acme.it"</span>,
<span class="key">"first_name"</span>: <span class="str">"Marco"</span>,
<span class="key">"last_name"</span>: <span class="str">"Rossi"</span>
},
<span class="key">"caller"</span>: {
<span class="key">"system"</span>: <span class="str">"lg231"</span>,
<span class="key">"company_id"</span>: 142,
<span class="key">"callback_url"</span>: <span class="str">"https://lg231.agile.software/api/integrations/nis2-callback"</span>
}
}
<span class="cmt">// Risposta (il piano e la durata vengono dall'invito, non dal body):</span>
{
<span class="key">"provisioned"</span>: <span class="kw">true</span>,
<span class="key">"org_id"</span>: 8,
<span class="key">"api_key"</span>: <span class="str">"nis2_abcdef..."</span>, <span class="cmt">← salvare in lg231 metadata</span>
<span class="key">"access_token"</span>: <span class="str">"eyJ..."</span>, <span class="cmt">← JWT 2h per apertura immediata</span>
<span class="key">"temp_password"</span>: <span class="str">"NIS2_xxxxxx"</span>, <span class="cmt">← cambio obbligatorio al primo login</span>
<span class="key">"invite_plan"</span>: <span class="str">"professional"</span>,
<span class="key">"license_expires_at"</span>: <span class="str">"2027-03-07 12:00:00"</span>,
<span class="key">"dashboard_url"</span>: <span class="str">"https://nis2.agile.software/dashboard.html"</span>
}</div>
<div class="section-title">Validazione preventiva (opzionale)</div>
<div class="code-block"><span class="cmt"># lg231 può validare l'invito prima del provisioning:</span>
curl "https://nis2.agile.software/api/invites/validate?token=inv_a1b2c3..."
<span class="cmt">// Se valido:</span>
{ <span class="key">"valid"</span>: <span class="kw">true</span>, <span class="key">"plan"</span>: <span class="str">"professional"</span>, <span class="key">"duration_months"</span>: 12,
<span class="key">"expires_at"</span>: <span class="str">"2026-04-06T12:00:00"</span>, <span class="key">"remaining_uses"</span>: 1,
<span class="key">"plan_features"</span>: [<span class="str">"Policy Management + AI"</span>, <span class="str">"Supply Chain Assessment"</span>, ...] }
<span class="cmt">// Se scaduto:</span>
{ <span class="key">"valid"</span>: <span class="kw">false</span>, <span class="key">"error"</span>: <span class="str">"Invito scaduto il 2026-03-01 00:00:00"</span>, <span class="key">"code"</span>: <span class="str">"EXPIRED"</span> }
<span class="cmt">// Se già usato:</span>
{ <span class="key">"valid"</span>: <span class="kw">false</span>, <span class="key">"error"</span>: <span class="str">"Invito già utilizzato"</span>, <span class="key">"code"</span>: <span class="str">"ALREADY_USED"</span> }</div>
<div class="section-title">Gestione ciclo di vita</div>
<table class="api-table">
<thead><tr><th>Stato invito</th><th>Significato</th><th>Azione</th></tr></thead>
<tbody>
<tr><td><code>pending</code></td><td>Valido, non ancora usato</td><td>Usabile per provision</td></tr>
<tr><td><code>used</code></td><td>Raggiunto max_uses</td><td>Provisioning bloccato</td></tr>
<tr><td><code>expired</code></td><td>expires_at superato</td><td>Provisioning bloccato, rinnovare</td></tr>
<tr><td><code>revoked</code></td><td>Revocato da admin</td><td>Provisioning bloccato permanente</td></tr>
</tbody>
</table>
<div class="callout">
<strong>Sicurezza token:</strong> il token <code>inv_</code> viene restituito in chiaro UNA SOLA VOLTA
alla creazione. Nel DB è conservato solo il hash SHA-256. Il <code>token_prefix</code> (es: <code>inv_a1b2c3...</code>)
è visibile nell'admin panel per identificazione visiva.
</div>
</div>
<!-- TAB: Guida lg231 -->
<div id="tab-lg231" class="tab-pane">
<div class="callout">
@ -427,10 +582,32 @@ $ssoResp = Nis2Client::post(<span class="str">'/sso'</span>, $apiKey, [
<code>if (ssoToken) { localStorage.setItem('nis2_token', ssoToken); location.hash = ''; }</code>
</div>
<div class="section-title">5. Provisioning automatico dal portale lg231</div>
<div class="callout">
Quando un cliente lg231 acquista una licenza NIS2 Agile, lg231 riceve un <strong>invite_token</strong>
dall'e-commerce. Con questo token lg231 chiama <code>POST /api/services/provision</code> 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).
</div>
<div class="code-block"><span class="cmt">// Salva in lg231 companies.metadata dopo provisioning:</span>
$meta[<span class="str">'nis2_api_key'</span>] = $response[<span class="str">'api_key'</span>];
$meta[<span class="str">'nis2_org_id'</span>] = $response[<span class="str">'org_id'</span>];
$meta[<span class="str">'nis2_invite_id'</span>] = $response[<span class="str">'invite_id'</span>];
$meta[<span class="str">'nis2_license_plan'</span>] = $response[<span class="str">'invite_plan'</span>];
$meta[<span class="str">'nis2_expires_at'</span>] = $response[<span class="str">'license_expires_at'</span>];
$meta[<span class="str">'nis2_enabled'</span>] = <span class="kw">true</span>;
<span class="cmt">// Controlla scadenza prima di ogni chiamata:</span>
<span class="kw">if</span> (strtotime($meta[<span class="str">'nis2_expires_at'</span>] ?? <span class="str">'1970-01-01'</span>) &lt; time()) {
<span class="cmt">// licenza scaduta → mostrare banner rinnovo, non chiamare NIS2</span>
<span class="kw">return</span> [<span class="str">'success'</span> => <span class="kw">false</span>, <span class="str">'reason'</span> => <span class="str">'license_expired'</span>];
}</div>
<div class="section-title">Checklist implementazione lg231</div>
<ul class="checklist">
<li>company-ms: aggiungere <code>nis2_api_key</code>, <code>nis2_org_id</code>, <code>nis2_enabled</code> a provider-config</li>
<li>shared: creare <code>Nis2Client</code> (curl wrapper leggero)</li>
<li>company-ms: aggiungere <code>nis2_api_key</code>, <code>nis2_org_id</code>, <code>nis2_enabled</code>, <code>nis2_invite_id</code>, <code>nis2_license_plan</code>, <code>nis2_expires_at</code> a provider-config</li>
<li>shared: creare <code>Nis2Client</code> (curl wrapper leggero) con check scadenza licenza</li>
<li>company-ms: endpoint provisioning — ricevi invite_token, chiama <code>POST /api/services/provision</code>, salva risposta in metadata</li>
<li>UI: widget NIS2 score nella company detail (gauge + badge + rischi high)</li>
<li>risk-ms: import rischi cyber durante assessment (categoria art.24-bis)</li>
<li>monitoring-ms: escalation incidenti significativi → odv_activity</li>
@ -571,7 +748,7 @@ curl -H <span class="str">"X-API-Key: nis2_TUA_CHIAVE"</span> \
<script>
function showTab(id) {
document.querySelectorAll('.tab-btn').forEach((b, i) => {
const ids = ['api', 'lg231', 'webhook', 'quickstart'];
const ids = ['api', 'invites', 'lg231', 'webhook', 'quickstart'];
b.classList.toggle('active', ids[i] === id);
});
document.querySelectorAll('.tab-pane').forEach(p => {