Database non ha metodo execute() — corretto in: InviteController, ServicesController, WebhookController, NormativeController, WhistleblowingController. Causa del HTTP 500 su tutti gli endpoint /api/invites/*. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
406 lines
16 KiB
PHP
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::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)',
|
|
];
|
|
}
|
|
}
|