nis2-agile/application/services/WebhookService.php
DevEnv nis2-agile 13df162ec4 [FIX] SIM-06 + EmailService + WebhookService + supplier assessment
- ServicesController::provision(): created_by usa userId (INT) non string
- EmailService::logEmail(): rimosso sent_at (colonna non esiste in email_log)
- WebhookService::incidentPayload(): status ?? 'detected' (null-safe)
- simulate-nis2.php: supplier assessment usa formato assessment_responses
  corretto [{question, weight, value: yes|partial|no}]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 09:56:39 +01:00

321 lines
12 KiB
PHP

<?php
/**
* NIS2 Agile - Webhook Service
*
* Gestisce la consegna webhook outbound a sistemi esterni.
* Pattern: Stripe-like HMAC-SHA256, retry 3x con backoff esponenziale.
*
* Firma header: X-NIS2-Signature: sha256=HMAC_SHA256(body, secret)
* Retry: 1° tentativo immediato → 2° dopo 5min → 3° dopo 30min
*/
require_once APP_PATH . '/config/database.php';
class WebhookService
{
// Retry schedule: secondi di attesa per tentativo
private const RETRY_DELAYS = [0, 300, 1800]; // 0s, 5min, 30min
private const TIMEOUT_SEC = 10; // Timeout HTTP per delivery
private const MAX_RESPONSE_LEN = 2048; // Max byte di risposta salvata
// ══════════════════════════════════════════════════════════════════════
// DISPATCH (entry point principale)
// ══════════════════════════════════════════════════════════════════════
/**
* Dispatcha un evento a tutte le subscription attive per quell'org/evento.
* Si chiama sincrono nel request cycle (fire-and-forget con best effort).
*
* @param int $orgId Organizzazione proprietaria
* @param string $eventType Es. "incident.created"
* @param array $payload Dati dell'evento
* @param string $eventId UUID univoco evento (idempotency)
*/
public function dispatch(int $orgId, string $eventType, array $payload, string $eventId = ''): void
{
if (empty($eventId)) {
$eventId = $this->generateUuid();
}
// Trova subscription attive che ascoltano questo evento
$subscriptions = Database::fetchAll(
'SELECT * FROM webhook_subscriptions
WHERE organization_id = ? AND is_active = 1 AND failure_count < 10
ORDER BY id',
[$orgId]
);
foreach ($subscriptions as $sub) {
$events = json_decode($sub['events'], true) ?? [];
if (!in_array($eventType, $events) && !in_array('*', $events)) {
continue;
}
// Crea delivery record
$fullPayload = $this->buildPayload($eventType, $eventId, $payload, $sub);
$deliveryId = $this->createDelivery($sub, $eventType, $eventId, $fullPayload);
// Tenta consegna immediata
$this->attemptDelivery($deliveryId, $sub, $fullPayload);
}
}
/**
* Processa retry pendenti (chiamato via cron o endpoint admin).
*/
public function processRetries(): int
{
$pending = Database::fetchAll(
'SELECT wd.*, ws.url, ws.secret
FROM webhook_deliveries wd
JOIN webhook_subscriptions ws ON ws.id = wd.subscription_id
WHERE wd.status = "retrying"
AND wd.next_retry_at <= NOW()
AND wd.attempt <= 3
LIMIT 50'
);
$processed = 0;
foreach ($pending as $delivery) {
$sub = ['id' => $delivery['subscription_id'], 'url' => $delivery['url'], 'secret' => $delivery['secret']];
$this->attemptDelivery($delivery['id'], $sub, json_decode($delivery['payload'], true));
$processed++;
}
return $processed;
}
// ══════════════════════════════════════════════════════════════════════
// INTERNAL
// ══════════════════════════════════════════════════════════════════════
/**
* Costruisce il payload completo con envelope standard NIS2.
*/
private function buildPayload(string $eventType, string $eventId, array $data, array $sub): array
{
return [
'id' => $eventId,
'event' => $eventType,
'api_version' => '1.0.0',
'created' => time(),
'created_at' => date('c'),
'source' => 'nis2-agile',
'org_id' => $sub['organization_id'],
'data' => $data,
];
}
/**
* Crea record delivery nel DB, restituisce ID.
*/
private function createDelivery(array $sub, string $eventType, string $eventId, array $payload): int
{
return Database::insert('webhook_deliveries', [
'subscription_id' => $sub['id'],
'organization_id' => $sub['organization_id'],
'event_type' => $eventType,
'event_id' => $eventId,
'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE),
'status' => 'pending',
'attempt' => 1,
]);
}
/**
* Tenta la consegna HTTP di un webhook.
* Aggiorna il record delivery con il risultato.
*/
private function attemptDelivery(int $deliveryId, array $sub, array $payload): void
{
$bodyJson = json_encode($payload, JSON_UNESCAPED_UNICODE);
$signature = 'sha256=' . hash_hmac('sha256', $bodyJson, $sub['secret']);
$attempt = $payload['_attempt'] ?? 1;
$httpCode = null;
$responseBody = null;
$success = false;
try {
$ch = curl_init($sub['url']);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $bodyJson,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => self::TIMEOUT_SEC,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'User-Agent: NIS2-Agile-Webhooks/1.0',
'X-NIS2-Signature: ' . $signature,
'X-NIS2-Event: ' . $payload['event'],
'X-NIS2-Delivery-Id: ' . $deliveryId,
'X-NIS2-Attempt: ' . $attempt,
],
]);
$responseBody = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Successo: qualsiasi 2xx
$success = ($httpCode >= 200 && $httpCode < 300);
} catch (Throwable $e) {
$responseBody = 'Exception: ' . $e->getMessage();
$httpCode = 0;
}
// Tronca response
if (strlen($responseBody) > self::MAX_RESPONSE_LEN) {
$responseBody = substr($responseBody, 0, self::MAX_RESPONSE_LEN) . '...[truncated]';
}
if ($success) {
// Delivery riuscita
Database::execute(
'UPDATE webhook_deliveries
SET status = "delivered", http_status = ?, response_body = ?,
delivered_at = NOW(), updated_at = NOW()
WHERE id = ?',
[$httpCode, $responseBody, $deliveryId]
);
// Reset failure count
Database::execute(
'UPDATE webhook_subscriptions SET failure_count = 0, last_triggered_at = NOW() WHERE id = ?',
[$sub['id']]
);
} else {
// Calcola prossimo retry
$nextAttempt = $attempt + 1;
if ($nextAttempt <= 3) {
$delay = self::RETRY_DELAYS[$attempt] ?? 1800;
$nextRetry = date('Y-m-d H:i:s', time() + $delay);
Database::execute(
'UPDATE webhook_deliveries
SET status = "retrying", http_status = ?, response_body = ?,
attempt = ?, next_retry_at = ?, updated_at = NOW()
WHERE id = ?',
[$httpCode, $responseBody, $nextAttempt, $nextRetry, $deliveryId]
);
} else {
// Tutti i tentativi esauriti
Database::execute(
'UPDATE webhook_deliveries
SET status = "failed", http_status = ?, response_body = ?,
updated_at = NOW()
WHERE id = ?',
[$httpCode, $responseBody, $deliveryId]
);
// Incrementa failure count subscription
Database::execute(
'UPDATE webhook_subscriptions
SET failure_count = failure_count + 1, updated_at = NOW()
WHERE id = ?',
[$sub['id']]
);
}
}
}
// ── Payload builders per evento ───────────────────────────────────────
/**
* Costruisce payload per evento incident.created / incident.updated
*/
public static function incidentPayload(array $incident, string $action = 'created'): array
{
return [
'action' => $action,
'incident' => [
'id' => $incident['id'],
'title' => $incident['title'],
'classification' => $incident['classification'],
'severity' => $incident['severity'],
'status' => $incident['status'] ?? 'detected',
'is_significant' => (bool)$incident['is_significant'],
'detected_at' => $incident['detected_at'],
'art23_deadlines' => [
'early_warning' => date('c', strtotime($incident['detected_at']) + 86400),
'notification' => date('c', strtotime($incident['detected_at']) + 259200),
'final_report' => date('c', strtotime($incident['detected_at']) + 2592000),
],
],
];
}
/**
* Costruisce payload per evento risk.high_created / risk.critical_created
*/
public static function riskPayload(array $risk, string $action = 'created'): array
{
return [
'action' => $action,
'risk' => [
'id' => $risk['id'],
'title' => $risk['title'],
'category' => $risk['category'],
'likelihood' => $risk['likelihood'],
'impact' => $risk['impact'],
'risk_level' => $risk['risk_level'],
'risk_score' => $risk['inherent_risk_score'],
'status' => $risk['status'],
'created_at' => $risk['created_at'],
],
];
}
/**
* Costruisce payload per evento policy.approved
*/
public static function policyPayload(array $policy): array
{
return [
'action' => 'approved',
'policy' => [
'id' => $policy['id'],
'title' => $policy['title'],
'category' => $policy['category'],
'nis2_article'=> $policy['nis2_article'],
'version' => $policy['version'],
'approved_at' => $policy['approved_at'],
'ai_generated'=> (bool)$policy['ai_generated'],
],
];
}
/**
* Costruisce payload per evento compliance.score_changed
*/
public static function scorePayload(int $orgId, ?int $previousScore, int $newScore): array
{
return [
'previous_score' => $previousScore,
'new_score' => $newScore,
'delta' => $newScore - ($previousScore ?? 0),
'label' => $newScore >= 80 ? 'compliant'
: ($newScore >= 60 ? 'substantially_compliant'
: ($newScore >= 40 ? 'partial' : 'significant_gaps')),
];
}
// ── UUID ──────────────────────────────────────────────────────────────
private function generateUuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
}