'Accesso completo in lettura a tutti i dati', 'read:compliance' => 'Compliance score e controlli Art.21', 'read:risks' => 'Risk register e matrice rischi', 'read:incidents' => 'Incidenti e timeline Art.23', 'read:assets' => 'Inventario asset critici', 'read:supply_chain' => 'Supply chain e rischio fornitori', 'read:policies' => 'Policy approvate', ]; // ══════════════════════════════════════════════════════════════════════ // API KEYS // ══════════════════════════════════════════════════════════════════════ /** * GET /api/webhooks/api-keys */ public function listApiKeys(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $keys = Database::fetchAll( 'SELECT ak.id, ak.name, ak.key_prefix, ak.scopes, ak.last_used_at, ak.expires_at, ak.is_active, ak.created_at, u.full_name as created_by_name FROM api_keys ak LEFT JOIN users u ON u.id = ak.created_by WHERE ak.organization_id = ? ORDER BY ak.created_at DESC', [$this->getCurrentOrgId()] ); // Decodifica scopes foreach ($keys as &$key) { $key['scopes'] = json_decode($key['scopes'], true) ?? []; } unset($key); $this->jsonSuccess([ 'api_keys' => $keys, 'available_scopes' => self::AVAILABLE_SCOPES, ]); } /** * POST /api/webhooks/api-keys * Crea nuova API key. Restituisce la chiave completa UNA SOLA VOLTA. */ public function createApiKey(): void { $this->requireOrgRole(['org_admin']); $this->validateRequired(['name', 'scopes']); $name = trim($this->getParam('name')); $scopes = $this->getParam('scopes'); if (is_string($scopes)) { $scopes = json_decode($scopes, true) ?? []; } // Valida scopes foreach ($scopes as $scope) { if (!array_key_exists($scope, self::AVAILABLE_SCOPES)) { $this->jsonError("Scope non valido: {$scope}", 400, 'INVALID_SCOPE'); } } if (empty($scopes)) { $this->jsonError('Almeno uno scope è richiesto', 400, 'EMPTY_SCOPES'); } // Genera chiave: nis2_ + 32 caratteri random $rawKey = 'nis2_' . bin2hex(random_bytes(16)); $prefix = substr($rawKey, 0, 12); // "nis2_xxxxxxx" (visibile) $keyHash = hash('sha256', $rawKey); $expiresAt = $this->getParam('expires_at'); $id = Database::insert('api_keys', [ 'organization_id' => $this->getCurrentOrgId(), 'created_by' => $this->getCurrentUserId(), 'name' => $name, 'key_prefix' => $prefix, 'key_hash' => $keyHash, 'scopes' => json_encode($scopes), 'expires_at' => $expiresAt ?: null, 'is_active' => 1, ]); $this->logAudit('api_key_created', 'api_key', $id, ['name' => $name, 'scopes' => $scopes]); $this->jsonSuccess([ 'id' => $id, 'name' => $name, 'key' => $rawKey, // ATTENZIONE: solo al momento della creazione! 'key_prefix' => $prefix, 'scopes' => $scopes, 'expires_at' => $expiresAt ?: null, 'created_at' => date('c'), 'warning' => 'Salva questa chiave in modo sicuro. Non sarà più visibile.', ], 'API Key creata con successo', 201); } /** * DELETE /api/webhooks/api-keys/{id} */ public function deleteApiKey(int $id): void { $this->requireOrgRole(['org_admin']); $key = Database::fetchOne( 'SELECT * FROM api_keys WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$key) { $this->jsonError('API Key non trovata', 404, 'NOT_FOUND'); } Database::query( 'UPDATE api_keys SET is_active = 0, updated_at = NOW() WHERE id = ?', [$id] ); $this->logAudit('api_key_revoked', 'api_key', $id, ['name' => $key['name']]); $this->jsonSuccess(null, 'API Key revocata'); } // ══════════════════════════════════════════════════════════════════════ // WEBHOOK SUBSCRIPTIONS // ══════════════════════════════════════════════════════════════════════ /** * GET /api/webhooks/subscriptions */ public function listSubscriptions(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $subs = Database::fetchAll( 'SELECT ws.*, u.full_name as created_by_name, (SELECT COUNT(*) FROM webhook_deliveries wd WHERE wd.subscription_id = ws.id) as total_deliveries, (SELECT COUNT(*) FROM webhook_deliveries wd WHERE wd.subscription_id = ws.id AND wd.status = "delivered") as success_deliveries, (SELECT COUNT(*) FROM webhook_deliveries wd WHERE wd.subscription_id = ws.id AND wd.status = "failed") as failed_deliveries FROM webhook_subscriptions ws LEFT JOIN users u ON u.id = ws.created_by WHERE ws.organization_id = ? ORDER BY ws.created_at DESC', [$this->getCurrentOrgId()] ); foreach ($subs as &$sub) { $sub['events'] = json_decode($sub['events'], true) ?? []; unset($sub['secret']); // non esporre il secret } unset($sub); $this->jsonSuccess([ 'subscriptions' => $subs, 'available_events' => $this->availableEvents(), ]); } /** * POST /api/webhooks/subscriptions */ public function createSubscription(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $this->validateRequired(['name', 'url', 'events']); $name = trim($this->getParam('name')); $url = trim($this->getParam('url')); $events = $this->getParam('events'); if (is_string($events)) { $events = json_decode($events, true) ?? []; } // Valida URL if (!filter_var($url, FILTER_VALIDATE_URL)) { $this->jsonError('URL non valido', 400, 'INVALID_URL'); } if (!in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'])) { $this->jsonError('URL deve essere http o https', 400, 'INVALID_URL_SCHEME'); } // Valida eventi $validEvents = array_keys($this->availableEvents()); foreach ($events as $evt) { if ($evt !== '*' && !in_array($evt, $validEvents)) { $this->jsonError("Evento non valido: {$evt}", 400, 'INVALID_EVENT'); } } // Genera secret HMAC $secret = bin2hex(random_bytes(24)); $id = Database::insert('webhook_subscriptions', [ 'organization_id' => $this->getCurrentOrgId(), 'created_by' => $this->getCurrentUserId(), 'name' => $name, 'url' => $url, 'secret' => $secret, 'events' => json_encode(array_values($events)), 'is_active' => 1, ]); $this->logAudit('webhook_created', 'webhook_subscription', $id, ['name' => $name, 'url' => $url]); $this->jsonSuccess([ 'id' => $id, 'name' => $name, 'url' => $url, 'secret' => $secret, // Solo al momento della creazione! 'events' => $events, 'warning' => 'Salva il secret. Sarà usato per verificare la firma X-NIS2-Signature.', ], 'Webhook creato', 201); } /** * PUT /api/webhooks/subscriptions/{id} */ public function updateSubscription(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $sub = Database::fetchOne( 'SELECT * FROM webhook_subscriptions WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$sub) { $this->jsonError('Webhook non trovato', 404, 'NOT_FOUND'); } $updates = []; if ($this->hasParam('name')) $updates['name'] = trim($this->getParam('name')); if ($this->hasParam('is_active')) $updates['is_active'] = (int)$this->getParam('is_active'); if ($this->hasParam('events')) { $events = $this->getParam('events'); if (is_string($events)) $events = json_decode($events, true) ?? []; $updates['events'] = json_encode(array_values($events)); } if (!empty($updates)) { $updates['updated_at'] = date('Y-m-d H:i:s'); $setClauses = implode(', ', array_map(fn($k) => "{$k} = ?", array_keys($updates))); Database::query( "UPDATE webhook_subscriptions SET {$setClauses} WHERE id = ?", array_merge(array_values($updates), [$id]) ); } $this->jsonSuccess(null, 'Webhook aggiornato'); } /** * DELETE /api/webhooks/subscriptions/{id} */ public function deleteSubscription(int $id): void { $this->requireOrgRole(['org_admin']); $sub = Database::fetchOne( 'SELECT * FROM webhook_subscriptions WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$sub) { $this->jsonError('Webhook non trovato', 404, 'NOT_FOUND'); } Database::query('DELETE FROM webhook_subscriptions WHERE id = ?', [$id]); $this->logAudit('webhook_deleted', 'webhook_subscription', $id, ['name' => $sub['name']]); $this->jsonSuccess(null, 'Webhook eliminato'); } /** * POST /api/webhooks/subscriptions/{id}/test * Invia un evento ping di test al webhook. */ public function testSubscription(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $sub = Database::fetchOne( 'SELECT * FROM webhook_subscriptions WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$sub) { $this->jsonError('Webhook non trovato', 404, 'NOT_FOUND'); } $webhookService = new WebhookService(); $testPayload = [ 'message' => 'Questo è un evento di test da NIS2 Agile.', 'timestamp' => date('c'), ]; $webhookService->dispatch($this->getCurrentOrgId(), 'webhook.test', $testPayload); $this->jsonSuccess([ 'subscription_id' => $id, 'url' => $sub['url'], 'event' => 'webhook.test', ], 'Ping di test inviato. Controlla i delivery log per il risultato.'); } // ══════════════════════════════════════════════════════════════════════ // DELIVERIES // ══════════════════════════════════════════════════════════════════════ /** * GET /api/webhooks/deliveries * Ultimi 100 delivery log per l'organizzazione. */ public function listDeliveries(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $subFilter = ''; $params = [$this->getCurrentOrgId()]; if ($this->hasParam('subscription_id')) { $subFilter = ' AND wd.subscription_id = ?'; $params[] = (int)$this->getParam('subscription_id'); } $deliveries = Database::fetchAll( "SELECT wd.id, wd.event_type, wd.event_id, wd.status, wd.http_status, wd.attempt, wd.delivered_at, wd.next_retry_at, wd.created_at, ws.name as subscription_name, ws.url FROM webhook_deliveries wd JOIN webhook_subscriptions ws ON ws.id = wd.subscription_id WHERE wd.organization_id = ? {$subFilter} ORDER BY wd.created_at DESC LIMIT 100", $params ); $this->jsonSuccess(['deliveries' => $deliveries]); } /** * POST /api/webhooks/retry * Processa retry pendenti (anche richiamabile da cron). */ public function processRetry(): void { $this->requireOrgRole(['org_admin']); $webhookService = new WebhookService(); $count = $webhookService->processRetries(); $this->jsonSuccess(['processed' => $count], "Processati {$count} retry"); } // ── Utility ─────────────────────────────────────────────────────────── private function availableEvents(): array { return [ 'incident.created' => 'Nuovo incidente creato', 'incident.updated' => 'Incidente aggiornato', 'incident.significant' => 'Incidente significativo (Art.23 attivato)', 'incident.deadline_warning' => 'Scadenza Art.23 imminente (24h/72h)', 'risk.high_created' => 'Nuovo rischio HIGH o CRITICAL', 'risk.updated' => 'Rischio aggiornato', 'compliance.score_changed' => 'Variazione compliance score >5%', 'policy.approved' => 'Policy approvata', 'policy.created' => 'Nuova policy creata', 'supplier.risk_flagged' => 'Fornitore con rischio HIGH/CRITICAL', 'assessment.completed' => 'Gap assessment completato', 'whistleblowing.received' => 'Nuova segnalazione ricevuta', 'normative.update' => 'Aggiornamento normativo NIS2/ACN', 'webhook.test' => 'Evento di test', '*' => 'Tutti gli eventi (wildcard)', ]; } }