nis2-agile/application/controllers/WebhookController.php
DevEnv nis2-agile 86e9bdded2 [FEAT] Services API, Webhook, Whistleblowing, Normative + integrazioni
Sprint completo — prodotto presentation-ready:

Services API (read-only, API Key + scope):
- GET /api/services/status|compliance-summary|risks-feed|incidents-feed
- GET /api/services/controls-status|assets-critical|suppliers-risk|policies-approved
- GET /api/services/openapi (spec OpenAPI 3.0.3 JSON)

Webhook Outbound (Stripe-like HMAC-SHA256):
- CRUD api_keys + webhook_subscriptions (Settings → 2 nuovi tab)
- WebhookService: retry 3x backoff (0s/5min/30min), delivery log
- Trigger auto in IncidentController, RiskController, PolicyController
- Delivery log, test ping, processRetry

Nuovi moduli:
- WhistleblowingController (Art.32 NIS2): anonimato garantito, timeline, token tracking
- NormativeController: feed NIS2/ACN/DORA con ACK tracciato per audit

Frontend:
- whistleblowing.html: form submit anonimo/firmato + gestione CISO
- normative.html: feed con presa visione documentata + progress bar ACK
- public/docs/api.html: documentazione API dark theme (Swagger-like)
- settings.html: tab API Keys + tab Webhook
- integrations/: guide per lg231, SustainAI, AllRisk, SIEM (widget + codice)
- Sidebar: Segnalazioni + Normative aggiunte a common.js

DB: migration 007 (api_keys, webhook_subscriptions, webhook_deliveries),
    008 (whistleblowing_reports + timeline),
    009 (normative_updates + normative_ack + seed NIS2/ACN/DORA/ISO)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 13:20:24 +01:00

406 lines
16 KiB
PHP

<?php
/**
* NIS2 Agile - Webhook Controller
*
* CRUD per API Keys e Webhook Subscriptions.
* Gestione completa dal pannello Settings.
*
* Endpoint:
* --- API KEYS ---
* GET /api/webhooks/api-keys → lista API keys org
* POST /api/webhooks/api-keys → crea nuova API key
* DELETE /api/webhooks/api-keys/{id} → revoca API key
*
* --- WEBHOOK SUBSCRIPTIONS ---
* GET /api/webhooks/subscriptions → lista subscriptions
* POST /api/webhooks/subscriptions → crea subscription
* PUT /api/webhooks/subscriptions/{id} → aggiorna subscription
* DELETE /api/webhooks/subscriptions/{id} → elimina subscription
* POST /api/webhooks/subscriptions/{id}/test → invia ping di test
*
* --- DELIVERIES ---
* GET /api/webhooks/deliveries → log delivery ultimi 100
* POST /api/webhooks/retry → processa retry pendenti
*/
require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/WebhookService.php';
class WebhookController extends BaseController
{
// Scopes disponibili
private const AVAILABLE_SCOPES = [
'read:all' => '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::execute(
'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::execute(
"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::execute('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)',
];
}
}