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