[MKTG-API] Documentazione + auth API Key per licenze

- InviteController: requireLicenseAuth() accetta X-API-Key (scope admin:licenses)
  oppure JWT super_admin — tutti i metodi admin aggiornati
- mktg-api-doc.html: risponde alle 6 domande del marketing con esempi curl,
  tabelle risposta, riepilogo endpoint, link Postman collection
- nis2-license-api.postman.json: collection completa (login, create, list,
  revoke, regenerate, validate, provision) con pre-script salva JWT/invite_id

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-03-07 16:05:18 +01:00
co-authored by Claude Sonnet 4.6
parent cb0988da27
commit d407fd0510
3 changed files with 696 additions and 16 deletions
+72 -16
View File
@@ -6,15 +6,20 @@
* 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
* 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)
* 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';
@@ -53,8 +58,7 @@ class InviteController extends BaseController
*/
public function create(): void
{
$this->requireAuth();
$this->requireRole(['super_admin']);
$this->requireLicenseAuth(writeRequired: true);
$body = $this->getBody();
$plan = in_array($body['plan'] ?? '', ['essentials','professional','enterprise'])
@@ -135,8 +139,7 @@ class InviteController extends BaseController
*/
public function index(): void
{
$this->requireAuth();
$this->requireRole(['super_admin']);
$this->requireLicenseAuth(writeRequired: false);
$status = $_GET['status'] ?? null; // pending/used/expired/revoked
$channel = $_GET['channel'] ?? null;
@@ -184,8 +187,7 @@ class InviteController extends BaseController
*/
public function show(int $id = 0): void
{
$this->requireAuth();
$this->requireRole(['super_admin']);
$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');
@@ -206,8 +208,7 @@ class InviteController extends BaseController
*/
public function revoke(int $id = 0): void
{
$this->requireAuth();
$this->requireRole(['super_admin']);
$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');
@@ -224,8 +225,7 @@ class InviteController extends BaseController
*/
public function regenerate(int $id = 0): void
{
$this->requireAuth();
$this->requireRole(['super_admin']);
$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');
@@ -376,6 +376,62 @@ class InviteController extends BaseController
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::execute('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
{