- 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>
2189 lines
100 KiB
PHP
2189 lines
100 KiB
PHP
<?php
|
||
/**
|
||
* NIS2 Agile - Services Controller
|
||
*
|
||
* API pubblica per sistemi esterni (SIEM, GRC, 231 Agile, SustainAI, AllRisk).
|
||
* Autenticazione via API Key (header X-API-Key o Bearer nis2_xxx).
|
||
* Rate limiting: 100 req/h per chiave.
|
||
*
|
||
* Endpoint:
|
||
* GET /api/services/status
|
||
* GET /api/services/compliance-summary
|
||
* GET /api/services/risks/feed
|
||
* GET /api/services/incidents/feed
|
||
* GET /api/services/controls/status
|
||
* GET /api/services/assets/critical
|
||
* GET /api/services/suppliers/risk
|
||
* GET /api/services/policies/approved
|
||
* GET /api/services/openapi
|
||
*/
|
||
|
||
require_once __DIR__ . '/BaseController.php';
|
||
|
||
class ServicesController extends BaseController
|
||
{
|
||
// ─── API Key autenticata ──────────────────────────────────────────────
|
||
private ?array $apiKeyRecord = null;
|
||
private const RATE_LIMIT_DIR = '/tmp/nis2_api_ratelimit/';
|
||
private const RATE_LIMIT_MAX = 100; // req per finestra
|
||
private const RATE_LIMIT_WINDOW = 3600; // secondi (1 ora)
|
||
|
||
// ─── Versione API ─────────────────────────────────────────────────────
|
||
private const API_VERSION = '1.0.0';
|
||
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
// AUTH API KEY
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* Autentica la richiesta via API Key.
|
||
* Cerca in:
|
||
* 1. Header X-API-Key
|
||
* 2. Authorization: Bearer nis2_xxx
|
||
* 3. Query string ?api_key=nis2_xxx
|
||
*/
|
||
private function requireApiKey(string $scope = 'read:all'): void
|
||
{
|
||
$rawKey = null;
|
||
|
||
// 1. Header X-API-Key
|
||
$rawKey = $_SERVER['HTTP_X_API_KEY'] ?? null;
|
||
|
||
// 2. Bearer token
|
||
if (!$rawKey) {
|
||
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||
if (str_starts_with($auth, 'Bearer nis2_')) {
|
||
$rawKey = substr($auth, 7);
|
||
}
|
||
}
|
||
|
||
// 3. Query string
|
||
if (!$rawKey) {
|
||
$rawKey = $_GET['api_key'] ?? null;
|
||
}
|
||
|
||
if (!$rawKey) {
|
||
$this->jsonError('API Key mancante', 401, 'MISSING_API_KEY');
|
||
}
|
||
|
||
// Hash SHA-256 della chiave
|
||
$keyHash = hash('sha256', $rawKey);
|
||
|
||
// Cerca in DB
|
||
$record = Database::fetchOne(
|
||
'SELECT ak.*, o.name as org_name, o.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,
|
||
]);
|
||
}
|
||
}
|