nis2-agile/application/controllers/ServicesController.php
DevEnv nis2-agile 56df54f8b1 [FEAT] Services API: full-snapshot endpoint + BigSim SSE wrapper
- ServicesController: nuovo endpoint GET /api/services/full-snapshot
  Aggrega gap-analysis, measures, incidents, training, deadlines,
  compliance-summary in una sola chiamata (reduce 6 round-trip → 1)
  Parametro ?days=N per finestra deadlines (default 30, max 365)

- public/index.php: route GET:fullSnapshot aggiunta all'action map services

- public/simulate-nis2-big.php: wrapper SSE per simulate-nis2-big.php
  Esegue il simulatore come sottoprocesso CLI con NIS2_SSE=1 e
  streama l'output al browser tramite Server-Sent Events

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 15:16:00 +01:00

2189 lines
100 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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.entity_type as 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,
$userId, // created_by: admin utente provisioned
]
);
// ── 7. JWT accesso immediato (2h) ────────────────────────────────
$issuedAt = time();
$jwtPayload = [
'user_id' => $userId, // campo standard atteso da requireAuth()
'iat' => $issuedAt,
'exp' => $issuedAt + JWT_EXPIRES_IN,
];
$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 — response_value: not_implemented=0, partial=2, implemented=4, not_applicable=null
$responses = Database::fetchAll(
'SELECT question_code, category, response_value
FROM assessment_responses
WHERE assessment_id = ?',
[$assessment['id']]
);
$byCategory = [];
foreach ($responses as $r) {
$cat = $r['category'] ?? 'uncategorized';
if (!isset($byCategory[$cat])) {
$byCategory[$cat] = ['total' => 0, 'count' => 0];
}
$val = match($r['response_value'] ?? 'not_implemented') {
'implemented' => 4,
'partial' => 2,
'not_implemented' => 0,
default => 0,
};
if ($r['response_value'] !== 'not_applicable') {
$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 (risk_level calcolato da inherent_risk_score: >=16 critical, >=9 high)
$riskStats = Database::fetchOne(
'SELECT
COUNT(*) as total,
SUM(CASE WHEN status NOT IN ("closed") THEN 1 ELSE 0 END) as open_count,
SUM(CASE WHEN inherent_risk_score >= 9 AND status NOT IN ("closed") THEN 1 ELSE 0 END) as high_critical,
SUM(CASE WHEN status = "monitored" 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 NOT IN ("closed","post_mortem") 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_at IS NOT NULL 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, entity_type, sector, employee_count FROM organizations WHERE id = ?',
[$orgId]
);
$this->jsonSuccess([
'organization' => [
'name' => $org['name'],
'entity_type' => $org['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'])) {
// risk_level è calcolato da inherent_risk_score
$levelMap = ['critical' => 16, 'high' => 9, 'medium' => 4, 'low' => 0];
$levels = array_filter(explode(',', $_GET['level']));
$conditions = [];
foreach ($levels as $lv) {
$lv = strtolower(trim($lv));
if ($lv === 'critical') { $conditions[] = 'r.inherent_risk_score >= 16'; }
elseif ($lv === 'high') { $conditions[] = '(r.inherent_risk_score >= 9 AND r.inherent_risk_score < 16)'; }
elseif ($lv === 'medium') { $conditions[] = '(r.inherent_risk_score >= 4 AND r.inherent_risk_score < 9)'; }
elseif ($lv === 'low') { $conditions[] = 'r.inherent_risk_score < 4'; }
}
if ($conditions) $where .= ' AND ('.implode(' OR ', $conditions).')';
}
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,
CASE WHEN r.inherent_risk_score >= 16 THEN 'critical'
WHEN r.inherent_risk_score >= 9 THEN 'high'
WHEN r.inherent_risk_score >= 4 THEN 'medium'
ELSE 'low' END as risk_level,
r.status, r.treatment, 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, closed_at,
early_warning_sent_at, notification_sent_at, final_report_sent_at,
early_warning_due, notification_due, final_report_due,
affected_services, root_cause,
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' => $inc['early_warning_sent_at'] !== null,
'overdue' => !$inc['early_warning_sent_at'] && $now > $detectedTs + 86400,
],
'notification_72h' => [
'required' => (bool)$inc['is_significant'],
'deadline' => date('c', $detectedTs + 259200),
'sent' => $inc['notification_sent_at'] !== null,
'overdue' => !$inc['notification_sent_at'] && $now > $detectedTs + 259200,
],
'final_report_30d' => [
'required' => (bool)$inc['is_significant'],
'deadline' => date('c', $detectedTs + 2592000),
'sent' => $inc['final_report_sent_at'] !== null,
'overdue' => !$inc['final_report_sent_at'] && $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, framework, status,
implementation_percentage, evidence_description, next_review_date, updated_at
FROM compliance_controls
WHERE organization_id = ?
ORDER BY framework, control_code',
[$orgId]
);
// Raggruppa per framework
$byCategory = [];
foreach ($controls as $ctrl) {
$cat = $ctrl['framework'] ?? 'nis2';
if (!isset($byCategory[$cat])) {
$byCategory[$cat] = [
'category' => $cat,
'controls' => [],
'stats' => ['total' => 0, 'implemented' => 0, 'in_progress' => 0, 'not_started' => 0, 'verified' => 0],
];
}
$byCategory[$cat]['controls'][] = $ctrl;
$byCategory[$cat]['stats']['total']++;
$s = $ctrl['status'] ?? 'not_started';
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'] + ($cat['stats']['verified'] ?? 0);
$p = $cat['stats']['in_progress'] ?? 0;
$cat['score'] = $t > 0 ? round((($i + $p * 0.5) / $t) * 100) : 0;
}
unset($cat);
$totals = [
'total' => count($controls),
'implemented' => 0,
'verified' => 0,
'in_progress' => 0,
'not_started' => 0,
];
foreach ($controls as $ctrl) {
$s = $ctrl['status'] ?? 'not_started';
if (isset($totals[$s])) $totals[$s]++;
}
$done = $totals['implemented'] + $totals['verified'];
$totals['overall_score'] = $totals['total'] > 0
? round((($done + $totals['in_progress'] * 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_user_id, 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'])) {
// risk_score: 0-100, mappa high=>=60, critical=>=80
$levels = array_filter(explode(',', $_GET['risk_level']));
$conditions = [];
foreach ($levels as $lv) {
$lv = strtolower(trim($lv));
if ($lv === 'critical') { $conditions[] = 's.risk_score >= 80'; }
elseif ($lv === 'high') { $conditions[] = '(s.risk_score >= 60 AND s.risk_score < 80)'; }
elseif ($lv === 'medium') { $conditions[] = '(s.risk_score >= 30 AND s.risk_score < 60)'; }
elseif ($lv === 'low') { $conditions[] = 's.risk_score < 30'; }
}
if ($conditions) $where .= ' AND ('.implode(' OR ', $conditions).')';
}
if (!empty($_GET['status'])) {
$where .= ' AND s.status = ?';
$params[] = $_GET['status'];
}
$suppliers = Database::fetchAll(
"SELECT s.id, s.name, s.service_type, s.criticality, s.risk_score, s.status,
s.last_assessment_date, s.contact_email,
s.created_at, s.updated_at
FROM suppliers s
WHERE {$where}
ORDER BY FIELD(s.criticality,'critical','high','medium','low'), s.risk_score DESC, s.name",
$params
);
$stats = Database::fetchOne(
"SELECT
COUNT(*) as total,
SUM(CASE WHEN risk_score >= 60 AND deleted_at IS NULL THEN 1 ELSE 0 END) as high_risk,
SUM(CASE WHEN criticality IN ('critical','high') 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 ───────────────────────────────────────────────────────────
/**
* GET /api/services/full-snapshot
* Aggregato di tutti i flussi lg231 in una sola chiamata.
* Riduce da 6 round-trip a 1. Risposta: { gap_analysis, measures, incidents, training, deadlines, compliance_summary }
* ?days=30 finestra deadlines (default 30)
*/
public function fullSnapshot(): void
{
$this->requireApiKey('read:all');
$orgId = $this->currentOrgId;
$days = min((int) ($this->getParam('days') ?: 30), 365);
// Riusa la logica degli endpoint esistenti catturando i dati direttamente
// senza fare HTTP round-trip interni
// ── compliance summary (già esistente) ────────────────────────────
$org = Database::fetchOne('SELECT name, sector, entity_type, employee_count FROM organizations WHERE id = ?', [$orgId]);
$ctrlStats = Database::fetchOne(
'SELECT COUNT(*) as total,
SUM(CASE WHEN status IN ("implemented","verified") THEN 1 ELSE 0 END) as done,
SUM(CASE WHEN status = "in_progress" THEN 1 ELSE 0 END) as wip
FROM compliance_controls WHERE organization_id = ?', [$orgId]
);
$complianceScore = ($ctrlStats['total'] ?? 0) > 0
? (int) round(($ctrlStats['done'] + $ctrlStats['wip'] * 0.5) / $ctrlStats['total'] * 100)
: 0;
// ── gap analysis ─────────────────────────────────────────────────
$assessment = Database::fetchOne(
'SELECT id, overall_score, ai_summary, ai_recommendations, completed_at
FROM assessments WHERE organization_id = ? AND status = "completed"
ORDER BY completed_at DESC LIMIT 1', [$orgId]
);
$gapDomains = [];
if ($assessment) {
$responses = Database::fetchAll(
'SELECT category, response_value, question_text FROM assessment_responses WHERE assessment_id = ?',
[$assessment['id']]
);
$dData = []; $dGaps = [];
foreach ($responses as $r) {
$cat = $r['category'];
if (!isset($dData[$cat])) { $dData[$cat] = ['implemented'=>0,'partial'=>0,'not_implemented'=>0,'not_applicable'=>0]; $dGaps[$cat] = []; }
match ($r['response_value']) {
'implemented' => $dData[$cat]['implemented']++, 'partial' => $dData[$cat]['partial']++,
'not_implemented' => $dData[$cat]['not_implemented']++, 'not_applicable' => $dData[$cat]['not_applicable']++,
default => null,
};
if ($r['response_value'] === 'not_implemented' && !empty($r['question_text'])) $dGaps[$cat][] = $r['question_text'];
}
$mogMap = ['governance'=>'pillar_1_governance','risk_management'=>'pillar_2_risk_assessment','incident_management'=>'pillar_7_segnalazioni','business_continuity'=>'pillar_5_monitoraggio','supply_chain'=>'pillar_3_procedure_operative','vulnerability'=>'pillar_5_monitoraggio','policy_measurement'=>'pillar_3_procedure_operative','training_awareness'=>'pillar_4_formazione','cryptography'=>'pillar_6_sicurezza_it','access_control'=>'pillar_6_sicurezza_it'];
$artMap = ['governance'=>'Art.20-21','risk_management'=>'Art.21.2.a','incident_management'=>'Art.21.2.b','business_continuity'=>'Art.21.2.c','supply_chain'=>'Art.21.2.d','vulnerability'=>'Art.21.2.e','policy_measurement'=>'Art.21.2.f','training_awareness'=>'Art.21.2.g','cryptography'=>'Art.21.2.h','access_control'=>'Art.21.2.i'];
$gapActions = ['critical'=>'Intervento immediato: piano d\'azione entro 30 giorni.','high'=>'Priorità alta: avviare implementazione nel prossimo sprint.','medium'=>'Completare misure parziali entro il prossimo trimestre.','low'=>'Revisione periodica annuale.'];
foreach ($dData as $domain => $counts) {
$scorable = $counts['implemented'] + $counts['partial'] + $counts['not_implemented'];
$score = $scorable > 0 ? (int) round(($counts['implemented']*4+$counts['partial']*2)/($scorable*4)*100) : null;
$gl = match(true) { $score===null=>'not_assessed', $score>=75=>'low', $score>=50=>'medium', $score>=25=>'high', default=>'critical' };
$gapDomains[] = ['domain'=>$domain,'nis2_article'=>$artMap[$domain]??'Art.21','mog_pillar'=>$mogMap[$domain]??'pillar_3_procedure_operative','score'=>$score,'gap_level'=>$gl,'implemented'=>$counts['implemented'],'partial'=>$counts['partial'],'not_implemented'=>$counts['not_implemented'],'not_implemented_items'=>array_slice($dGaps[$domain]??[],0,3),'suggested_action'=>$gapActions[$gl]??null];
}
usort($gapDomains, fn($a,$b) => ($a['score']??101)<=>($b['score']??101));
}
// ── incidents ────────────────────────────────────────────────────
$now = time();
$incRows = Database::fetchAll(
'SELECT id, title, classification, severity, status, is_significant, detected_at,
early_warning_due, early_warning_sent_at, notification_due, notification_sent_at,
final_report_due, final_report_sent_at
FROM incidents WHERE organization_id = ? ORDER BY detected_at DESC LIMIT 20', [$orgId]
);
$incidents = [];
$csirtOverdue = 0;
foreach ($incRows as $r) {
$isOpen = !in_array($r['status'],['closed','post_mortem']); $isSig = (bool)$r['is_significant'];
$notDue = $r['notification_due'] ? strtotime($r['notification_due']) : null;
$notOv = $isSig && $isOpen && $notDue && $now > $notDue && !$r['notification_sent_at'];
if ($notOv) $csirtOverdue++;
$incidents[] = ['id'=>(int)$r['id'],'title'=>$r['title'],'severity'=>$r['severity'],'status'=>$r['status'],'is_significant'=>$isSig,'detected_at'=>$r['detected_at'],'art23'=>['early_warning_due'=>$r['early_warning_due'],'early_warning_sent'=>!empty($r['early_warning_sent_at']),'early_warning_overdue'=>$isSig&&$isOpen&&$r['early_warning_due']&&$now>strtotime($r['early_warning_due'])&&!$r['early_warning_sent_at'],'notification_due'=>$r['notification_due'],'notification_sent'=>!empty($r['notification_sent_at']),'notification_overdue'=>$notOv,'final_report_due'=>$r['final_report_due'],'final_report_sent'=>!empty($r['final_report_sent_at']),'final_report_overdue'=>$isSig&&$isOpen&&$r['final_report_due']&&$now>strtotime($r['final_report_due'])&&!$r['final_report_sent_at']]];
}
// ── training ─────────────────────────────────────────────────────
$courses = Database::fetchAll('SELECT id,title,target_role,nis2_article,is_mandatory,duration_minutes FROM training_courses WHERE (organization_id=? OR organization_id IS NULL) AND is_active=1 ORDER BY is_mandatory DESC,title', [$orgId]);
$trainingData = ['courses_total'=>0,'mandatory_total'=>0,'overall_completion_rate'=>0,'board_completion_rate'=>0,'art20_compliance'=>false,'non_compliant_mandatory'=>[]];
if (!empty($courses)) {
$cids = array_column($courses,'id');
$ph = implode(',', array_fill(0,count($cids),'?'));
$agg = Database::fetchAll("SELECT course_id, COUNT(*) as total, SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END) as completed FROM training_assignments WHERE organization_id=? AND course_id IN ({$ph}) GROUP BY course_id", array_merge([$orgId],$cids));
$am = []; foreach ($agg as $a) $am[(int)$a['course_id']] = ['total'=>(int)$a['total'],'completed'=>(int)$a['completed']];
$board = Database::fetchOne("SELECT COUNT(*) as total, SUM(CASE WHEN ta.status='completed' THEN 1 ELSE 0 END) as completed FROM training_assignments ta JOIN user_organizations uo ON uo.user_id=ta.user_id AND uo.organization_id=ta.organization_id WHERE ta.organization_id=? AND uo.role='board_member'", [$orgId]);
$tTot=0; $tComp=0; $mBTot=0; $mBComp=0; $cList=[];
foreach ($courses as $c) {
$cid=(int)$c['id']; $a=$am[$cid]??['total'=>0,'completed'=>0];
$rate=$a['total']>0?(int)round($a['completed']/$a['total']*100):0;
$tTot+=$a['total']; $tComp+=$a['completed'];
if ($c['is_mandatory']&&$c['target_role']==='board_member') { $mBTot+=$a['total']; $mBComp+=$a['completed']; }
$cList[]=['id'=>$cid,'title'=>$c['title'],'target_role'=>$c['target_role'],'nis2_article'=>$c['nis2_article'],'is_mandatory'=>(bool)$c['is_mandatory'],'assigned'=>$a['total'],'completed'=>$a['completed'],'completion_rate'=>$rate];
}
$nonCompliant = array_values(array_filter($cList, fn($c) => $c['is_mandatory'] && $c['completion_rate'] < 100));
$trainingData = ['courses_total'=>count($courses),'mandatory_total'=>count(array_filter($courses,fn($c)=>$c['is_mandatory'])),'assignments_total'=>$tTot,'assignments_completed'=>$tComp,'overall_completion_rate'=>$tTot>0?(int)round($tComp/$tTot*100):0,'board_completion_rate'=>(int)($board['total']??0)>0?(int)round((int)($board['completed']??0)/(int)$board['total']*100):0,'art20_compliance'=>$mBTot>0&&$mBComp>=$mBTot,'non_compliant_mandatory'=>$nonCompliant,'courses'=>$cList];
}
// ── deadlines ────────────────────────────────────────────────────
$horizon = $now + ($days * 86400);
$deadlines = [];
foreach (Database::fetchAll('SELECT id,title,severity,early_warning_due,early_warning_sent_at,notification_due,notification_sent_at,final_report_due,final_report_sent_at FROM incidents WHERE organization_id=? AND is_significant=1 AND status NOT IN ("closed","post_mortem")', [$orgId]) as $inc) {
foreach ([['notification_72h',$inc['notification_due'],$inc['notification_sent_at']],['early_warning_24h',$inc['early_warning_due'],$inc['early_warning_sent_at']],['final_report_30d',$inc['final_report_due'],$inc['final_report_sent_at']]] as [$sub,$due,$sent]) {
if (!$due||$sent) continue; $ts=strtotime($due); if ($ts>$horizon) continue;
$deadlines[]=['type'=>'incident_notification','subtype'=>$sub,'title'=>$sub.': '.$inc['title'],'due_date'=>$due,'overdue'=>$ts<$now,'hours_remaining'=>(int)round(($ts-$now)/3600),'priority'=>'critical','reference_id'=>(int)$inc['id'],'nis2_article'=>'Art.23'];
}
}
foreach (Database::fetchAll('SELECT id,control_code,title,next_review_date,status FROM compliance_controls WHERE organization_id=? AND next_review_date IS NOT NULL AND status!="verified"', [$orgId]) as $c) {
$ts=strtotime($c['next_review_date']); if ($ts>$horizon) continue;
$deadlines[]=['type'=>'control_review','subtype'=>$c['status'],'title'=>'Revisione: '.$c['title'],'due_date'=>$c['next_review_date'],'overdue'=>$ts<$now,'hours_remaining'=>(int)round(($ts-$now)/3600),'priority'=>'medium','reference_id'=>(int)$c['id'],'nis2_article'=>'Art.21'];
}
foreach (Database::fetchAll('SELECT id,name,criticality,next_assessment_date FROM suppliers WHERE organization_id=? AND deleted_at IS NULL AND next_assessment_date IS NOT NULL', [$orgId]) as $s) {
$ts=strtotime($s['next_assessment_date']); if ($ts>$horizon) continue;
$deadlines[]=['type'=>'supplier_assessment','subtype'=>$s['criticality'],'title'=>'Valutazione fornitore: '.$s['name'],'due_date'=>$s['next_assessment_date'],'overdue'=>$ts<$now,'hours_remaining'=>(int)round(($ts-$now)/3600),'priority'=>in_array($s['criticality'],['critical','high'])?'high':'medium','reference_id'=>(int)$s['id'],'nis2_article'=>'Art.21.2.d'];
}
usort($deadlines, fn($a,$b) => $b['overdue']<=>$a['overdue'] ?: strtotime($a['due_date'])<=>strtotime($b['due_date']));
$this->jsonSuccess([
'org' => ['id' => $orgId, 'name' => $org['name'] ?? '', 'sector' => $org['sector'] ?? '', 'entity_type' => $org['entity_type'] ?? ''],
'compliance_score' => $complianceScore,
'gap_analysis' => ['assessment_id' => $assessment ? (int)$assessment['id'] : null, 'overall_score' => $assessment ? $assessment['overall_score'] : null, 'completed_at' => $assessment['completed_at'] ?? null, 'domains' => $gapDomains],
'incidents' => ['total' => count($incRows), 'csirt_overdue' => $csirtOverdue, 'items' => $incidents],
'training' => $trainingData,
'deadlines' => ['days_horizon' => $days, 'overdue' => count(array_filter($deadlines,fn($d)=>$d['overdue'])), 'total' => count($deadlines), 'items' => $deadlines],
'generated_at' => date('c'),
]);
}
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';
}
// ══════════════════════════════════════════════════════════════════════
// lg231 / GRC INTEGRATION ENDPOINTS
// ══════════════════════════════════════════════════════════════════════
/**
* GET /api/services/gap-analysis
* Gap analysis per dominio NIS2 Art.21 con mapping ai 7 pilastri MOG 231.
* Basato sull'assessment più recente completato.
*/
public function gapAnalysis(): void
{
$this->requireApiKey('read:compliance');
$orgId = $this->currentOrgId;
// Mapping NIS2 domain → MOG 231 pillar e articolo
$domainMeta = [
'governance' => ['article' => 'Art.20-21', 'mog_pillar' => 'pillar_1_governance'],
'risk_management' => ['article' => 'Art.21.2.a', 'mog_pillar' => 'pillar_2_risk_assessment'],
'incident_management' => ['article' => 'Art.21.2.b', 'mog_pillar' => 'pillar_7_segnalazioni'],
'business_continuity' => ['article' => 'Art.21.2.c', 'mog_pillar' => 'pillar_5_monitoraggio'],
'supply_chain' => ['article' => 'Art.21.2.d', 'mog_pillar' => 'pillar_3_procedure_operative'],
'vulnerability' => ['article' => 'Art.21.2.e', 'mog_pillar' => 'pillar_5_monitoraggio'],
'policy_measurement' => ['article' => 'Art.21.2.f', 'mog_pillar' => 'pillar_3_procedure_operative'],
'training_awareness' => ['article' => 'Art.21.2.g', 'mog_pillar' => 'pillar_4_formazione'],
'cryptography' => ['article' => 'Art.21.2.h', 'mog_pillar' => 'pillar_6_sicurezza_it'],
'access_control' => ['article' => 'Art.21.2.i', 'mog_pillar' => 'pillar_6_sicurezza_it'],
];
// Assessment più recente completato
$assessment = Database::fetchOne(
'SELECT id, overall_score, category_scores, ai_summary, ai_recommendations, completed_at
FROM assessments
WHERE organization_id = ? AND status = "completed"
ORDER BY completed_at DESC LIMIT 1',
[$orgId]
);
if (!$assessment) {
$this->jsonSuccess([
'assessment_id' => null,
'completed_at' => null,
'overall_score' => null,
'domains' => [],
'ai_summary' => null,
'ai_recommendations' => [],
'note' => 'Nessun assessment completato. Avvia un gap analysis dal modulo Assessment.',
]);
return;
}
// Risposte per assessment (incluse domande non implementate per suggested_action)
$responses = Database::fetchAll(
'SELECT category, response_value, question_text
FROM assessment_responses
WHERE assessment_id = ?',
[$assessment['id']]
);
// Calcola score per dominio dalle risposte + raccoglie items non implementati
$domainData = [];
$domainGaps = []; // category → [question_text, ...]
foreach ($responses as $r) {
$cat = $r['category'];
if (!isset($domainData[$cat])) {
$domainData[$cat] = ['implemented' => 0, 'partial' => 0, 'not_implemented' => 0, 'not_applicable' => 0];
$domainGaps[$cat] = [];
}
match ($r['response_value']) {
'implemented' => $domainData[$cat]['implemented']++,
'partial' => $domainData[$cat]['partial']++,
'not_implemented' => $domainData[$cat]['not_implemented']++,
'not_applicable' => $domainData[$cat]['not_applicable']++,
default => null,
};
if ($r['response_value'] === 'not_implemented' && !empty($r['question_text'])) {
$domainGaps[$cat][] = $r['question_text'];
}
}
$domains = [];
foreach ($domainData as $domain => $counts) {
$scorable = $counts['implemented'] + $counts['partial'] + $counts['not_implemented'];
$score = $scorable > 0
? (int) round(($counts['implemented'] * 4 + $counts['partial'] * 2) / ($scorable * 4) * 100)
: null;
$gapLevel = match (true) {
$score === null => 'not_assessed',
$score >= 75 => 'low',
$score >= 50 => 'medium',
$score >= 25 => 'high',
default => 'critical',
};
$meta = $domainMeta[$domain] ?? ['article' => 'Art.21', 'mog_pillar' => 'pillar_3_procedure_operative'];
$domains[] = [
'domain' => $domain,
'nis2_article' => $meta['article'],
'mog_pillar' => $meta['mog_pillar'],
'score' => $score,
'gap_level' => $gapLevel,
'implemented' => $counts['implemented'],
'partial' => $counts['partial'],
'not_implemented' => $counts['not_implemented'],
'not_applicable' => $counts['not_applicable'],
];
}
// Ordina per score ASC (gap peggiori prima)
usort($domains, fn($a, $b) => ($a['score'] ?? 101) <=> ($b['score'] ?? 101));
// AI recommendations globali
$aiRecs = [];
if (!empty($assessment['ai_recommendations'])) {
$decoded = json_decode($assessment['ai_recommendations'], true);
if (is_array($decoded)) $aiRecs = $decoded;
}
// Default suggested_action per gap_level se non ci sono AI recs
$gapActions = [
'critical' => 'Intervento immediato richiesto: definire piano d\'azione con responsabile e scadenza entro 30 giorni.',
'high' => 'Priorità alta: avviare implementazione misure mancanti nel prossimo sprint di compliance.',
'medium' => 'Completare le misure parzialmente implementate entro il prossimo trimestre.',
'low' => 'Mantenere il livello attuale con revisione periodica annuale.',
];
// Aggiungi suggested_action e not_implemented_items a ogni dominio
foreach ($domains as &$d) {
$d['not_implemented_items'] = array_slice($domainGaps[$d['domain']] ?? [], 0, 5);
$d['suggested_action'] = $gapActions[$d['gap_level']] ?? null;
}
unset($d);
$this->jsonSuccess([
'assessment_id' => (int) $assessment['id'],
'completed_at' => $assessment['completed_at'],
'overall_score' => $assessment['overall_score'] !== null ? (int) $assessment['overall_score'] : null,
'domains' => $domains,
'ai_summary' => $assessment['ai_summary'],
'ai_recommendations' => $aiRecs,
]);
}
/**
* GET /api/services/measures
* Misure di sicurezza Art.21 con stato implementazione e mapping MOG 231.
* ?framework=nis2|iso27001|both (default: tutti)
* ?status=not_started|in_progress|implemented|verified
*/
public function measures(): void
{
$this->requireApiKey('read:compliance');
$orgId = $this->currentOrgId;
// Mapping control_code prefix → mog_area
$mogAreaMap = [
'NIS2-20' => 'governance',
'NIS2-21' => 'governance',
'NIS2-21.2.a' => 'risk_assessment',
'NIS2-21.2.b' => 'incident_management',
'NIS2-21.2.c' => 'business_continuity',
'NIS2-21.2.d' => 'supply_chain',
'NIS2-21.2.e' => 'vulnerability_management',
'NIS2-21.2.f' => 'policy_procedure',
'NIS2-21.2.g' => 'training_awareness',
'NIS2-21.2.h' => 'cryptography',
'NIS2-21.2.i' => 'access_control',
'NIS2-21.2.j' => 'access_control',
'ISO' => 'iso27001_control',
];
$where = 'organization_id = ?';
$params = [$orgId];
$filterFramework = $this->getParam('framework');
if ($filterFramework && in_array($filterFramework, ['nis2', 'iso27001', 'both'])) {
$where .= ' AND framework = ?';
$params[] = $filterFramework;
}
$filterStatus = $this->getParam('status');
if ($filterStatus) {
$where .= ' AND status = ?';
$params[] = $filterStatus;
}
$controls = Database::fetchAll(
"SELECT id, control_code, framework, title, status,
implementation_percentage, next_review_date
FROM compliance_controls
WHERE {$where}
ORDER BY framework, control_code",
$params
);
$stats = ['total' => 0, 'not_started' => 0, 'in_progress' => 0, 'implemented' => 0, 'verified' => 0];
$measures = [];
foreach ($controls as $c) {
$stats['total']++;
$s = $c['status'] ?? 'not_started';
if (isset($stats[$s])) $stats[$s]++;
$code = $c['control_code'] ?? '';
// Derive mog_area from control_code
$mogArea = 'other';
foreach ($mogAreaMap as $prefix => $area) {
if (str_starts_with($code, $prefix)) { $mogArea = $area; break; }
}
if ($mogArea === 'other' && str_starts_with($code, 'ISO')) $mogArea = 'iso27001_control';
// Derive nis2_article from code (e.g. "NIS2-21.2.a" → "Art.21.2.a")
$nis2Article = null;
if (preg_match('/^NIS2-(\S+)/', $code, $m)) {
$nis2Article = 'Art.' . $m[1];
}
$measures[] = [
'id' => (int) $c['id'],
'code' => $code,
'framework' => $c['framework'],
'title' => $c['title'],
'status' => $s,
'implementation_percentage' => (int) ($c['implementation_percentage'] ?? 0),
'nis2_article' => $nis2Article,
'mog_area' => $mogArea,
'next_review_date' => $c['next_review_date'],
];
}
$completionPct = $stats['total'] > 0
? (int) round(($stats['implemented'] + $stats['verified'] + $stats['in_progress'] * 0.5) / $stats['total'] * 100)
: 0;
$this->jsonSuccess(array_merge($stats, [
'completion_percentage' => $completionPct,
'measures' => $measures,
]));
}
/**
* GET /api/services/incidents
* Incidenti con stato Art.23 CSIRT (diverso da incidentsFeed: focus compliance).
* ?significant=1 solo incidenti significativi
* ?status=open solo aperti (non closed/post_mortem)
* ?limit=50
*/
public function incidents(): void
{
$this->requireApiKey('read:incidents');
$orgId = $this->currentOrgId;
$limit = min((int) ($this->getParam('limit') ?: 50), 200);
$where = 'organization_id = ?';
$params = [$orgId];
if ($this->getParam('significant') === '1') {
$where .= ' AND is_significant = 1';
}
if ($this->getParam('status') === 'open') {
$where .= ' AND status NOT IN ("closed","post_mortem")';
}
$rows = Database::fetchAll(
"SELECT id, title, classification, severity, status, is_significant,
detected_at, closed_at,
early_warning_due, early_warning_sent_at,
notification_due, notification_sent_at,
final_report_due, final_report_sent_at,
affected_services, root_cause
FROM incidents
WHERE {$where}
ORDER BY detected_at DESC
LIMIT {$limit}",
$params
);
$now = time();
$csirtOverdue = 0;
$incidents = [];
foreach ($rows as $r) {
$isOpen = !in_array($r['status'], ['closed', 'post_mortem']);
$isSig = (bool) $r['is_significant'];
// Art.23 compliance per incidente significativo aperto
$ewDue = $r['early_warning_due'] ? strtotime($r['early_warning_due']) : null;
$notDue = $r['notification_due'] ? strtotime($r['notification_due']) : null;
$frDue = $r['final_report_due'] ? strtotime($r['final_report_due']) : null;
$notOverdue = $isSig && $isOpen && $notDue && $now > $notDue && !$r['notification_sent_at'];
if ($notOverdue) $csirtOverdue++;
$incidents[] = [
'id' => (int) $r['id'],
'title' => $r['title'],
'classification' => $r['classification'],
'severity' => $r['severity'],
'status' => $r['status'],
'is_significant' => $isSig,
'detected_at' => $r['detected_at'],
'closed_at' => $r['closed_at'],
'art23' => [
'early_warning_due' => $r['early_warning_due'],
'early_warning_sent' => !empty($r['early_warning_sent_at']),
'early_warning_overdue'=> $isSig && $isOpen && $ewDue && $now > $ewDue && !$r['early_warning_sent_at'],
'notification_due' => $r['notification_due'],
'notification_sent' => !empty($r['notification_sent_at']),
'notification_overdue' => $notOverdue,
'final_report_due' => $r['final_report_due'],
'final_report_sent' => !empty($r['final_report_sent_at']),
'final_report_overdue' => $isSig && $isOpen && $frDue && $now > $frDue && !$r['final_report_sent_at'],
],
];
}
// Statistiche totali (non solo quelle nella pagina)
$stats = Database::fetchOne(
'SELECT COUNT(*) as total,
SUM(CASE WHEN status NOT IN ("closed","post_mortem") THEN 1 ELSE 0 END) as open_count,
SUM(CASE WHEN is_significant = 1 THEN 1 ELSE 0 END) as significant
FROM incidents WHERE organization_id = ?',
[$orgId]
);
$this->jsonSuccess([
'total' => (int) ($stats['total'] ?? 0),
'open' => (int) ($stats['open_count'] ?? 0),
'significant' => (int) ($stats['significant'] ?? 0),
'csirt_overdue' => $csirtOverdue,
'fetched' => count($incidents),
'incidents' => $incidents,
]);
}
/**
* GET /api/services/training
* Formazione NIS2 Art.20 — corsi, completamento, compliance board.
* Utile per lg231 per evidenza OdV Pillar 4 (Formazione).
*/
public function training(): void
{
$this->requireApiKey('read:all');
$orgId = $this->currentOrgId;
// Corsi dell'org + corsi globali (org_id IS NULL)
$courses = Database::fetchAll(
'SELECT id, title, target_role, nis2_article, is_mandatory, duration_minutes
FROM training_courses
WHERE (organization_id = ? OR organization_id IS NULL) AND is_active = 1
ORDER BY is_mandatory DESC, target_role, title',
[$orgId]
);
if (empty($courses)) {
$this->jsonSuccess([
'courses_total' => 0, 'mandatory_total' => 0,
'assignments_total' => 0, 'assignments_completed' => 0,
'overall_completion_rate' => 0, 'board_completion_rate' => 0,
'art20_compliance' => false, 'courses' => [],
]);
return;
}
$courseIds = array_column($courses, 'id');
$placeholders = implode(',', array_fill(0, count($courseIds), '?'));
// Aggregazione assignments per corso
$assignRows = Database::fetchAll(
"SELECT course_id,
COUNT(*) as total,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed
FROM training_assignments
WHERE organization_id = ? AND course_id IN ({$placeholders})
GROUP BY course_id",
array_merge([$orgId], $courseIds)
);
$assignMap = [];
foreach ($assignRows as $row) {
$assignMap[(int)$row['course_id']] = ['total' => (int)$row['total'], 'completed' => (int)$row['completed']];
}
// Completion board members: corsi mandatory × utenti board
$boardAssign = Database::fetchOne(
"SELECT COUNT(*) as total,
SUM(CASE WHEN ta.status = 'completed' THEN 1 ELSE 0 END) as completed
FROM training_assignments ta
JOIN users u ON u.id = ta.user_id
JOIN user_organizations uo ON uo.user_id = u.id AND uo.organization_id = ta.organization_id
WHERE ta.organization_id = ? AND uo.role = 'board_member'",
[$orgId]
);
$totalAssign = 0;
$totalCompleted = 0;
$mandatoryBoardTotal = 0;
$mandatoryBoardCompleted = 0;
$courseList = [];
foreach ($courses as $c) {
$cid = (int) $c['id'];
$a = $assignMap[$cid] ?? ['total' => 0, 'completed' => 0];
$rate = $a['total'] > 0 ? (int) round($a['completed'] / $a['total'] * 100) : 0;
$totalAssign += $a['total'];
$totalCompleted += $a['completed'];
if ($c['is_mandatory'] && $c['target_role'] === 'board_member') {
$mandatoryBoardTotal += $a['total'];
$mandatoryBoardCompleted += $a['completed'];
}
$courseList[] = [
'id' => $cid,
'title' => $c['title'],
'target_role' => $c['target_role'],
'nis2_article' => $c['nis2_article'],
'is_mandatory' => (bool) $c['is_mandatory'],
'duration_minutes' => (int) ($c['duration_minutes'] ?? 0),
'assigned' => $a['total'],
'completed' => $a['completed'],
'completion_rate' => $rate,
];
}
$overallRate = $totalAssign > 0 ? (int) round($totalCompleted / $totalAssign * 100) : 0;
$boardRate = ((int)($boardAssign['total'] ?? 0)) > 0
? (int) round((int)($boardAssign['completed'] ?? 0) / (int)$boardAssign['total'] * 100)
: 0;
$art20ok = $mandatoryBoardTotal > 0 && $mandatoryBoardCompleted >= $mandatoryBoardTotal;
// Corsi mandatory non conformi (completion_rate < 100%) → evidenza OdV Pillar 4
$nonCompliant = array_values(array_filter($courseList, fn($c) => $c['is_mandatory'] && $c['completion_rate'] < 100));
$this->jsonSuccess([
'courses_total' => count($courses),
'mandatory_total' => count(array_filter($courses, fn($c) => $c['is_mandatory'])),
'assignments_total' => $totalAssign,
'assignments_completed' => $totalCompleted,
'overall_completion_rate' => $overallRate,
'board_completion_rate' => $boardRate,
'art20_compliance' => $art20ok,
'non_compliant_mandatory' => $nonCompliant,
'courses' => $courseList,
]);
}
/**
* GET /api/services/deadlines
* Scadenze aggregate da 4 sorgenti: incidenti CSIRT, revisioni controlli,
* assessment fornitori, training assignments.
* ?days=30 finestra (default 30, max 365)
*/
public function deadlines(): void
{
$this->requireApiKey('read:all');
$orgId = $this->currentOrgId;
$days = min((int) ($this->getParam('days') ?: 30), 365);
$now = time();
$horizon = $now + ($days * 86400);
$deadlines = [];
// ── 1. Notifiche CSIRT incidenti significativi aperti ──────────────
$openInc = Database::fetchAll(
'SELECT id, title, severity,
early_warning_due, early_warning_sent_at,
notification_due, notification_sent_at,
final_report_due, final_report_sent_at
FROM incidents
WHERE organization_id = ? AND is_significant = 1
AND status NOT IN ("closed","post_mortem")',
[$orgId]
);
foreach ($openInc as $inc) {
$steps = [
['subtype' => 'early_warning_24h', 'due' => $inc['early_warning_due'], 'sent' => $inc['early_warning_sent_at']],
['subtype' => 'notification_72h', 'due' => $inc['notification_due'], 'sent' => $inc['notification_sent_at']],
['subtype' => 'final_report_30d', 'due' => $inc['final_report_due'], 'sent' => $inc['final_report_sent_at']],
];
foreach ($steps as $step) {
if (!$step['due'] || $step['sent']) continue;
$dueTs = strtotime($step['due']);
if ($dueTs > $horizon) continue;
$hoursLeft = (int) round(($dueTs - $now) / 3600);
$deadlines[] = [
'type' => 'incident_notification',
'subtype' => $step['subtype'],
'title' => $step['subtype'] . ': ' . $inc['title'],
'due_date' => $step['due'],
'overdue' => $dueTs < $now,
'hours_remaining' => $hoursLeft,
'priority' => 'critical',
'reference_id' => (int) $inc['id'],
'nis2_article' => 'Art.23',
];
}
}
// ── 2. Revisioni controlli compliance ─────────────────────────────
$controls = Database::fetchAll(
'SELECT id, control_code, title, next_review_date, status
FROM compliance_controls
WHERE organization_id = ? AND next_review_date IS NOT NULL
AND status != "verified"',
[$orgId]
);
foreach ($controls as $c) {
$dueTs = strtotime($c['next_review_date']);
if ($dueTs > $horizon) continue;
$hoursLeft = (int) round(($dueTs - $now) / 3600);
$deadlines[] = [
'type' => 'control_review',
'subtype' => $c['status'],
'title' => 'Revisione: ' . $c['title'],
'due_date' => $c['next_review_date'],
'overdue' => $dueTs < $now,
'hours_remaining' => $hoursLeft,
'priority' => 'medium',
'reference_id' => (int) $c['id'],
'nis2_article' => 'Art.21',
];
}
// ── 3. Assessment fornitori ────────────────────────────────────────
$suppliers = Database::fetchAll(
'SELECT id, name, criticality, next_assessment_date
FROM suppliers
WHERE organization_id = ? AND deleted_at IS NULL
AND next_assessment_date IS NOT NULL',
[$orgId]
);
foreach ($suppliers as $s) {
$dueTs = strtotime($s['next_assessment_date']);
if ($dueTs > $horizon) continue;
$hoursLeft = (int) round(($dueTs - $now) / 3600);
$priority = in_array($s['criticality'], ['critical', 'high']) ? 'high' : 'medium';
$deadlines[] = [
'type' => 'supplier_assessment',
'subtype' => $s['criticality'],
'title' => 'Valutazione fornitore: ' . $s['name'],
'due_date' => $s['next_assessment_date'],
'overdue' => $dueTs < $now,
'hours_remaining' => $hoursLeft,
'priority' => $priority,
'reference_id' => (int) $s['id'],
'nis2_article' => 'Art.21.2.d',
];
}
// ── 4. Training assignments scaduti/in scadenza ────────────────────
$trainings = Database::fetchAll(
'SELECT ta.id, tc.title, ta.due_date, ta.status
FROM training_assignments ta
JOIN training_courses tc ON tc.id = ta.course_id
WHERE ta.organization_id = ? AND ta.status NOT IN ("completed")
AND ta.due_date IS NOT NULL',
[$orgId]
);
foreach ($trainings as $t) {
$dueTs = strtotime($t['due_date']);
if ($dueTs > $horizon) continue;
$hoursLeft = (int) round(($dueTs - $now) / 3600);
$deadlines[] = [
'type' => 'training_assignment',
'subtype' => $t['status'],
'title' => 'Formazione: ' . $t['title'],
'due_date' => $t['due_date'],
'overdue' => $dueTs < $now,
'hours_remaining' => $hoursLeft,
'priority' => 'low',
'reference_id' => (int) $t['id'],
'nis2_article' => 'Art.20',
];
}
// Ordina: overdue + urgenti prima
usort($deadlines, function ($a, $b) {
if ($a['overdue'] !== $b['overdue']) return $b['overdue'] <=> $a['overdue'];
return strtotime($a['due_date']) <=> strtotime($b['due_date']);
});
$overdue = count(array_filter($deadlines, fn($d) => $d['overdue']));
$due7days = count(array_filter($deadlines, fn($d) => !$d['overdue'] && strtotime($d['due_date']) <= $now + 7 * 86400));
$due30days = count(array_filter($deadlines, fn($d) => !$d['overdue']));
$this->jsonSuccess([
'days_horizon' => $days,
'overdue' => $overdue,
'due_7_days' => $due7days,
'due_30_days' => $due30days,
'total' => count($deadlines),
'deadlines' => $deadlines,
]);
}
}