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