nis2-agile/application/controllers/ServicesController.php
DevEnv nis2-agile ef8b7a90e4 [FIX] Simulator: P.IVA checksum + ServicesController: sectorMap + role enum
- simulate-nis2.php: P.IVA demo corrette con checksum Luhn validi
  (09876543217, 07654321095, 05432109873, 99887766550)
- ServicesController::provision(): sectorMap rimappato a valori enum reali
  (es: 'energia'→'energy', 'finanza'→'banking', 'ict'→'ict_services')
- ServicesController::provision(): user_organizations.role 'super_admin'→'org_admin'
  (super_admin non è nel enum di user_organizations)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 09:38:02 +01:00

1456 lines
60 KiB
PHP

<?php
/**
* NIS2 Agile - Services Controller
*
* API pubblica per sistemi esterni (SIEM, GRC, 231 Agile, SustainAI, AllRisk).
* Autenticazione via API Key (header X-API-Key o Bearer nis2_xxx).
* Rate limiting: 100 req/h per chiave.
*
* Endpoint:
* GET /api/services/status
* GET /api/services/compliance-summary
* GET /api/services/risks/feed
* GET /api/services/incidents/feed
* GET /api/services/controls/status
* GET /api/services/assets/critical
* GET /api/services/suppliers/risk
* GET /api/services/policies/approved
* GET /api/services/openapi
*/
require_once __DIR__ . '/BaseController.php';
class ServicesController extends BaseController
{
// ─── API Key autenticata ──────────────────────────────────────────────
private ?array $apiKeyRecord = null;
private const RATE_LIMIT_DIR = '/tmp/nis2_api_ratelimit/';
private const RATE_LIMIT_MAX = 100; // req per finestra
private const RATE_LIMIT_WINDOW = 3600; // secondi (1 ora)
// ─── Versione API ─────────────────────────────────────────────────────
private const API_VERSION = '1.0.0';
// ══════════════════════════════════════════════════════════════════════
// AUTH API KEY
// ══════════════════════════════════════════════════════════════════════
/**
* Autentica la richiesta via API Key.
* Cerca in:
* 1. Header X-API-Key
* 2. Authorization: Bearer nis2_xxx
* 3. Query string ?api_key=nis2_xxx
*/
private function requireApiKey(string $scope = 'read:all'): void
{
$rawKey = null;
// 1. Header X-API-Key
$rawKey = $_SERVER['HTTP_X_API_KEY'] ?? null;
// 2. Bearer token
if (!$rawKey) {
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (str_starts_with($auth, 'Bearer nis2_')) {
$rawKey = substr($auth, 7);
}
}
// 3. Query string
if (!$rawKey) {
$rawKey = $_GET['api_key'] ?? null;
}
if (!$rawKey) {
$this->jsonError('API Key mancante', 401, 'MISSING_API_KEY');
}
// Hash SHA-256 della chiave
$keyHash = hash('sha256', $rawKey);
// Cerca in DB
$record = Database::fetchOne(
'SELECT ak.*, o.name as org_name, o.nis2_entity_type, o.sector
FROM api_keys ak
JOIN organizations o ON o.id = ak.organization_id
WHERE ak.key_hash = ? AND ak.is_active = 1
AND (ak.expires_at IS NULL OR ak.expires_at > NOW())',
[$keyHash]
);
if (!$record) {
$this->jsonError('API Key non valida o scaduta', 401, 'INVALID_API_KEY');
}
// Verifica scope — read:all è master scope che include tutto
$scopes = json_decode($record['scopes'], true) ?? [];
$hasAll = in_array('read:all', $scopes);
if (!$hasAll && !in_array($scope, $scopes)) {
$this->jsonError("Scope '{$scope}' non autorizzato per questa chiave", 403, 'SCOPE_DENIED');
}
// Rate limiting per API key
$this->checkRateLimit($record['key_prefix']);
// Aggiorna last_used_at (async: non blocchiamo su errore)
try {
Database::query(
'UPDATE api_keys SET last_used_at = NOW() WHERE id = ?',
[$record['id']]
);
} catch (Throwable $e) {
// non critico
}
$this->apiKeyRecord = $record;
$this->currentOrgId = (int) $record['organization_id'];
// ── Audit trail: ogni chiamata esterna autenticata viene registrata ──
$this->logExternalCall($scope);
}
/**
* Registra la chiamata esterna nell'audit trail con AuditService.
* action: api.external_call — severity: info (warning se scope sensibile)
*/
private function logExternalCall(string $scope): void
{
$rec = $this->apiKeyRecord;
$endpoint = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
$caller = $_SERVER['HTTP_X_CALLER'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? 'unknown');
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
AuditService::log(
orgId: $this->currentOrgId,
userId: null,
action: 'api.external_call',
entityType: 'api_key',
entityId: (int) $rec['id'],
details: [
'endpoint' => $endpoint,
'method' => $method,
'scope' => $scope,
'caller' => $caller,
'key_name' => $rec['name'] ?? 'n/a',
'key_prefix' => $rec['key_prefix'] ?? '',
'org_name' => $rec['org_name'] ?? '',
],
ipAddress: $_SERVER['REMOTE_ADDR'] ?? '',
userAgent: $caller,
severity: in_array($scope, ['read:incidents', 'read:all'], true) ? 'warning' : 'info',
performedBy: $rec['name'] ?? 'api_key'
);
}
/**
* Rate limiting file-based per API Key
*/
private function checkRateLimit(string $keyPrefix): void
{
if (!is_dir(self::RATE_LIMIT_DIR)) {
@mkdir(self::RATE_LIMIT_DIR, 0755, true);
}
$file = self::RATE_LIMIT_DIR . 'key_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $keyPrefix) . '.json';
$now = time();
$data = ['count' => 0, 'window_start' => $now];
if (file_exists($file)) {
$raw = @json_decode(file_get_contents($file), true);
if ($raw && ($now - $raw['window_start']) < self::RATE_LIMIT_WINDOW) {
$data = $raw;
}
}
if ($data['count'] >= self::RATE_LIMIT_MAX) {
$retryAfter = self::RATE_LIMIT_WINDOW - ($now - $data['window_start']);
header('Retry-After: ' . $retryAfter);
header('X-RateLimit-Limit: ' . self::RATE_LIMIT_MAX);
header('X-RateLimit-Remaining: 0');
$this->jsonError('Rate limit superato. Max ' . self::RATE_LIMIT_MAX . ' req/h per API key.', 429, 'RATE_LIMITED');
}
$data['count']++;
file_put_contents($file, json_encode($data), LOCK_EX);
header('X-RateLimit-Limit: ' . self::RATE_LIMIT_MAX);
header('X-RateLimit-Remaining: ' . (self::RATE_LIMIT_MAX - $data['count']));
}
/**
* Headers standard per tutte le risposte Services API
*/
private function setServiceHeaders(): void
{
header('X-NIS2-API-Version: ' . self::API_VERSION);
header('X-NIS2-Org-Id: ' . $this->currentOrgId);
}
// ══════════════════════════════════════════════════════════════════════
// ENDPOINT
// ══════════════════════════════════════════════════════════════════════
/**
* POST /api/services/token
*
* Token exchange: lg231 (e altri sistemi) inviano la loro API Key e
* ricevono un JWT temporaneo (TTL 15 min) per le chiamate successive.
* Pattern identico al CertiSource PAT → session.
*
* Request: X-API-Key: nis2_xxx (o body JSON {"api_key":"nis2_xxx"})
* Response: {"token":"eyJ...", "expires_in":900, "org_id":5, "scopes":[...]}
*/
public function token(): void
{
$this->requireApiKey('read:all');
$this->setServiceHeaders();
$rec = $this->apiKeyRecord;
$orgId = $this->currentOrgId;
$scopes = json_decode($rec['scopes'], true) ?? ['read:all'];
$ttl = 900; // 15 minuti
// Carica JWT config dal config applicativo
require_once APP_PATH . '/config/config.php';
$secret = defined('JWT_SECRET') ? JWT_SECRET : ($_ENV['JWT_SECRET'] ?? 'changeme');
$issuedAt = time();
$payload = [
'iss' => 'nis2.agile.software',
'sub' => 'api_key:' . $rec['id'],
'org_id' => $orgId,
'key_id' => (int) $rec['id'],
'scopes' => $scopes,
'caller' => $_SERVER['HTTP_X_CALLER'] ?? 'external',
'iat' => $issuedAt,
'exp' => $issuedAt + $ttl,
'type' => 'service_token',
];
// JWT manuale HS256 (stesso formato di BaseController)
$header = $this->base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$body = $this->base64UrlEncode(json_encode($payload));
$signature = $this->base64UrlEncode(hash_hmac('sha256', "$header.$body", $secret, true));
$jwt = "$header.$body.$signature";
// Audit
AuditService::log(
orgId: $orgId,
userId: null,
action: 'api.token_issued',
entityType: 'api_key',
entityId: (int) $rec['id'],
details: [
'key_name' => $rec['name'] ?? 'n/a',
'caller' => $_SERVER['HTTP_X_CALLER'] ?? 'external',
'ttl' => $ttl,
'scopes' => $scopes,
],
ipAddress: $_SERVER['REMOTE_ADDR'] ?? '',
severity: 'info',
performedBy: $rec['name'] ?? 'api_key'
);
$this->jsonSuccess([
'token' => $jwt,
'token_type' => 'Bearer',
'expires_in' => $ttl,
'expires_at' => date('c', $issuedAt + $ttl),
'org_id' => $orgId,
'org_name' => $rec['org_name'] ?? '',
'scopes' => $scopes,
]);
}
/**
* POST /api/services/sso
*
* Single Sign-On federato: lg231 (o altro sistema Agile) invia
* l'identità del suo utente e NIS2 emette un JWT di sessione valido
* nell'app NIS2 — senza che l'utente debba fare login separato.
*
* Request (X-API-Key + JSON body):
* {
* "user_email": "tizio@azienda.it",
* "user_name": "Mario Rossi", // opzionale
* "user_role": "compliance_manager", // ruolo NIS2 desiderato
* "caller_system": "lg231", // sistema chiamante
* "caller_user_id": 42, // ID utente nel sistema chiamante
* "responsibilities": [ // facoltativo
* {"area": "MOG 231", "scope": "art.24-bis"},
* {"area": "OdV", "scope": "monitoraggio"}
* ]
* }
*
* Response:
* {
* "token": "eyJ...", // JWT NIS2 valido 2h (usa nell'Authorization header)
* "user_id": 12, // ID utente NIS2 (creato se non esiste)
* "org_id": 5,
* "role": "compliance_manager",
* "redirect_url": "https://nis2.agile.software/dashboard.html"
* }
*
* Audit: logga l'accesso SSO con identità completa e responsabilità.
*/
public function sso(): void
{
$this->requireApiKey('sso:login');
$this->setServiceHeaders();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$email = trim($body['user_email'] ?? '');
$name = trim($body['user_name'] ?? '');
$role = $body['user_role'] ?? 'auditor';
$caller = $body['caller_system'] ?? ($_SERVER['HTTP_X_CALLER'] ?? 'external');
$callerUserId = $body['caller_user_id'] ?? null;
$responsibilities = $body['responsibilities'] ?? [];
if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->jsonError('user_email mancante o non valido', 400, 'INVALID_EMAIL');
}
// Ruoli NIS2 consentiti via SSO (non super_admin)
$allowedRoles = ['compliance_manager', 'auditor', 'board_member', 'employee', 'consultant'];
if (!in_array($role, $allowedRoles, true)) {
$role = 'auditor';
}
$db = Database::getInstance();
$orgId = $this->currentOrgId;
// Trova o crea l'utente NIS2 per questa email
$user = Database::fetchOne('SELECT * FROM users WHERE email = ? LIMIT 1', [$email]);
if (!$user) {
// Crea utente SSO (senza password — accede solo via token)
Database::query(
'INSERT INTO users (email, password_hash, full_name, role, is_active)
VALUES (?, ?, ?, ?, 1)',
[$email, '', $name ?: $email, $role]
);
$userId = (int) Database::lastInsertId();
} else {
$userId = (int) $user['id'];
}
// Assicura membership org
$membership = Database::fetchOne(
'SELECT id FROM user_organizations WHERE user_id = ? AND organization_id = ? LIMIT 1',
[$userId, $orgId]
);
if (!$membership) {
Database::query(
'INSERT INTO user_organizations (user_id, organization_id, role) VALUES (?, ?, ?)',
[$userId, $orgId, $role]
);
}
// Emetti JWT NIS2 (TTL 2h, stesso formato BaseController)
require_once APP_PATH . '/config/config.php';
$secret = defined('JWT_SECRET') ? JWT_SECRET : ($_ENV['JWT_SECRET'] ?? 'changeme');
$issuedAt = time();
$ttl = 7200; // 2 ore
$payload = [
'iss' => 'nis2.agile.software',
'sub' => $userId,
'org_id' => $orgId,
'role' => $role,
'sso' => true,
'sso_caller' => $caller,
'sso_caller_uid' => $callerUserId,
'responsibilities'=> $responsibilities,
'iat' => $issuedAt,
'exp' => $issuedAt + $ttl,
'type' => 'access',
];
$header = $this->base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$jwtBody = $this->base64UrlEncode(json_encode($payload));
$signature = $this->base64UrlEncode(hash_hmac('sha256', "$header.$jwtBody", $secret, true));
$jwt = "$header.$jwtBody.$signature";
// Audit trail — traccia SSO con responsabilità
AuditService::log(
orgId: $orgId,
userId: $userId,
action: 'auth.sso_login',
entityType: 'user',
entityId: $userId,
details: [
'caller_system' => $caller,
'caller_user_id' => $callerUserId,
'role_granted' => $role,
'responsibilities' => $responsibilities,
'email' => $email,
'user_created' => !isset($user),
],
ipAddress: $_SERVER['REMOTE_ADDR'] ?? '',
severity: 'warning', // SSO login è sempre warning per audit
performedBy: $caller . ':' . $email
);
$this->jsonSuccess([
'token' => $jwt,
'token_type' => 'Bearer',
'expires_in' => $ttl,
'expires_at' => date('c', $issuedAt + $ttl),
'user_id' => $userId,
'user_email' => $email,
'org_id' => $orgId,
'role' => $role,
'redirect_url' => 'https://nis2.agile.software/dashboard.html',
'sso_caller' => $caller,
]);
}
/**
* POST /api/services/provision
*
* Provisioning automatico B2B: lg231 (o altro sistema Agile partner)
* acquista una licenza NIS2 per conto di un cliente e invia i dati.
* NIS2 crea automaticamente:
* - Organizzazione con tutti i dati aziendali
* - Utente admin (super_admin) con password temporanea
* - API Key con scope admin:org per accesso completo machine-to-machine
* - Welcome email all'admin
*
* Auth: X-Provision-Secret header (master secret, NON org-specific)
*
* Request body (tutti i campi di lg231 companies):
* {
* "company": {
* "ragione_sociale": "Acme S.r.l.", // REQUIRED
* "partita_iva": "02345678901", // REQUIRED — usato come unique key
* "forma_giuridica": "S.r.l.",
* "codice_fiscale": "ACMESRL02345678901",
* "ateco_code": "62.01.00",
* "ateco_description": "Produzione di software",
* "sede_legale": "Via Roma 1, 20100 Milano MI",
* "pec": "acme@pec.it",
* "telefono": "+39 02 1234567",
* "fatturato_annuo": 5000000,
* "numero_dipendenti": 45,
* "sector": "ict", // NIS2 sector key
* "nis2_entity_type": "important" // essential/important/voluntary
* },
* "admin": {
* "email": "ciso@acme.it", // REQUIRED
* "first_name": "Mario", // REQUIRED
* "last_name": "Rossi", // REQUIRED
* "phone": "+39 347 1234567",
* "title": "CISO"
* },
* "license": {
* "plan": "professional", // essentials/professional/enterprise
* "duration_months": 12,
* "lg231_order_id": "ORD-2026-0042",
* "purchased_at": "2026-03-07T13:00:00Z"
* },
* "caller": {
* "system": "lg231",
* "tenant_id": 1,
* "company_id": 42,
* "callback_url": "https://lg231.agile.software/api/integrations/nis2-callback"
* }
* }
*
* Response:
* {
* "org_id": 8,
* "org_name": "Acme S.r.l.",
* "admin_user_id": 15,
* "admin_email": "ciso@acme.it",
* "temp_password": "NIS2_xxxxx", // cambio obbligatorio al primo login
* "api_key": "nis2_xxxxxxxx...", // salva in lg231 companies.metadata.nis2_api_key
* "access_token": "eyJ...", // JWT 2h per uso immediato
* "dashboard_url": "https://nis2.agile.software/dashboard.html"
* }
*/
public function provision(): void
{
$this->setServiceHeaders();
// ── 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'] ?? '');
// Supporta sia full_name che first_name+last_name
$adminFullName = trim($admin['full_name']
?? trim(($admin['first_name'] ?? '') . ' ' . ($admin['last_name'] ?? '')));
if (!$ragioneSociale) $this->jsonError('company.ragione_sociale obbligatorio', 400, 'MISSING_FIELD');
if (strlen($partitaIva) !== 11) $this->jsonError('company.partita_iva non valida (11 cifre)', 400, 'INVALID_VAT');
if (!filter_var($adminEmail, FILTER_VALIDATE_EMAIL)) $this->jsonError('admin.email non valida', 400, 'INVALID_EMAIL');
if (!$adminFullName) $this->jsonError('admin.full_name obbligatorio', 400, 'MISSING_FIELD');
$db = Database::getInstance();
// ── 3. Idempotency: se org con stessa P.IVA esiste già → aggiorna e restituisce ──
$existing = Database::fetchOne(
'SELECT id FROM organizations WHERE vat_number = ? LIMIT 1',
[$partitaIva]
);
// Mappa sector lg231 → NIS2 (valori enum: energy,transport,banking,health,water,
// digital_infra,public_admin,manufacturing,postal,chemical,food,waste,
// ict_services,digital_providers,space,research,other)
$sectorMap = [
'energia' => 'energy', 'energia_elettrica' => 'energy',
'trasporti' => 'transport', 'sanità' => 'health', 'sanita' => 'health',
'finanza' => 'banking', 'finance' => 'banking',
'acqua' => 'water',
'ict' => 'ict_services', 'digital' => 'digital_providers',
'manifattura'=> 'manufacturing',
'gestione_rifiuti' => 'waste',
'consulting' => 'other', 'servizi_professionali' => 'other', 'social' => 'other',
];
$rawSector = strtolower($company['sector'] ?? 'ict');
$nis2Sector = $sectorMap[$rawSector] ?? $rawSector;
$rawEntityType = $company['nis2_entity_type'] ?? 'important';
$nis2EntityType = match($rawEntityType) {
'essential' => 'essential',
'voluntary' => 'not_applicable',
default => 'important',
};
if ($existing) {
$orgId = (int) $existing['id'];
} else {
// ── 4. Crea organizzazione ───────────────────────────────────
Database::query(
'INSERT INTO organizations
(name, vat_number, fiscal_code,
annual_turnover_eur, employee_count,
sector, entity_type, is_active,
provisioned_by, provisioned_at, license_plan, license_expires_at,
license_max_users, lg231_company_id, lg231_order_id)
VALUES (?,?,?,?,?,?,?,1,?,NOW(),?,?,?,?,?)',
[
$ragioneSociale,
$partitaIva,
$company['codice_fiscale'] ?? null,
$company['fatturato_annuo'] ?? null,
$company['numero_dipendenti'] ?? null,
$nis2Sector,
$nis2EntityType,
$caller['system'] ?? 'external',
$license['plan'] ?? 'professional',
isset($license['duration_months'])
? date('Y-m-d', strtotime('+' . (int)$license['duration_months'] . ' months'))
: date('Y-m-d', strtotime('+12 months')),
$resolvedInvite ? ($resolvedInvite['max_users_per_org'] !== null ? (int)$resolvedInvite['max_users_per_org'] : null) : null,
$caller['company_id'] ?? null,
$license['lg231_order_id'] ?? null,
]
);
$orgId = (int) Database::lastInsertId();
}
// ── 5. Crea o trova utente admin ─────────────────────────────────
$existingUser = Database::fetchOne('SELECT id FROM users WHERE email = ? LIMIT 1', [$adminEmail]);
$tempPassword = 'NIS2_' . bin2hex(random_bytes(6));
$passwordHash = password_hash($tempPassword, PASSWORD_BCRYPT, ['cost' => 12]);
if ($existingUser) {
$userId = (int) $existingUser['id'];
} else {
Database::query(
'INSERT INTO users (email, password_hash, full_name, role, is_active,
phone, job_title, must_change_password)
VALUES (?,?,?,\'org_admin\',1,?,?,1)',
[$adminEmail, $passwordHash, $adminFullName,
$admin['phone'] ?? null, $admin['title'] ?? null]
);
$userId = (int) Database::lastInsertId();
}
// Assicura membership
$mem = Database::fetchOne(
'SELECT id FROM user_organizations WHERE user_id=? AND organization_id=? LIMIT 1',
[$userId, $orgId]
);
if (!$mem) {
Database::query(
'INSERT INTO user_organizations (user_id, organization_id, role, is_primary) VALUES (?,?,\'org_admin\',1)',
[$userId, $orgId]
);
}
// ── 6. Genera API Key con scope admin:org ────────────────────────
// scope admin:org include: read:all + write:all + admin:org
$rawKey = 'nis2_' . bin2hex(random_bytes(20));
$keyPrefix = substr($rawKey, 0, 12);
$keyHash = hash('sha256', $rawKey);
$keyName = 'lg231-integration-' . $partitaIva;
$expiresAt = $license['duration_months']
? date('Y-m-d H:i:s', strtotime('+' . (int)$license['duration_months'] . ' months'))
: date('Y-m-d H:i:s', strtotime('+12 months'));
// Revoca eventuali chiavi lg231-integration precedenti (idempotency)
Database::query(
'UPDATE api_keys SET is_active=0 WHERE organization_id=? AND name LIKE \'lg231-integration-%\'',
[$orgId]
);
Database::query(
'INSERT INTO api_keys (organization_id, name, key_prefix, key_hash, scopes, is_active, expires_at, created_by)
VALUES (?,?,?,?,?,1,?,?)',
[
$orgId, $keyName, $keyPrefix, $keyHash,
json_encode(['read:all', 'write:all', 'admin:org', 'sso:login']),
$expiresAt,
'provision:' . ($caller['system'] ?? 'external'),
]
);
// ── 7. JWT accesso immediato (2h) ────────────────────────────────
$issuedAt = time();
$jwtPayload = [
'iss' => 'nis2.agile.software',
'sub' => $userId,
'org_id' => $orgId,
'role' => 'super_admin',
'provisioned' => true,
'iat' => $issuedAt,
'exp' => $issuedAt + 7200,
'type' => 'access',
];
$h = $this->base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$p = $this->base64UrlEncode(json_encode($jwtPayload));
$sig = $this->base64UrlEncode(hash_hmac('sha256', "$h.$p", JWT_SECRET, true));
$jwt = "$h.$p.$sig";
// ── 8. Audit trail ───────────────────────────────────────────────
AuditService::log(
orgId: $orgId,
userId: $userId,
action: 'org.provisioned',
entityType: 'organization',
entityId: $orgId,
details: [
'caller_system' => $caller['system'] ?? 'external',
'caller_company' => $caller['company_id'] ?? null,
'lg231_order_id' => $license['lg231_order_id'] ?? null,
'license_plan' => $license['plan'] ?? 'professional',
'entity_type' => $nis2EntityType,
'sector' => $nis2Sector,
'api_key_prefix' => $keyPrefix,
'admin_email' => $adminEmail,
],
ipAddress: $_SERVER['REMOTE_ADDR'] ?? '',
severity: 'critical',
performedBy: ($caller['system'] ?? 'external') . ':provision'
);
// ── 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([
'event' => 'nis2.provisioned',
'org_id' => $orgId,
'company_id' => $caller['company_id'] ?? null,
'api_key' => $rawKey,
'provisioned_at' => date('c'),
]);
$cbSig = 'sha256=' . hash_hmac('sha256', $callbackPayload, PROVISION_SECRET);
// Fire-and-forget (ignora errori)
@file_get_contents($callbackUrl, false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nX-NIS2-Signature: $cbSig\r\n",
'content' => $callbackPayload,
'timeout' => 3,
'ignore_errors' => true,
]
]));
}
$this->jsonSuccess([
'provisioned' => true,
'org_id' => $orgId,
'org_name' => $ragioneSociale,
'vat_number' => $partitaIva,
'entity_type' => $nis2EntityType,
'sector' => $nis2Sector,
'admin_user_id' => $userId,
'admin_email' => $adminEmail,
// Credenziali machine-to-machine
'api_key' => $rawKey,
'api_key_scopes'=> ['read:all', 'write:all', 'admin:org', 'sso:login'],
'api_key_expires_at' => $expiresAt,
// JWT per apertura immediata UI
'access_token' => $jwt,
'token_type' => 'Bearer',
'token_expires_in' => 7200,
// Credenziali primo accesso admin (cambio obbligatorio)
'temp_password' => !$existingUser ? $tempPassword : null,
'must_change_password' => !isset($existingUser),
// 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,
]);
}
/**
* GET /api/services/status
* Health check + info piattaforma. Nessuna auth richiesta.
*/
public function status(): void
{
$this->setServiceHeaders();
$this->jsonSuccess([
'platform' => 'NIS2 Agile',
'version' => self::API_VERSION,
'status' => 'operational',
'regulation' => ['EU 2022/2555', 'D.Lgs. 138/2024', 'ISO 27001/27005'],
'ai_provider' => 'Anthropic Claude',
'timestamp' => date('c'),
'endpoints' => [
'compliance_summary' => '/api/services/compliance-summary',
'risks_feed' => '/api/services/risks/feed',
'incidents_feed' => '/api/services/incidents/feed',
'controls_status' => '/api/services/controls/status',
'critical_assets' => '/api/services/assets/critical',
'suppliers_risk' => '/api/services/suppliers/risk',
'approved_policies' => '/api/services/policies/approved',
'openapi' => '/api/services/openapi',
'docs' => '/docs/api',
],
], 'NIS2 Agile Services API - Operational');
}
/**
* GET /api/services/compliance-summary
* Compliance score aggregato per dominio Art.21.
* Scope: read:compliance
*/
public function complianceSummary(): void
{
$this->requireApiKey('read:compliance');
$this->setServiceHeaders();
$orgId = $this->currentOrgId;
// Score da assessment più recente completato
$assessment = Database::fetchOne(
'SELECT * FROM assessments WHERE organization_id = ? AND status = "completed"
ORDER BY completed_at DESC LIMIT 1',
[$orgId]
);
$overallScore = null;
$domainScores = [];
$recommendations = [];
if ($assessment) {
// Calcola score per dominio (10 categorie Art.21)
$responses = Database::fetchAll(
'SELECT ar.*, q.category, q.weight
FROM assessment_responses ar
JOIN (
SELECT question_code, category, weight
FROM (
SELECT question_code,
JSON_UNQUOTE(JSON_EXTRACT(question_data, "$.category")) as category,
CAST(JSON_UNQUOTE(JSON_EXTRACT(question_data, "$.weight")) AS DECIMAL(3,1)) as weight
FROM assessment_responses
WHERE assessment_id = ?
) t GROUP BY question_code
) q ON q.question_code = ar.question_code
WHERE ar.assessment_id = ?',
[$assessment['id'], $assessment['id']]
);
// Semplificato: score per categoria
$byCategory = [];
foreach ($responses as $r) {
$cat = $r['category'] ?? 'uncategorized';
if (!isset($byCategory[$cat])) {
$byCategory[$cat] = ['total' => 0, 'count' => 0];
}
$val = (int) ($r['response_value'] ?? 0);
$byCategory[$cat]['total'] += $val;
$byCategory[$cat]['count']++;
}
$totalScore = 0;
$catCount = 0;
foreach ($byCategory as $cat => $data) {
$score = $data['count'] > 0
? round(($data['total'] / ($data['count'] * 4)) * 100)
: 0;
$domainScores[] = [
'domain' => $cat,
'score' => $score,
'status' => $score >= 70 ? 'compliant' : ($score >= 40 ? 'partial' : 'gap'),
];
$totalScore += $score;
$catCount++;
}
$overallScore = $catCount > 0 ? round($totalScore / $catCount) : 0;
// Raccomandazioni AI se disponibili
if (!empty($assessment['ai_analysis'])) {
$aiData = json_decode($assessment['ai_analysis'], true);
$recommendations = $aiData['recommendations'] ?? [];
}
}
// Risk summary
$riskStats = Database::fetchOne(
'SELECT
COUNT(*) as total,
SUM(CASE WHEN status = "open" THEN 1 ELSE 0 END) as open_count,
SUM(CASE WHEN risk_level IN ("high","critical") AND status = "open" THEN 1 ELSE 0 END) as high_critical,
SUM(CASE WHEN status = "mitigated" THEN 1 ELSE 0 END) as mitigated
FROM risks WHERE organization_id = ?',
[$orgId]
);
// Incident summary
$incidentStats = Database::fetchOne(
'SELECT
COUNT(*) as total,
SUM(CASE WHEN status = "open" OR status = "investigating" THEN 1 ELSE 0 END) as open_count,
SUM(CASE WHEN is_significant = 1 THEN 1 ELSE 0 END) as significant,
SUM(CASE WHEN early_warning_sent = 1 THEN 1 ELSE 0 END) as notified_acn
FROM incidents WHERE organization_id = ?',
[$orgId]
);
// Policy summary
$policyStats = Database::fetchOne(
'SELECT
COUNT(*) as total,
SUM(CASE WHEN status = "approved" THEN 1 ELSE 0 END) as approved,
SUM(CASE WHEN status IN ("draft","review") THEN 1 ELSE 0 END) as pending
FROM policies WHERE organization_id = ?',
[$orgId]
);
$org = Database::fetchOne(
'SELECT name, nis2_entity_type, sector, employee_count FROM organizations WHERE id = ?',
[$orgId]
);
$this->jsonSuccess([
'organization' => [
'name' => $org['name'],
'entity_type' => $org['nis2_entity_type'],
'sector' => $org['sector'],
],
'overall_score' => $overallScore,
'score_label' => $this->scoreLabel($overallScore),
'domain_scores' => $domainScores,
'assessment' => $assessment ? [
'id' => $assessment['id'],
'completed_at' => $assessment['completed_at'],
'status' => $assessment['status'],
] : null,
'risks' => [
'total' => (int)($riskStats['total'] ?? 0),
'open' => (int)($riskStats['open_count'] ?? 0),
'high_critical'=> (int)($riskStats['high_critical'] ?? 0),
'mitigated' => (int)($riskStats['mitigated'] ?? 0),
],
'incidents' => [
'total' => (int)($incidentStats['total'] ?? 0),
'open' => (int)($incidentStats['open_count'] ?? 0),
'significant' => (int)($incidentStats['significant'] ?? 0),
'notified_acn' => (int)($incidentStats['notified_acn'] ?? 0),
],
'policies' => [
'total' => (int)($policyStats['total'] ?? 0),
'approved' => (int)($policyStats['approved'] ?? 0),
'pending' => (int)($policyStats['pending'] ?? 0),
],
'top_recommendations' => array_slice($recommendations, 0, 5),
'generated_at' => date('c'),
]);
}
/**
* GET /api/services/risks/feed
* Feed rischi filtrabili.
* Scope: read:risks
* Query: ?level=high,critical &from=2026-01-01 &area=it &limit=50
*/
public function risksFeed(): void
{
$this->requireApiKey('read:risks');
$this->setServiceHeaders();
$orgId = $this->currentOrgId;
$where = 'r.organization_id = ? AND r.deleted_at IS NULL';
$params = [$orgId];
if (!empty($_GET['level'])) {
$levels = array_filter(explode(',', $_GET['level']));
$placeholders = implode(',', array_fill(0, count($levels), '?'));
$where .= " AND r.risk_level IN ({$placeholders})";
$params = array_merge($params, $levels);
}
if (!empty($_GET['area'])) {
$where .= ' AND r.category = ?';
$params[] = $_GET['area'];
}
if (!empty($_GET['status'])) {
$where .= ' AND r.status = ?';
$params[] = $_GET['status'];
}
if (!empty($_GET['from'])) {
$where .= ' AND r.created_at >= ?';
$params[] = $_GET['from'] . ' 00:00:00';
}
$limit = min(200, max(1, (int)($_GET['limit'] ?? 50)));
$risks = Database::fetchAll(
"SELECT r.id, r.title, r.description, r.category, r.likelihood,
r.impact, r.inherent_risk_score, r.risk_level, r.status,
r.treatment_plan, r.owner_name, r.residual_risk_score,
r.created_at, r.updated_at
FROM risks r
WHERE {$where}
ORDER BY r.inherent_risk_score DESC, r.created_at DESC
LIMIT {$limit}",
$params
);
$total = Database::count('risks', 'organization_id = ? AND deleted_at IS NULL', [$orgId]);
$this->jsonSuccess([
'risks' => $risks,
'total' => $total,
'fetched' => count($risks),
'filters' => [
'level' => $_GET['level'] ?? null,
'area' => $_GET['area'] ?? null,
'status' => $_GET['status'] ?? null,
'from' => $_GET['from'] ?? null,
],
'generated_at' => date('c'),
]);
}
/**
* GET /api/services/incidents/feed
* Feed incidenti Art.23 filtrabili.
* Scope: read:incidents
* Query: ?status=open &severity=high,critical &from=2026-01-01 &significant=1
*/
public function incidentsFeed(): void
{
$this->requireApiKey('read:incidents');
$this->setServiceHeaders();
$orgId = $this->currentOrgId;
$where = 'organization_id = ?';
$params = [$orgId];
if (!empty($_GET['status'])) {
$where .= ' AND status = ?';
$params[] = $_GET['status'];
}
if (!empty($_GET['severity'])) {
$severities = array_filter(explode(',', $_GET['severity']));
$ph = implode(',', array_fill(0, count($severities), '?'));
$where .= " AND severity IN ({$ph})";
$params = array_merge($params, $severities);
}
if (!empty($_GET['significant'])) {
$where .= ' AND is_significant = 1';
}
if (!empty($_GET['from'])) {
$where .= ' AND detected_at >= ?';
$params[] = $_GET['from'] . ' 00:00:00';
}
$limit = min(200, max(1, (int)($_GET['limit'] ?? 50)));
$incidents = Database::fetchAll(
"SELECT id, title, classification, severity, status, is_significant,
detected_at, contained_at, resolved_at,
early_warning_sent, early_warning_sent_at,
notification_sent, notification_sent_at,
final_report_sent, final_report_sent_at,
notification_deadline, final_report_deadline,
affected_systems, impact_description,
created_at, updated_at
FROM incidents
WHERE {$where}
ORDER BY detected_at DESC
LIMIT {$limit}",
$params
);
// Aggiungi stato scadenze Art.23
$now = time();
foreach ($incidents as &$inc) {
$detectedTs = strtotime($inc['detected_at']);
$inc['art23_status'] = [
'early_warning_24h' => [
'required' => (bool)$inc['is_significant'],
'deadline' => date('c', $detectedTs + 86400),
'sent' => (bool)$inc['early_warning_sent'],
'overdue' => !$inc['early_warning_sent'] && $now > $detectedTs + 86400,
],
'notification_72h' => [
'required' => (bool)$inc['is_significant'],
'deadline' => date('c', $detectedTs + 259200),
'sent' => (bool)$inc['notification_sent'],
'overdue' => !$inc['notification_sent'] && $now > $detectedTs + 259200,
],
'final_report_30d' => [
'required' => (bool)$inc['is_significant'],
'deadline' => date('c', $detectedTs + 2592000),
'sent' => (bool)$inc['final_report_sent'],
'overdue' => !$inc['final_report_sent'] && $now > $detectedTs + 2592000,
],
];
}
unset($inc);
$total = Database::count('incidents', 'organization_id = ?', [$orgId]);
$this->jsonSuccess([
'incidents' => $incidents,
'total' => $total,
'fetched' => count($incidents),
'generated_at' => date('c'),
]);
}
/**
* GET /api/services/controls/status
* Stato controlli di sicurezza Art.21 per dominio.
* Scope: read:compliance
*/
public function controlsStatus(): void
{
$this->requireApiKey('read:compliance');
$this->setServiceHeaders();
$orgId = $this->currentOrgId;
$controls = Database::fetchAll(
'SELECT id, control_code, title, category, status,
implementation_notes, due_date, updated_at
FROM compliance_controls
WHERE organization_id = ?
ORDER BY category, control_code',
[$orgId]
);
// Raggruppa per categoria
$byCategory = [];
foreach ($controls as $ctrl) {
$cat = $ctrl['category'] ?? 'uncategorized';
if (!isset($byCategory[$cat])) {
$byCategory[$cat] = [
'category' => $cat,
'controls' => [],
'stats' => ['total' => 0, 'implemented' => 0, 'partial' => 0, 'planned' => 0, 'not_applicable' => 0],
];
}
$byCategory[$cat]['controls'][] = $ctrl;
$byCategory[$cat]['stats']['total']++;
$s = $ctrl['status'] ?? 'not_applicable';
if (isset($byCategory[$cat]['stats'][$s])) {
$byCategory[$cat]['stats'][$s]++;
}
}
// Score per categoria
foreach ($byCategory as &$cat) {
$t = $cat['stats']['total'];
$i = $cat['stats']['implemented'];
$p = $cat['stats']['partial'];
$cat['score'] = $t > 0 ? round((($i + $p * 0.5) / $t) * 100) : 0;
}
unset($cat);
$totals = [
'total' => count($controls),
'implemented' => 0,
'partial' => 0,
'planned' => 0,
'not_applicable' => 0,
];
foreach ($controls as $ctrl) {
$s = $ctrl['status'] ?? 'not_applicable';
if (isset($totals[$s])) $totals[$s]++;
}
$totals['overall_score'] = $totals['total'] > 0
? round((($totals['implemented'] + $totals['partial'] * 0.5) / $totals['total']) * 100)
: 0;
$this->jsonSuccess([
'summary' => $totals,
'by_category' => array_values($byCategory),
'generated_at' => date('c'),
]);
}
/**
* GET /api/services/assets/critical
* Asset critici e dipendenze.
* Scope: read:assets
* Query: ?type=server,network &criticality=high,critical
*/
public function assetsCritical(): void
{
$this->requireApiKey('read:assets');
$this->setServiceHeaders();
$orgId = $this->currentOrgId;
$where = 'organization_id = ?';
$params = [$orgId];
if (!empty($_GET['type'])) {
$types = array_filter(explode(',', $_GET['type']));
$ph = implode(',', array_fill(0, count($types), '?'));
$where .= " AND asset_type IN ({$ph})";
$params = array_merge($params, $types);
}
if (!empty($_GET['criticality'])) {
$crits = array_filter(explode(',', $_GET['criticality']));
$ph = implode(',', array_fill(0, count($crits), '?'));
$where .= " AND criticality IN ({$ph})";
$params = array_merge($params, $crits);
} else {
// Default: solo high e critical
$where .= " AND criticality IN ('high','critical')";
}
$assets = Database::fetchAll(
"SELECT id, name, asset_type, criticality, status,
owner_name, location, ip_address, description,
dependencies, created_at
FROM assets
WHERE {$where}
ORDER BY FIELD(criticality,'critical','high','medium','low'), name",
$params
);
$this->jsonSuccess([
'assets' => $assets,
'total' => count($assets),
'generated_at' => date('c'),
]);
}
/**
* GET /api/services/suppliers/risk
* Supplier risk overview (supply chain security).
* Scope: read:supply_chain
* Query: ?risk_level=high,critical &status=active
*/
public function suppliersRisk(): void
{
$this->requireApiKey('read:supply_chain');
$this->setServiceHeaders();
$orgId = $this->currentOrgId;
$where = 's.organization_id = ? AND s.deleted_at IS NULL';
$params = [$orgId];
if (!empty($_GET['risk_level'])) {
$levels = array_filter(explode(',', $_GET['risk_level']));
$ph = implode(',', array_fill(0, count($levels), '?'));
$where .= " AND s.risk_level IN ({$ph})";
$params = array_merge($params, $levels);
}
if (!empty($_GET['status'])) {
$where .= ' AND s.status = ?';
$params[] = $_GET['status'];
}
$suppliers = Database::fetchAll(
"SELECT s.id, s.company_name, s.category, s.risk_level, s.status,
s.last_assessment_date, s.assessment_score, s.contact_email,
s.services_provided, s.critical_dependency,
s.created_at, s.updated_at
FROM suppliers s
WHERE {$where}
ORDER BY FIELD(s.risk_level,'critical','high','medium','low'), s.company_name",
$params
);
$stats = Database::fetchOne(
"SELECT
COUNT(*) as total,
SUM(CASE WHEN risk_level IN ('high','critical') AND deleted_at IS NULL THEN 1 ELSE 0 END) as high_risk,
SUM(CASE WHEN critical_dependency = 1 AND deleted_at IS NULL THEN 1 ELSE 0 END) as critical_deps,
SUM(CASE WHEN last_assessment_date IS NULL AND deleted_at IS NULL THEN 1 ELSE 0 END) as unassessed
FROM suppliers WHERE organization_id = ?",
[$orgId]
);
$this->jsonSuccess([
'summary' => $stats,
'suppliers' => $suppliers,
'generated_at' => date('c'),
]);
}
/**
* GET /api/services/policies/approved
* Policy approvate con metadati (no contenuto full per default).
* Scope: read:policies
* Query: ?category=... &include_content=1
*/
public function policiesApproved(): void
{
$this->requireApiKey('read:policies');
$this->setServiceHeaders();
$orgId = $this->currentOrgId;
$includeContent = !empty($_GET['include_content']);
$select = $includeContent
? 'id, title, category, nis2_article, status, version, approved_at, next_review_date, ai_generated, content'
: 'id, title, category, nis2_article, status, version, approved_at, next_review_date, ai_generated';
$where = 'organization_id = ? AND status = "approved"';
$params = [$orgId];
if (!empty($_GET['category'])) {
$where .= ' AND category = ?';
$params[] = $_GET['category'];
}
$policies = Database::fetchAll(
"SELECT {$select} FROM policies WHERE {$where} ORDER BY category, title",
$params
);
$this->jsonSuccess([
'policies' => $policies,
'total' => count($policies),
'generated_at' => date('c'),
]);
}
/**
* GET /api/services/openapi
* Specifica OpenAPI 3.0 JSON per questa API.
*/
public function openapi(): void
{
$this->setServiceHeaders();
header('Content-Type: application/json; charset=utf-8');
$spec = [
'openapi' => '3.0.3',
'info' => [
'title' => 'NIS2 Agile Services API',
'description' => 'API pubblica per integrazione con sistemi esterni. Espone dati di compliance NIS2, rischi, incidenti, controlli, asset e supply chain.',
'version' => self::API_VERSION,
'contact' => ['email' => 'presidenza@agile.software'],
'license' => ['name' => 'Proprietary', 'url' => 'https://agile.software'],
],
'servers' => [
['url' => 'https://nis2.agile.software', 'description' => 'Production'],
],
'security' => [
['ApiKeyHeader' => []],
['BearerToken' => []],
],
'components' => [
'securitySchemes' => [
'ApiKeyHeader' => ['type' => 'apiKey', 'in' => 'header', 'name' => 'X-API-Key'],
'BearerToken' => ['type' => 'http', 'scheme' => 'bearer', 'bearerFormat' => 'nis2_xxxxx'],
],
],
'paths' => [
'/api/services/status' => [
'get' => [
'summary' => 'Status piattaforma',
'description' => 'Health check. Nessuna autenticazione richiesta.',
'security' => [],
'responses' => ['200' => ['description' => 'Platform operational']],
'tags' => ['System'],
],
],
'/api/services/compliance-summary' => [
'get' => [
'summary' => 'Compliance summary',
'description' => 'Score aggregato per dominio Art.21, risk/incident/policy stats.',
'responses' => ['200' => ['description' => 'Compliance summary'], '401' => ['description' => 'API Key mancante']],
'tags' => ['Compliance'],
],
],
'/api/services/risks/feed' => [
'get' => [
'summary' => 'Risk feed',
'description' => 'Feed rischi filtrabili per level, area, status, data.',
'parameters' => [
['name' => 'level', 'in' => 'query', 'schema' => ['type' => 'string'], 'example' => 'high,critical'],
['name' => 'area', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'from', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date']],
['name' => 'limit', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 50, 'maximum' => 200]],
],
'responses' => ['200' => ['description' => 'List of risks']],
'tags' => ['Risks'],
],
],
'/api/services/incidents/feed' => [
'get' => [
'summary' => 'Incident feed Art.23',
'description' => 'Feed incidenti con stato scadenze Art.23 (24h/72h/30d).',
'parameters' => [
['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'severity', 'in' => 'query', 'schema' => ['type' => 'string'], 'example' => 'high,critical'],
['name' => 'significant', 'in' => 'query', 'schema' => ['type' => 'integer', 'enum' => [0, 1]]],
['name' => 'from', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date']],
],
'responses' => ['200' => ['description' => 'List of incidents']],
'tags' => ['Incidents'],
],
],
'/api/services/controls/status' => [
'get' => [
'summary' => 'Controlli Art.21 status',
'description' => 'Stato implementazione controlli per dominio di sicurezza.',
'responses' => ['200' => ['description' => 'Controls by domain']],
'tags' => ['Compliance'],
],
],
'/api/services/assets/critical' => [
'get' => [
'summary' => 'Asset critici',
'description' => 'Inventario asset con criticality high/critical.',
'parameters' => [
['name' => 'type', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'criticality', 'in' => 'query', 'schema' => ['type' => 'string']],
],
'responses' => ['200' => ['description' => 'Critical assets']],
'tags' => ['Assets'],
],
],
'/api/services/suppliers/risk' => [
'get' => [
'summary' => 'Supplier risk overview',
'description' => 'Supply chain risk: fornitori per livello rischio.',
'parameters' => [
['name' => 'risk_level', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string']],
],
'responses' => ['200' => ['description' => 'Suppliers risk data']],
'tags' => ['Supply Chain'],
],
],
'/api/services/policies/approved' => [
'get' => [
'summary' => 'Policy approvate',
'description' => 'Lista policy con status approved.',
'parameters' => [
['name' => 'category', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'include_content', 'in' => 'query', 'schema' => ['type' => 'integer', 'enum' => [0, 1]]],
],
'responses' => ['200' => ['description' => 'Approved policies']],
'tags' => ['Policies'],
],
],
],
'tags' => [
['name' => 'System'],
['name' => 'Compliance'],
['name' => 'Risks'],
['name' => 'Incidents'],
['name' => 'Assets'],
['name' => 'Supply Chain'],
['name' => 'Policies'],
],
];
echo json_encode($spec, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
// ── Utility ───────────────────────────────────────────────────────────
private function scoreLabel(?int $score): string
{
if ($score === null) return 'not_assessed';
if ($score >= 80) return 'compliant';
if ($score >= 60) return 'substantially_compliant';
if ($score >= 40) return 'partial';
return 'significant_gaps';
}
}