[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>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
3f4b451e2a
commit
86e9bdded2
@@ -8,6 +8,7 @@
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
require_once APP_PATH . '/services/AIService.php';
|
||||
require_once APP_PATH . '/services/EmailService.php';
|
||||
require_once APP_PATH . '/services/WebhookService.php';
|
||||
|
||||
class IncidentController extends BaseController
|
||||
{
|
||||
@@ -97,6 +98,18 @@ class IncidentController extends BaseController
|
||||
'severity' => $data['severity'], 'is_significant' => $isSignificant
|
||||
]);
|
||||
|
||||
// Dispatch webhook events
|
||||
try {
|
||||
$incident = array_merge($data, ['id' => $incidentId]);
|
||||
$webhookSvc = new WebhookService();
|
||||
$webhookSvc->dispatch($this->getCurrentOrgId(), 'incident.created', WebhookService::incidentPayload($incident, 'created'));
|
||||
if ($isSignificant) {
|
||||
$webhookSvc->dispatch($this->getCurrentOrgId(), 'incident.significant', WebhookService::incidentPayload($incident, 'significant'));
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log('[WEBHOOK] dispatch error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$this->jsonSuccess([
|
||||
'id' => $incidentId,
|
||||
'incident_code' => $data['incident_code'],
|
||||
@@ -187,6 +200,18 @@ class IncidentController extends BaseController
|
||||
if (!empty($updates)) {
|
||||
Database::update('incidents', $updates, 'id = ?', [$id]);
|
||||
$this->logAudit('incident_updated', 'incident', $id, $updates);
|
||||
|
||||
// Dispatch webhook: incident.updated e incident.significant se appena flaggato
|
||||
try {
|
||||
$updatedIncident = Database::fetchOne('SELECT * FROM incidents WHERE id = ?', [$id]);
|
||||
$webhookSvc = new WebhookService();
|
||||
$webhookSvc->dispatch($this->getCurrentOrgId(), 'incident.updated', WebhookService::incidentPayload($updatedIncident, 'updated'));
|
||||
if (isset($updates['is_significant']) && $updates['is_significant'] && !$incident['is_significant']) {
|
||||
$webhookSvc->dispatch($this->getCurrentOrgId(), 'incident.significant', WebhookService::incidentPayload($updatedIncident, 'significant'));
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log('[WEBHOOK] dispatch error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->jsonSuccess($updates, 'Incidente aggiornato');
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile - Normative Controller
|
||||
*
|
||||
* Feed aggiornamenti normativi NIS2/ACN/DORA con ACK tracciato per audit.
|
||||
* Le organizzazioni devono documentare la presa visione degli aggiornamenti
|
||||
* normativi per dimostrare compliance continuativa.
|
||||
*
|
||||
* Endpoint:
|
||||
* GET /api/normative/list → lista aggiornamenti (filtrabili)
|
||||
* GET /api/normative/{id} → dettaglio aggiornamento
|
||||
* POST /api/normative/{id}/ack → conferma presa visione (con note)
|
||||
* GET /api/normative/pending → aggiornamenti non ancora ACK dall'org
|
||||
* GET /api/normative/stats → statistiche ACK per dashboard
|
||||
* POST /api/normative/create → crea aggiornamento (solo super_admin)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
require_once APP_PATH . '/services/WebhookService.php';
|
||||
|
||||
class NormativeController extends BaseController
|
||||
{
|
||||
/**
|
||||
* GET /api/normative/list
|
||||
*/
|
||||
public function list(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
|
||||
$conditions = ['nu.is_published = 1'];
|
||||
$params = [];
|
||||
|
||||
if ($this->hasParam('source')) {
|
||||
$conditions[] = 'nu.source = ?';
|
||||
$params[] = $this->getParam('source');
|
||||
}
|
||||
if ($this->hasParam('impact')) {
|
||||
$conditions[] = 'nu.impact_level = ?';
|
||||
$params[] = $this->getParam('impact');
|
||||
}
|
||||
if ($this->hasParam('action_required')) {
|
||||
$conditions[] = 'nu.action_required = ?';
|
||||
$params[] = (int)$this->getParam('action_required');
|
||||
}
|
||||
|
||||
$where = implode(' AND ', $conditions);
|
||||
$updates = Database::fetchAll(
|
||||
"SELECT nu.*,
|
||||
na.acknowledged_at,
|
||||
na.acknowledged_by,
|
||||
u.full_name as ack_by_name
|
||||
FROM normative_updates nu
|
||||
LEFT JOIN normative_ack na ON na.normative_update_id = nu.id AND na.organization_id = ?
|
||||
LEFT JOIN users u ON u.id = na.acknowledged_by
|
||||
WHERE {$where}
|
||||
ORDER BY
|
||||
CASE nu.impact_level WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 WHEN 'low' THEN 4 ELSE 5 END,
|
||||
nu.published_at DESC",
|
||||
array_merge([$orgId], $params)
|
||||
);
|
||||
|
||||
// Decodifica affected_domains
|
||||
foreach ($updates as &$u) {
|
||||
$u['affected_domains'] = json_decode($u['affected_domains'] ?? '[]', true) ?? [];
|
||||
$u['is_acknowledged'] = !empty($u['acknowledged_at']);
|
||||
}
|
||||
unset($u);
|
||||
|
||||
$this->jsonSuccess(['updates' => $updates, 'total' => count($updates)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/normative/{id}
|
||||
*/
|
||||
public function get(int $id): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
|
||||
$update = Database::fetchOne(
|
||||
'SELECT nu.*,
|
||||
na.acknowledged_at, na.acknowledged_by, na.notes as ack_notes,
|
||||
u.full_name as ack_by_name
|
||||
FROM normative_updates nu
|
||||
LEFT JOIN normative_ack na ON na.normative_update_id = nu.id AND na.organization_id = ?
|
||||
LEFT JOIN users u ON u.id = na.acknowledged_by
|
||||
WHERE nu.id = ? AND nu.is_published = 1',
|
||||
[$orgId, $id]
|
||||
);
|
||||
|
||||
if (!$update) {
|
||||
$this->jsonError('Aggiornamento non trovato', 404, 'NOT_FOUND');
|
||||
}
|
||||
|
||||
$update['affected_domains'] = json_decode($update['affected_domains'] ?? '[]', true) ?? [];
|
||||
$update['is_acknowledged'] = !empty($update['acknowledged_at']);
|
||||
|
||||
$this->jsonSuccess($update);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/normative/{id}/ack
|
||||
* Documenta la presa visione dell'aggiornamento normativo.
|
||||
*/
|
||||
public function acknowledge(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
|
||||
$update = Database::fetchOne(
|
||||
'SELECT id, title, impact_level FROM normative_updates WHERE id = ? AND is_published = 1',
|
||||
[$id]
|
||||
);
|
||||
if (!$update) { $this->jsonError('Aggiornamento non trovato', 404, 'NOT_FOUND'); }
|
||||
|
||||
// Verifica se già ACK
|
||||
$existing = Database::fetchOne(
|
||||
'SELECT id FROM normative_ack WHERE normative_update_id = ? AND organization_id = ?',
|
||||
[$id, $orgId]
|
||||
);
|
||||
|
||||
$notes = trim($this->getParam('notes', ''));
|
||||
|
||||
if ($existing) {
|
||||
// Aggiorna note se già ACK
|
||||
Database::execute(
|
||||
'UPDATE normative_ack SET notes = ?, acknowledged_by = ?, acknowledged_at = NOW()
|
||||
WHERE normative_update_id = ? AND organization_id = ?',
|
||||
[$notes ?: null, $this->getCurrentUserId(), $id, $orgId]
|
||||
);
|
||||
} else {
|
||||
Database::insert('normative_ack', [
|
||||
'normative_update_id' => $id,
|
||||
'organization_id' => $orgId,
|
||||
'acknowledged_by' => $this->getCurrentUserId(),
|
||||
'notes' => $notes ?: null,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->logAudit('normative_acknowledged', 'normative_update', $id, [
|
||||
'title' => $update['title'],
|
||||
'impact' => $update['impact_level'],
|
||||
]);
|
||||
|
||||
// Dispatch webhook
|
||||
try {
|
||||
(new WebhookService())->dispatch($orgId, 'normative.update', [
|
||||
'id' => $update['id'],
|
||||
'title' => $update['title'],
|
||||
'impact_level' => $update['impact_level'],
|
||||
'acknowledged' => true,
|
||||
'acknowledged_at' => date('c'),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[WEBHOOK] dispatch error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$this->jsonSuccess(['acknowledged_at' => date('c')], 'Presa visione registrata');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/normative/pending
|
||||
* Aggiornamenti non ancora ACK dall'organizzazione corrente.
|
||||
*/
|
||||
public function pending(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
|
||||
$pending = Database::fetchAll(
|
||||
'SELECT nu.id, nu.title, nu.source, nu.impact_level, nu.action_required,
|
||||
nu.effective_date, nu.published_at
|
||||
FROM normative_updates nu
|
||||
LEFT JOIN normative_ack na ON na.normative_update_id = nu.id AND na.organization_id = ?
|
||||
WHERE nu.is_published = 1 AND na.id IS NULL
|
||||
ORDER BY
|
||||
CASE nu.impact_level WHEN \'critical\' THEN 1 WHEN \'high\' THEN 2 WHEN \'medium\' THEN 3 ELSE 4 END,
|
||||
nu.published_at DESC',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'pending' => $pending,
|
||||
'count' => count($pending),
|
||||
'critical_count' => count(array_filter($pending, fn($u) => $u['impact_level'] === 'critical')),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/normative/stats
|
||||
*/
|
||||
public function stats(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
|
||||
$total = Database::fetchOne('SELECT COUNT(*) as n FROM normative_updates WHERE is_published = 1');
|
||||
$acked = Database::fetchOne(
|
||||
'SELECT COUNT(*) as n FROM normative_ack WHERE organization_id = ?',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
$ackRate = $total['n'] > 0 ? round(($acked['n'] / $total['n']) * 100) : 0;
|
||||
|
||||
$bySource = Database::fetchAll(
|
||||
'SELECT nu.source,
|
||||
COUNT(nu.id) as total,
|
||||
COUNT(na.id) as acknowledged
|
||||
FROM normative_updates nu
|
||||
LEFT JOIN normative_ack na ON na.normative_update_id = nu.id AND na.organization_id = ?
|
||||
WHERE nu.is_published = 1
|
||||
GROUP BY nu.source',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'total_updates' => (int)$total['n'],
|
||||
'acknowledged' => (int)$acked['n'],
|
||||
'pending' => (int)$total['n'] - (int)$acked['n'],
|
||||
'ack_rate' => $ackRate,
|
||||
'by_source' => $bySource,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/normative/create
|
||||
* Solo super_admin può pubblicare nuovi aggiornamenti normativi.
|
||||
*/
|
||||
public function create(): void
|
||||
{
|
||||
$this->requireSuperAdmin();
|
||||
$this->validateRequired(['title', 'source', 'summary', 'impact_level']);
|
||||
|
||||
$domains = $this->getParam('affected_domains', []);
|
||||
if (is_string($domains)) $domains = json_decode($domains, true) ?? [];
|
||||
|
||||
$id = Database::insert('normative_updates', [
|
||||
'title' => trim($this->getParam('title')),
|
||||
'source' => $this->getParam('source'),
|
||||
'source_label' => $this->getParam('source_label'),
|
||||
'reference' => $this->getParam('reference'),
|
||||
'summary' => trim($this->getParam('summary')),
|
||||
'content' => $this->getParam('content'),
|
||||
'impact_level' => $this->getParam('impact_level'),
|
||||
'affected_domains'=> json_encode(array_values($domains)),
|
||||
'action_required' => $this->getParam('action_required', 0) ? 1 : 0,
|
||||
'effective_date' => $this->getParam('effective_date') ?: null,
|
||||
'url' => $this->getParam('url') ?: null,
|
||||
'is_published' => 1,
|
||||
]);
|
||||
|
||||
$this->jsonSuccess(['id' => $id], 'Aggiornamento normativo pubblicato', 201);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
require_once APP_PATH . '/services/AIService.php';
|
||||
require_once APP_PATH . '/services/WebhookService.php';
|
||||
|
||||
class PolicyController extends BaseController
|
||||
{
|
||||
@@ -119,6 +120,19 @@ class PolicyController extends BaseController
|
||||
], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
|
||||
|
||||
$this->logAudit('policy_approved', 'policy', $id);
|
||||
|
||||
// Dispatch webhook policy.approved
|
||||
try {
|
||||
$policy = Database::fetchOne('SELECT * FROM policies WHERE id = ?', [$id]);
|
||||
(new WebhookService())->dispatch(
|
||||
$this->getCurrentOrgId(),
|
||||
'policy.approved',
|
||||
WebhookService::policyPayload($policy)
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[WEBHOOK] dispatch error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$this->jsonSuccess(null, 'Policy approvata');
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
require_once APP_PATH . '/services/AIService.php';
|
||||
require_once APP_PATH . '/services/WebhookService.php';
|
||||
|
||||
class RiskController extends BaseController
|
||||
{
|
||||
@@ -77,6 +78,23 @@ class RiskController extends BaseController
|
||||
|
||||
$this->logAudit('risk_created', 'risk', $riskId);
|
||||
|
||||
// Dispatch webhook per rischi HIGH/CRITICAL
|
||||
$riskScore = $likelihood * $impact;
|
||||
if ($riskScore >= 12) { // HIGH: 12-16, CRITICAL: >16 (su scala 5x5)
|
||||
try {
|
||||
$riskData = Database::fetchOne('SELECT * FROM risks WHERE id = ?', [$riskId]);
|
||||
$riskLevel = $riskScore >= 20 ? 'critical' : 'high';
|
||||
$riskData['risk_level'] = $riskLevel;
|
||||
(new WebhookService())->dispatch(
|
||||
$this->getCurrentOrgId(),
|
||||
'risk.high_created',
|
||||
WebhookService::riskPayload($riskData, 'created')
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[WEBHOOK] dispatch error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->jsonSuccess(['id' => $riskId], 'Rischio registrato', 201);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile - Services Controller
|
||||
*
|
||||
* API pubblica per sistemi esterni (SIEM, GRC, 231 Agile, SustainAI, AllRisk).
|
||||
* Autenticazione via API Key (header X-API-Key o Bearer nis2_xxx).
|
||||
* Rate limiting: 100 req/h per chiave.
|
||||
*
|
||||
* Endpoint:
|
||||
* GET /api/services/status
|
||||
* GET /api/services/compliance-summary
|
||||
* GET /api/services/risks/feed
|
||||
* GET /api/services/incidents/feed
|
||||
* GET /api/services/controls/status
|
||||
* GET /api/services/assets/critical
|
||||
* GET /api/services/suppliers/risk
|
||||
* GET /api/services/policies/approved
|
||||
* GET /api/services/openapi
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
|
||||
class ServicesController extends BaseController
|
||||
{
|
||||
// ─── API Key autenticata ──────────────────────────────────────────────
|
||||
private ?array $apiKeyRecord = null;
|
||||
private const RATE_LIMIT_DIR = '/tmp/nis2_api_ratelimit/';
|
||||
private const RATE_LIMIT_MAX = 100; // req per finestra
|
||||
private const RATE_LIMIT_WINDOW = 3600; // secondi (1 ora)
|
||||
|
||||
// ─── Versione API ─────────────────────────────────────────────────────
|
||||
private const API_VERSION = '1.0.0';
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// AUTH API KEY
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Autentica la richiesta via API Key.
|
||||
* Cerca in:
|
||||
* 1. Header X-API-Key
|
||||
* 2. Authorization: Bearer nis2_xxx
|
||||
* 3. Query string ?api_key=nis2_xxx
|
||||
*/
|
||||
private function requireApiKey(string $scope = 'read:all'): void
|
||||
{
|
||||
$rawKey = null;
|
||||
|
||||
// 1. Header X-API-Key
|
||||
$rawKey = $_SERVER['HTTP_X_API_KEY'] ?? null;
|
||||
|
||||
// 2. Bearer token
|
||||
if (!$rawKey) {
|
||||
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
if (str_starts_with($auth, 'Bearer nis2_')) {
|
||||
$rawKey = substr($auth, 7);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Query string
|
||||
if (!$rawKey) {
|
||||
$rawKey = $_GET['api_key'] ?? null;
|
||||
}
|
||||
|
||||
if (!$rawKey) {
|
||||
$this->jsonError('API Key mancante', 401, 'MISSING_API_KEY');
|
||||
}
|
||||
|
||||
// Hash SHA-256 della chiave
|
||||
$keyHash = hash('sha256', $rawKey);
|
||||
|
||||
// Cerca in DB
|
||||
$record = Database::fetchOne(
|
||||
'SELECT ak.*, o.name as org_name, o.nis2_entity_type, o.sector
|
||||
FROM api_keys ak
|
||||
JOIN organizations o ON o.id = ak.organization_id
|
||||
WHERE ak.key_hash = ? AND ak.is_active = 1
|
||||
AND (ak.expires_at IS NULL OR ak.expires_at > NOW())',
|
||||
[$keyHash]
|
||||
);
|
||||
|
||||
if (!$record) {
|
||||
$this->jsonError('API Key non valida o scaduta', 401, 'INVALID_API_KEY');
|
||||
}
|
||||
|
||||
// Verifica scope
|
||||
$scopes = json_decode($record['scopes'], true) ?? [];
|
||||
if (!in_array($scope, $scopes) && !in_array('read:all', $scopes)) {
|
||||
$this->jsonError("Scope '{$scope}' non autorizzato per questa chiave", 403, 'SCOPE_DENIED');
|
||||
}
|
||||
|
||||
// Rate limiting per API key
|
||||
$this->checkRateLimit($record['key_prefix']);
|
||||
|
||||
// Aggiorna last_used_at (async: non blocchiamo su errore)
|
||||
try {
|
||||
Database::execute(
|
||||
'UPDATE api_keys SET last_used_at = NOW() WHERE id = ?',
|
||||
[$record['id']]
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
// non critico
|
||||
}
|
||||
|
||||
$this->apiKeyRecord = $record;
|
||||
$this->currentOrgId = (int) $record['organization_id'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiting file-based per API Key
|
||||
*/
|
||||
private function checkRateLimit(string $keyPrefix): void
|
||||
{
|
||||
if (!is_dir(self::RATE_LIMIT_DIR)) {
|
||||
@mkdir(self::RATE_LIMIT_DIR, 0755, true);
|
||||
}
|
||||
|
||||
$file = self::RATE_LIMIT_DIR . 'key_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $keyPrefix) . '.json';
|
||||
$now = time();
|
||||
$data = ['count' => 0, 'window_start' => $now];
|
||||
|
||||
if (file_exists($file)) {
|
||||
$raw = @json_decode(file_get_contents($file), true);
|
||||
if ($raw && ($now - $raw['window_start']) < self::RATE_LIMIT_WINDOW) {
|
||||
$data = $raw;
|
||||
}
|
||||
}
|
||||
|
||||
if ($data['count'] >= self::RATE_LIMIT_MAX) {
|
||||
$retryAfter = self::RATE_LIMIT_WINDOW - ($now - $data['window_start']);
|
||||
header('Retry-After: ' . $retryAfter);
|
||||
header('X-RateLimit-Limit: ' . self::RATE_LIMIT_MAX);
|
||||
header('X-RateLimit-Remaining: 0');
|
||||
$this->jsonError('Rate limit superato. Max ' . self::RATE_LIMIT_MAX . ' req/h per API key.', 429, 'RATE_LIMITED');
|
||||
}
|
||||
|
||||
$data['count']++;
|
||||
file_put_contents($file, json_encode($data), LOCK_EX);
|
||||
|
||||
header('X-RateLimit-Limit: ' . self::RATE_LIMIT_MAX);
|
||||
header('X-RateLimit-Remaining: ' . (self::RATE_LIMIT_MAX - $data['count']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers standard per tutte le risposte Services API
|
||||
*/
|
||||
private function setServiceHeaders(): void
|
||||
{
|
||||
header('X-NIS2-API-Version: ' . self::API_VERSION);
|
||||
header('X-NIS2-Org-Id: ' . $this->currentOrgId);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// ENDPOINT
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* GET /api/services/status
|
||||
* Health check + info piattaforma. Nessuna auth richiesta.
|
||||
*/
|
||||
public function status(): void
|
||||
{
|
||||
$this->setServiceHeaders();
|
||||
|
||||
$this->jsonSuccess([
|
||||
'platform' => 'NIS2 Agile',
|
||||
'version' => self::API_VERSION,
|
||||
'status' => 'operational',
|
||||
'regulation' => ['EU 2022/2555', 'D.Lgs. 138/2024', 'ISO 27001/27005'],
|
||||
'ai_provider' => 'Anthropic Claude',
|
||||
'timestamp' => date('c'),
|
||||
'endpoints' => [
|
||||
'compliance_summary' => '/api/services/compliance-summary',
|
||||
'risks_feed' => '/api/services/risks/feed',
|
||||
'incidents_feed' => '/api/services/incidents/feed',
|
||||
'controls_status' => '/api/services/controls/status',
|
||||
'critical_assets' => '/api/services/assets/critical',
|
||||
'suppliers_risk' => '/api/services/suppliers/risk',
|
||||
'approved_policies' => '/api/services/policies/approved',
|
||||
'openapi' => '/api/services/openapi',
|
||||
'docs' => '/docs/api',
|
||||
],
|
||||
], 'NIS2 Agile Services API - Operational');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/services/compliance-summary
|
||||
* Compliance score aggregato per dominio Art.21.
|
||||
* Scope: read:compliance
|
||||
*/
|
||||
public function complianceSummary(): void
|
||||
{
|
||||
$this->requireApiKey('read:compliance');
|
||||
$this->setServiceHeaders();
|
||||
|
||||
$orgId = $this->currentOrgId;
|
||||
|
||||
// Score da assessment più recente completato
|
||||
$assessment = Database::fetchOne(
|
||||
'SELECT * FROM assessments WHERE organization_id = ? AND status = "completed"
|
||||
ORDER BY completed_at DESC LIMIT 1',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
$overallScore = null;
|
||||
$domainScores = [];
|
||||
$recommendations = [];
|
||||
|
||||
if ($assessment) {
|
||||
// Calcola score per dominio (10 categorie Art.21)
|
||||
$responses = Database::fetchAll(
|
||||
'SELECT ar.*, q.category, q.weight
|
||||
FROM assessment_responses ar
|
||||
JOIN (
|
||||
SELECT question_code, category, weight
|
||||
FROM (
|
||||
SELECT question_code,
|
||||
JSON_UNQUOTE(JSON_EXTRACT(question_data, "$.category")) as category,
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(question_data, "$.weight")) AS DECIMAL(3,1)) as weight
|
||||
FROM assessment_responses
|
||||
WHERE assessment_id = ?
|
||||
) t GROUP BY question_code
|
||||
) q ON q.question_code = ar.question_code
|
||||
WHERE ar.assessment_id = ?',
|
||||
[$assessment['id'], $assessment['id']]
|
||||
);
|
||||
|
||||
// Semplificato: score per categoria
|
||||
$byCategory = [];
|
||||
foreach ($responses as $r) {
|
||||
$cat = $r['category'] ?? 'uncategorized';
|
||||
if (!isset($byCategory[$cat])) {
|
||||
$byCategory[$cat] = ['total' => 0, 'count' => 0];
|
||||
}
|
||||
$val = (int) ($r['response_value'] ?? 0);
|
||||
$byCategory[$cat]['total'] += $val;
|
||||
$byCategory[$cat]['count']++;
|
||||
}
|
||||
|
||||
$totalScore = 0;
|
||||
$catCount = 0;
|
||||
foreach ($byCategory as $cat => $data) {
|
||||
$score = $data['count'] > 0
|
||||
? round(($data['total'] / ($data['count'] * 4)) * 100)
|
||||
: 0;
|
||||
$domainScores[] = [
|
||||
'domain' => $cat,
|
||||
'score' => $score,
|
||||
'status' => $score >= 70 ? 'compliant' : ($score >= 40 ? 'partial' : 'gap'),
|
||||
];
|
||||
$totalScore += $score;
|
||||
$catCount++;
|
||||
}
|
||||
|
||||
$overallScore = $catCount > 0 ? round($totalScore / $catCount) : 0;
|
||||
|
||||
// Raccomandazioni AI se disponibili
|
||||
if (!empty($assessment['ai_analysis'])) {
|
||||
$aiData = json_decode($assessment['ai_analysis'], true);
|
||||
$recommendations = $aiData['recommendations'] ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
// Risk summary
|
||||
$riskStats = Database::fetchOne(
|
||||
'SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = "open" THEN 1 ELSE 0 END) as open_count,
|
||||
SUM(CASE WHEN risk_level IN ("high","critical") AND status = "open" THEN 1 ELSE 0 END) as high_critical,
|
||||
SUM(CASE WHEN status = "mitigated" THEN 1 ELSE 0 END) as mitigated
|
||||
FROM risks WHERE organization_id = ?',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
// Incident summary
|
||||
$incidentStats = Database::fetchOne(
|
||||
'SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = "open" OR status = "investigating" THEN 1 ELSE 0 END) as open_count,
|
||||
SUM(CASE WHEN is_significant = 1 THEN 1 ELSE 0 END) as significant,
|
||||
SUM(CASE WHEN early_warning_sent = 1 THEN 1 ELSE 0 END) as notified_acn
|
||||
FROM incidents WHERE organization_id = ?',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
// Policy summary
|
||||
$policyStats = Database::fetchOne(
|
||||
'SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = "approved" THEN 1 ELSE 0 END) as approved,
|
||||
SUM(CASE WHEN status IN ("draft","review") THEN 1 ELSE 0 END) as pending
|
||||
FROM policies WHERE organization_id = ?',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
$org = Database::fetchOne(
|
||||
'SELECT name, nis2_entity_type, sector, employee_count FROM organizations WHERE id = ?',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'organization' => [
|
||||
'name' => $org['name'],
|
||||
'entity_type' => $org['nis2_entity_type'],
|
||||
'sector' => $org['sector'],
|
||||
],
|
||||
'overall_score' => $overallScore,
|
||||
'score_label' => $this->scoreLabel($overallScore),
|
||||
'domain_scores' => $domainScores,
|
||||
'assessment' => $assessment ? [
|
||||
'id' => $assessment['id'],
|
||||
'completed_at' => $assessment['completed_at'],
|
||||
'status' => $assessment['status'],
|
||||
] : null,
|
||||
'risks' => [
|
||||
'total' => (int)($riskStats['total'] ?? 0),
|
||||
'open' => (int)($riskStats['open_count'] ?? 0),
|
||||
'high_critical'=> (int)($riskStats['high_critical'] ?? 0),
|
||||
'mitigated' => (int)($riskStats['mitigated'] ?? 0),
|
||||
],
|
||||
'incidents' => [
|
||||
'total' => (int)($incidentStats['total'] ?? 0),
|
||||
'open' => (int)($incidentStats['open_count'] ?? 0),
|
||||
'significant' => (int)($incidentStats['significant'] ?? 0),
|
||||
'notified_acn' => (int)($incidentStats['notified_acn'] ?? 0),
|
||||
],
|
||||
'policies' => [
|
||||
'total' => (int)($policyStats['total'] ?? 0),
|
||||
'approved' => (int)($policyStats['approved'] ?? 0),
|
||||
'pending' => (int)($policyStats['pending'] ?? 0),
|
||||
],
|
||||
'top_recommendations' => array_slice($recommendations, 0, 5),
|
||||
'generated_at' => date('c'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/services/risks/feed
|
||||
* Feed rischi filtrabili.
|
||||
* Scope: read:risks
|
||||
* Query: ?level=high,critical &from=2026-01-01 &area=it &limit=50
|
||||
*/
|
||||
public function risksFeed(): void
|
||||
{
|
||||
$this->requireApiKey('read:risks');
|
||||
$this->setServiceHeaders();
|
||||
|
||||
$orgId = $this->currentOrgId;
|
||||
$where = 'r.organization_id = ? AND r.deleted_at IS NULL';
|
||||
$params = [$orgId];
|
||||
|
||||
if (!empty($_GET['level'])) {
|
||||
$levels = array_filter(explode(',', $_GET['level']));
|
||||
$placeholders = implode(',', array_fill(0, count($levels), '?'));
|
||||
$where .= " AND r.risk_level IN ({$placeholders})";
|
||||
$params = array_merge($params, $levels);
|
||||
}
|
||||
|
||||
if (!empty($_GET['area'])) {
|
||||
$where .= ' AND r.category = ?';
|
||||
$params[] = $_GET['area'];
|
||||
}
|
||||
|
||||
if (!empty($_GET['status'])) {
|
||||
$where .= ' AND r.status = ?';
|
||||
$params[] = $_GET['status'];
|
||||
}
|
||||
|
||||
if (!empty($_GET['from'])) {
|
||||
$where .= ' AND r.created_at >= ?';
|
||||
$params[] = $_GET['from'] . ' 00:00:00';
|
||||
}
|
||||
|
||||
$limit = min(200, max(1, (int)($_GET['limit'] ?? 50)));
|
||||
|
||||
$risks = Database::fetchAll(
|
||||
"SELECT r.id, r.title, r.description, r.category, r.likelihood,
|
||||
r.impact, r.inherent_risk_score, r.risk_level, r.status,
|
||||
r.treatment_plan, r.owner_name, r.residual_risk_score,
|
||||
r.created_at, r.updated_at
|
||||
FROM risks r
|
||||
WHERE {$where}
|
||||
ORDER BY r.inherent_risk_score DESC, r.created_at DESC
|
||||
LIMIT {$limit}",
|
||||
$params
|
||||
);
|
||||
|
||||
$total = Database::count('risks', 'organization_id = ? AND deleted_at IS NULL', [$orgId]);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'risks' => $risks,
|
||||
'total' => $total,
|
||||
'fetched' => count($risks),
|
||||
'filters' => [
|
||||
'level' => $_GET['level'] ?? null,
|
||||
'area' => $_GET['area'] ?? null,
|
||||
'status' => $_GET['status'] ?? null,
|
||||
'from' => $_GET['from'] ?? null,
|
||||
],
|
||||
'generated_at' => date('c'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/services/incidents/feed
|
||||
* Feed incidenti Art.23 filtrabili.
|
||||
* Scope: read:incidents
|
||||
* Query: ?status=open &severity=high,critical &from=2026-01-01 &significant=1
|
||||
*/
|
||||
public function incidentsFeed(): void
|
||||
{
|
||||
$this->requireApiKey('read:incidents');
|
||||
$this->setServiceHeaders();
|
||||
|
||||
$orgId = $this->currentOrgId;
|
||||
$where = 'organization_id = ?';
|
||||
$params = [$orgId];
|
||||
|
||||
if (!empty($_GET['status'])) {
|
||||
$where .= ' AND status = ?';
|
||||
$params[] = $_GET['status'];
|
||||
}
|
||||
|
||||
if (!empty($_GET['severity'])) {
|
||||
$severities = array_filter(explode(',', $_GET['severity']));
|
||||
$ph = implode(',', array_fill(0, count($severities), '?'));
|
||||
$where .= " AND severity IN ({$ph})";
|
||||
$params = array_merge($params, $severities);
|
||||
}
|
||||
|
||||
if (!empty($_GET['significant'])) {
|
||||
$where .= ' AND is_significant = 1';
|
||||
}
|
||||
|
||||
if (!empty($_GET['from'])) {
|
||||
$where .= ' AND detected_at >= ?';
|
||||
$params[] = $_GET['from'] . ' 00:00:00';
|
||||
}
|
||||
|
||||
$limit = min(200, max(1, (int)($_GET['limit'] ?? 50)));
|
||||
|
||||
$incidents = Database::fetchAll(
|
||||
"SELECT id, title, classification, severity, status, is_significant,
|
||||
detected_at, contained_at, resolved_at,
|
||||
early_warning_sent, early_warning_sent_at,
|
||||
notification_sent, notification_sent_at,
|
||||
final_report_sent, final_report_sent_at,
|
||||
notification_deadline, final_report_deadline,
|
||||
affected_systems, impact_description,
|
||||
created_at, updated_at
|
||||
FROM incidents
|
||||
WHERE {$where}
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT {$limit}",
|
||||
$params
|
||||
);
|
||||
|
||||
// Aggiungi stato scadenze Art.23
|
||||
$now = time();
|
||||
foreach ($incidents as &$inc) {
|
||||
$detectedTs = strtotime($inc['detected_at']);
|
||||
$inc['art23_status'] = [
|
||||
'early_warning_24h' => [
|
||||
'required' => (bool)$inc['is_significant'],
|
||||
'deadline' => date('c', $detectedTs + 86400),
|
||||
'sent' => (bool)$inc['early_warning_sent'],
|
||||
'overdue' => !$inc['early_warning_sent'] && $now > $detectedTs + 86400,
|
||||
],
|
||||
'notification_72h' => [
|
||||
'required' => (bool)$inc['is_significant'],
|
||||
'deadline' => date('c', $detectedTs + 259200),
|
||||
'sent' => (bool)$inc['notification_sent'],
|
||||
'overdue' => !$inc['notification_sent'] && $now > $detectedTs + 259200,
|
||||
],
|
||||
'final_report_30d' => [
|
||||
'required' => (bool)$inc['is_significant'],
|
||||
'deadline' => date('c', $detectedTs + 2592000),
|
||||
'sent' => (bool)$inc['final_report_sent'],
|
||||
'overdue' => !$inc['final_report_sent'] && $now > $detectedTs + 2592000,
|
||||
],
|
||||
];
|
||||
}
|
||||
unset($inc);
|
||||
|
||||
$total = Database::count('incidents', 'organization_id = ?', [$orgId]);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'incidents' => $incidents,
|
||||
'total' => $total,
|
||||
'fetched' => count($incidents),
|
||||
'generated_at' => date('c'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/services/controls/status
|
||||
* Stato controlli di sicurezza Art.21 per dominio.
|
||||
* Scope: read:compliance
|
||||
*/
|
||||
public function controlsStatus(): void
|
||||
{
|
||||
$this->requireApiKey('read:compliance');
|
||||
$this->setServiceHeaders();
|
||||
|
||||
$orgId = $this->currentOrgId;
|
||||
|
||||
$controls = Database::fetchAll(
|
||||
'SELECT id, control_code, title, category, status,
|
||||
implementation_notes, due_date, updated_at
|
||||
FROM compliance_controls
|
||||
WHERE organization_id = ?
|
||||
ORDER BY category, control_code',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
// Raggruppa per categoria
|
||||
$byCategory = [];
|
||||
foreach ($controls as $ctrl) {
|
||||
$cat = $ctrl['category'] ?? 'uncategorized';
|
||||
if (!isset($byCategory[$cat])) {
|
||||
$byCategory[$cat] = [
|
||||
'category' => $cat,
|
||||
'controls' => [],
|
||||
'stats' => ['total' => 0, 'implemented' => 0, 'partial' => 0, 'planned' => 0, 'not_applicable' => 0],
|
||||
];
|
||||
}
|
||||
$byCategory[$cat]['controls'][] = $ctrl;
|
||||
$byCategory[$cat]['stats']['total']++;
|
||||
$s = $ctrl['status'] ?? 'not_applicable';
|
||||
if (isset($byCategory[$cat]['stats'][$s])) {
|
||||
$byCategory[$cat]['stats'][$s]++;
|
||||
}
|
||||
}
|
||||
|
||||
// Score per categoria
|
||||
foreach ($byCategory as &$cat) {
|
||||
$t = $cat['stats']['total'];
|
||||
$i = $cat['stats']['implemented'];
|
||||
$p = $cat['stats']['partial'];
|
||||
$cat['score'] = $t > 0 ? round((($i + $p * 0.5) / $t) * 100) : 0;
|
||||
}
|
||||
unset($cat);
|
||||
|
||||
$totals = [
|
||||
'total' => count($controls),
|
||||
'implemented' => 0,
|
||||
'partial' => 0,
|
||||
'planned' => 0,
|
||||
'not_applicable' => 0,
|
||||
];
|
||||
foreach ($controls as $ctrl) {
|
||||
$s = $ctrl['status'] ?? 'not_applicable';
|
||||
if (isset($totals[$s])) $totals[$s]++;
|
||||
}
|
||||
$totals['overall_score'] = $totals['total'] > 0
|
||||
? round((($totals['implemented'] + $totals['partial'] * 0.5) / $totals['total']) * 100)
|
||||
: 0;
|
||||
|
||||
$this->jsonSuccess([
|
||||
'summary' => $totals,
|
||||
'by_category' => array_values($byCategory),
|
||||
'generated_at' => date('c'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/services/assets/critical
|
||||
* Asset critici e dipendenze.
|
||||
* Scope: read:assets
|
||||
* Query: ?type=server,network &criticality=high,critical
|
||||
*/
|
||||
public function assetsCritical(): void
|
||||
{
|
||||
$this->requireApiKey('read:assets');
|
||||
$this->setServiceHeaders();
|
||||
|
||||
$orgId = $this->currentOrgId;
|
||||
$where = 'organization_id = ?';
|
||||
$params = [$orgId];
|
||||
|
||||
if (!empty($_GET['type'])) {
|
||||
$types = array_filter(explode(',', $_GET['type']));
|
||||
$ph = implode(',', array_fill(0, count($types), '?'));
|
||||
$where .= " AND asset_type IN ({$ph})";
|
||||
$params = array_merge($params, $types);
|
||||
}
|
||||
|
||||
if (!empty($_GET['criticality'])) {
|
||||
$crits = array_filter(explode(',', $_GET['criticality']));
|
||||
$ph = implode(',', array_fill(0, count($crits), '?'));
|
||||
$where .= " AND criticality IN ({$ph})";
|
||||
$params = array_merge($params, $crits);
|
||||
} else {
|
||||
// Default: solo high e critical
|
||||
$where .= " AND criticality IN ('high','critical')";
|
||||
}
|
||||
|
||||
$assets = Database::fetchAll(
|
||||
"SELECT id, name, asset_type, criticality, status,
|
||||
owner_name, location, ip_address, description,
|
||||
dependencies, created_at
|
||||
FROM assets
|
||||
WHERE {$where}
|
||||
ORDER BY FIELD(criticality,'critical','high','medium','low'), name",
|
||||
$params
|
||||
);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'assets' => $assets,
|
||||
'total' => count($assets),
|
||||
'generated_at' => date('c'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/services/suppliers/risk
|
||||
* Supplier risk overview (supply chain security).
|
||||
* Scope: read:supply_chain
|
||||
* Query: ?risk_level=high,critical &status=active
|
||||
*/
|
||||
public function suppliersRisk(): void
|
||||
{
|
||||
$this->requireApiKey('read:supply_chain');
|
||||
$this->setServiceHeaders();
|
||||
|
||||
$orgId = $this->currentOrgId;
|
||||
$where = 's.organization_id = ? AND s.deleted_at IS NULL';
|
||||
$params = [$orgId];
|
||||
|
||||
if (!empty($_GET['risk_level'])) {
|
||||
$levels = array_filter(explode(',', $_GET['risk_level']));
|
||||
$ph = implode(',', array_fill(0, count($levels), '?'));
|
||||
$where .= " AND s.risk_level IN ({$ph})";
|
||||
$params = array_merge($params, $levels);
|
||||
}
|
||||
|
||||
if (!empty($_GET['status'])) {
|
||||
$where .= ' AND s.status = ?';
|
||||
$params[] = $_GET['status'];
|
||||
}
|
||||
|
||||
$suppliers = Database::fetchAll(
|
||||
"SELECT s.id, s.company_name, s.category, s.risk_level, s.status,
|
||||
s.last_assessment_date, s.assessment_score, s.contact_email,
|
||||
s.services_provided, s.critical_dependency,
|
||||
s.created_at, s.updated_at
|
||||
FROM suppliers s
|
||||
WHERE {$where}
|
||||
ORDER BY FIELD(s.risk_level,'critical','high','medium','low'), s.company_name",
|
||||
$params
|
||||
);
|
||||
|
||||
$stats = Database::fetchOne(
|
||||
"SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN risk_level IN ('high','critical') AND deleted_at IS NULL THEN 1 ELSE 0 END) as high_risk,
|
||||
SUM(CASE WHEN critical_dependency = 1 AND deleted_at IS NULL THEN 1 ELSE 0 END) as critical_deps,
|
||||
SUM(CASE WHEN last_assessment_date IS NULL AND deleted_at IS NULL THEN 1 ELSE 0 END) as unassessed
|
||||
FROM suppliers WHERE organization_id = ?",
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'summary' => $stats,
|
||||
'suppliers' => $suppliers,
|
||||
'generated_at' => date('c'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/services/policies/approved
|
||||
* Policy approvate con metadati (no contenuto full per default).
|
||||
* Scope: read:policies
|
||||
* Query: ?category=... &include_content=1
|
||||
*/
|
||||
public function policiesApproved(): void
|
||||
{
|
||||
$this->requireApiKey('read:policies');
|
||||
$this->setServiceHeaders();
|
||||
|
||||
$orgId = $this->currentOrgId;
|
||||
$includeContent = !empty($_GET['include_content']);
|
||||
|
||||
$select = $includeContent
|
||||
? 'id, title, category, nis2_article, status, version, approved_at, next_review_date, ai_generated, content'
|
||||
: 'id, title, category, nis2_article, status, version, approved_at, next_review_date, ai_generated';
|
||||
|
||||
$where = 'organization_id = ? AND status = "approved"';
|
||||
$params = [$orgId];
|
||||
|
||||
if (!empty($_GET['category'])) {
|
||||
$where .= ' AND category = ?';
|
||||
$params[] = $_GET['category'];
|
||||
}
|
||||
|
||||
$policies = Database::fetchAll(
|
||||
"SELECT {$select} FROM policies WHERE {$where} ORDER BY category, title",
|
||||
$params
|
||||
);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'policies' => $policies,
|
||||
'total' => count($policies),
|
||||
'generated_at' => date('c'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/services/openapi
|
||||
* Specifica OpenAPI 3.0 JSON per questa API.
|
||||
*/
|
||||
public function openapi(): void
|
||||
{
|
||||
$this->setServiceHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$spec = [
|
||||
'openapi' => '3.0.3',
|
||||
'info' => [
|
||||
'title' => 'NIS2 Agile Services API',
|
||||
'description' => 'API pubblica per integrazione con sistemi esterni. Espone dati di compliance NIS2, rischi, incidenti, controlli, asset e supply chain.',
|
||||
'version' => self::API_VERSION,
|
||||
'contact' => ['email' => 'presidenza@agile.software'],
|
||||
'license' => ['name' => 'Proprietary', 'url' => 'https://agile.software'],
|
||||
],
|
||||
'servers' => [
|
||||
['url' => 'https://nis2.certisource.it', 'description' => 'Production'],
|
||||
],
|
||||
'security' => [
|
||||
['ApiKeyHeader' => []],
|
||||
['BearerToken' => []],
|
||||
],
|
||||
'components' => [
|
||||
'securitySchemes' => [
|
||||
'ApiKeyHeader' => ['type' => 'apiKey', 'in' => 'header', 'name' => 'X-API-Key'],
|
||||
'BearerToken' => ['type' => 'http', 'scheme' => 'bearer', 'bearerFormat' => 'nis2_xxxxx'],
|
||||
],
|
||||
],
|
||||
'paths' => [
|
||||
'/api/services/status' => [
|
||||
'get' => [
|
||||
'summary' => 'Status piattaforma',
|
||||
'description' => 'Health check. Nessuna autenticazione richiesta.',
|
||||
'security' => [],
|
||||
'responses' => ['200' => ['description' => 'Platform operational']],
|
||||
'tags' => ['System'],
|
||||
],
|
||||
],
|
||||
'/api/services/compliance-summary' => [
|
||||
'get' => [
|
||||
'summary' => 'Compliance summary',
|
||||
'description' => 'Score aggregato per dominio Art.21, risk/incident/policy stats.',
|
||||
'responses' => ['200' => ['description' => 'Compliance summary'], '401' => ['description' => 'API Key mancante']],
|
||||
'tags' => ['Compliance'],
|
||||
],
|
||||
],
|
||||
'/api/services/risks/feed' => [
|
||||
'get' => [
|
||||
'summary' => 'Risk feed',
|
||||
'description' => 'Feed rischi filtrabili per level, area, status, data.',
|
||||
'parameters' => [
|
||||
['name' => 'level', 'in' => 'query', 'schema' => ['type' => 'string'], 'example' => 'high,critical'],
|
||||
['name' => 'area', 'in' => 'query', 'schema' => ['type' => 'string']],
|
||||
['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string']],
|
||||
['name' => 'from', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date']],
|
||||
['name' => 'limit', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 50, 'maximum' => 200]],
|
||||
],
|
||||
'responses' => ['200' => ['description' => 'List of risks']],
|
||||
'tags' => ['Risks'],
|
||||
],
|
||||
],
|
||||
'/api/services/incidents/feed' => [
|
||||
'get' => [
|
||||
'summary' => 'Incident feed Art.23',
|
||||
'description' => 'Feed incidenti con stato scadenze Art.23 (24h/72h/30d).',
|
||||
'parameters' => [
|
||||
['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string']],
|
||||
['name' => 'severity', 'in' => 'query', 'schema' => ['type' => 'string'], 'example' => 'high,critical'],
|
||||
['name' => 'significant', 'in' => 'query', 'schema' => ['type' => 'integer', 'enum' => [0, 1]]],
|
||||
['name' => 'from', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date']],
|
||||
],
|
||||
'responses' => ['200' => ['description' => 'List of incidents']],
|
||||
'tags' => ['Incidents'],
|
||||
],
|
||||
],
|
||||
'/api/services/controls/status' => [
|
||||
'get' => [
|
||||
'summary' => 'Controlli Art.21 status',
|
||||
'description' => 'Stato implementazione controlli per dominio di sicurezza.',
|
||||
'responses' => ['200' => ['description' => 'Controls by domain']],
|
||||
'tags' => ['Compliance'],
|
||||
],
|
||||
],
|
||||
'/api/services/assets/critical' => [
|
||||
'get' => [
|
||||
'summary' => 'Asset critici',
|
||||
'description' => 'Inventario asset con criticality high/critical.',
|
||||
'parameters' => [
|
||||
['name' => 'type', 'in' => 'query', 'schema' => ['type' => 'string']],
|
||||
['name' => 'criticality', 'in' => 'query', 'schema' => ['type' => 'string']],
|
||||
],
|
||||
'responses' => ['200' => ['description' => 'Critical assets']],
|
||||
'tags' => ['Assets'],
|
||||
],
|
||||
],
|
||||
'/api/services/suppliers/risk' => [
|
||||
'get' => [
|
||||
'summary' => 'Supplier risk overview',
|
||||
'description' => 'Supply chain risk: fornitori per livello rischio.',
|
||||
'parameters' => [
|
||||
['name' => 'risk_level', 'in' => 'query', 'schema' => ['type' => 'string']],
|
||||
['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string']],
|
||||
],
|
||||
'responses' => ['200' => ['description' => 'Suppliers risk data']],
|
||||
'tags' => ['Supply Chain'],
|
||||
],
|
||||
],
|
||||
'/api/services/policies/approved' => [
|
||||
'get' => [
|
||||
'summary' => 'Policy approvate',
|
||||
'description' => 'Lista policy con status approved.',
|
||||
'parameters' => [
|
||||
['name' => 'category', 'in' => 'query', 'schema' => ['type' => 'string']],
|
||||
['name' => 'include_content', 'in' => 'query', 'schema' => ['type' => 'integer', 'enum' => [0, 1]]],
|
||||
],
|
||||
'responses' => ['200' => ['description' => 'Approved policies']],
|
||||
'tags' => ['Policies'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'tags' => [
|
||||
['name' => 'System'],
|
||||
['name' => 'Compliance'],
|
||||
['name' => 'Risks'],
|
||||
['name' => 'Incidents'],
|
||||
['name' => 'Assets'],
|
||||
['name' => 'Supply Chain'],
|
||||
['name' => 'Policies'],
|
||||
],
|
||||
];
|
||||
|
||||
echo json_encode($spec, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Utility ───────────────────────────────────────────────────────────
|
||||
|
||||
private function scoreLabel(?int $score): string
|
||||
{
|
||||
if ($score === null) return 'not_assessed';
|
||||
if ($score >= 80) return 'compliant';
|
||||
if ($score >= 60) return 'substantially_compliant';
|
||||
if ($score >= 40) return 'partial';
|
||||
return 'significant_gaps';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
<?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)',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile - Whistleblowing Controller (Art.32 NIS2)
|
||||
*
|
||||
* Canale segnalazioni anomalie di sicurezza, con anonimato garantito.
|
||||
* Art. 32 D.Lgs. 138/2024: le entità NIS2 devono predisporre canali
|
||||
* interni per la segnalazione di violazioni alla sicurezza informatica.
|
||||
*
|
||||
* Endpoint:
|
||||
* POST /api/whistleblowing/submit → invia segnalazione (anonima o firmata)
|
||||
* GET /api/whistleblowing/list → lista segnalazioni (CISO/admin)
|
||||
* GET /api/whistleblowing/{id} → dettaglio segnalazione
|
||||
* PUT /api/whistleblowing/{id} → aggiorna status/priorità/note
|
||||
* POST /api/whistleblowing/{id}/assign → assegna a utente
|
||||
* POST /api/whistleblowing/{id}/close → chiudi segnalazione
|
||||
* GET /api/whistleblowing/stats → statistiche per dashboard
|
||||
* GET /api/whistleblowing/track-anonymous → tracking anonimo (via token)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
require_once APP_PATH . '/services/WebhookService.php';
|
||||
|
||||
class WhistleblowingController extends BaseController
|
||||
{
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// SUBMIT (pubblica — anche per utenti non autenticati)
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* POST /api/whistleblowing/submit
|
||||
* Invia segnalazione. Supporta anonima (no auth) o firmata (auth opzionale).
|
||||
*/
|
||||
public function submit(): void
|
||||
{
|
||||
// Nota: non richiede auth — supporta segnalazioni anonime
|
||||
$this->validateRequired(['category', 'title', 'description']);
|
||||
|
||||
$orgId = (int)($this->getParam('organization_id') ?: $this->getCurrentOrgId());
|
||||
if (!$orgId) {
|
||||
$this->jsonError('organization_id obbligatorio per segnalazioni anonime', 400, 'ORG_REQUIRED');
|
||||
}
|
||||
|
||||
$category = $this->getParam('category');
|
||||
$title = trim($this->getParam('title'));
|
||||
$description = trim($this->getParam('description'));
|
||||
$priority = $this->getParam('priority', 'medium');
|
||||
$contactEmail = $this->getParam('contact_email');
|
||||
$nisArticle = $this->getParam('nis2_article');
|
||||
|
||||
// Valida categoria
|
||||
$validCategories = ['security_incident','policy_violation','unauthorized_access','data_breach',
|
||||
'supply_chain_risk','corruption','fraud','nis2_non_compliance','other'];
|
||||
if (!in_array($category, $validCategories)) {
|
||||
$this->jsonError("Categoria non valida: {$category}", 400, 'INVALID_CATEGORY');
|
||||
}
|
||||
|
||||
// Determina se anonima
|
||||
$userId = null;
|
||||
$isAnonymous = 1;
|
||||
try {
|
||||
$userId = $this->getCurrentUserId();
|
||||
$isAnonymous = $this->getParam('is_anonymous', 0) ? 1 : 0;
|
||||
} catch (Throwable) {
|
||||
// Nessuna auth → forza anonima
|
||||
$isAnonymous = 1;
|
||||
}
|
||||
|
||||
// Token anonimo per tracking
|
||||
$anonymousToken = $isAnonymous ? bin2hex(random_bytes(24)) : null;
|
||||
|
||||
$code = $this->generateCode('WB');
|
||||
$reportId = Database::insert('whistleblowing_reports', [
|
||||
'organization_id' => $orgId,
|
||||
'report_code' => $code,
|
||||
'is_anonymous' => $isAnonymous,
|
||||
'submitted_by' => $isAnonymous ? null : $userId,
|
||||
'anonymous_token' => $anonymousToken,
|
||||
'contact_email' => $contactEmail ?: null,
|
||||
'category' => $category,
|
||||
'title' => $title,
|
||||
'description' => $description,
|
||||
'nis2_article' => $nisArticle ?: null,
|
||||
'priority' => in_array($priority, ['critical','high','medium','low']) ? $priority : 'medium',
|
||||
'status' => 'received',
|
||||
]);
|
||||
|
||||
// Prima voce timeline
|
||||
Database::insert('whistleblowing_timeline', [
|
||||
'report_id' => $reportId,
|
||||
'event_type' => 'received',
|
||||
'description' => 'Segnalazione ricevuta tramite canale interno.',
|
||||
'is_visible_to_reporter' => 1,
|
||||
]);
|
||||
|
||||
// Dispatch webhook
|
||||
try {
|
||||
(new WebhookService())->dispatch($orgId, 'whistleblowing.received', [
|
||||
'id' => $reportId,
|
||||
'code' => $code,
|
||||
'category' => $category,
|
||||
'priority' => $priority,
|
||||
'anonymous' => (bool)$isAnonymous,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[WEBHOOK] dispatch error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$this->jsonSuccess([
|
||||
'id' => $reportId,
|
||||
'report_code' => $code,
|
||||
'anonymous_token' => $anonymousToken, // Usabile per tracking se anonima
|
||||
'note' => $anonymousToken
|
||||
? 'Conserva questo token per verificare lo stato della segnalazione: /api/whistleblowing/track-anonymous?token=' . $anonymousToken
|
||||
: null,
|
||||
], 'Segnalazione ricevuta', 201);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// LIST (solo CISO/admin)
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* GET /api/whistleblowing/list
|
||||
*/
|
||||
public function list(): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
||||
|
||||
$conditions = ['wr.organization_id = ?'];
|
||||
$params = [$this->getCurrentOrgId()];
|
||||
|
||||
if ($this->hasParam('status')) {
|
||||
$conditions[] = 'wr.status = ?';
|
||||
$params[] = $this->getParam('status');
|
||||
}
|
||||
if ($this->hasParam('priority')) {
|
||||
$conditions[] = 'wr.priority = ?';
|
||||
$params[] = $this->getParam('priority');
|
||||
}
|
||||
if ($this->hasParam('category')) {
|
||||
$conditions[] = 'wr.category = ?';
|
||||
$params[] = $this->getParam('category');
|
||||
}
|
||||
|
||||
$where = implode(' AND ', $conditions);
|
||||
$reports = Database::fetchAll(
|
||||
"SELECT wr.id, wr.report_code, wr.category, wr.title, wr.priority, wr.status,
|
||||
wr.is_anonymous, wr.created_at, wr.closed_at,
|
||||
u.full_name as assigned_to_name
|
||||
FROM whistleblowing_reports wr
|
||||
LEFT JOIN users u ON u.id = wr.assigned_to
|
||||
WHERE {$where}
|
||||
ORDER BY
|
||||
CASE wr.priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END,
|
||||
wr.created_at DESC",
|
||||
$params
|
||||
);
|
||||
|
||||
$this->jsonSuccess(['reports' => $reports, 'total' => count($reports)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/whistleblowing/{id}
|
||||
*/
|
||||
public function get(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
||||
|
||||
$report = Database::fetchOne(
|
||||
'SELECT wr.*, u1.full_name as assigned_to_name, u2.full_name as submitted_by_name
|
||||
FROM whistleblowing_reports wr
|
||||
LEFT JOIN users u1 ON u1.id = wr.assigned_to
|
||||
LEFT JOIN users u2 ON u2.id = wr.submitted_by
|
||||
WHERE wr.id = ? AND wr.organization_id = ?',
|
||||
[$id, $this->getCurrentOrgId()]
|
||||
);
|
||||
|
||||
if (!$report) {
|
||||
$this->jsonError('Segnalazione non trovata', 404, 'NOT_FOUND');
|
||||
}
|
||||
|
||||
// Non esporre token e contact_email (privacy)
|
||||
unset($report['anonymous_token']);
|
||||
if ($report['is_anonymous']) unset($report['contact_email']);
|
||||
|
||||
$report['timeline'] = Database::fetchAll(
|
||||
'SELECT wt.*, u.full_name as created_by_name
|
||||
FROM whistleblowing_timeline wt
|
||||
LEFT JOIN users u ON u.id = wt.created_by
|
||||
WHERE wt.report_id = ?
|
||||
ORDER BY wt.created_at ASC',
|
||||
[$id]
|
||||
);
|
||||
|
||||
$this->jsonSuccess($report);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/whistleblowing/{id}
|
||||
* Aggiorna status, priorità, note di risoluzione.
|
||||
*/
|
||||
public function update(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
||||
|
||||
$report = Database::fetchOne(
|
||||
'SELECT * FROM whistleblowing_reports WHERE id = ? AND organization_id = ?',
|
||||
[$id, $this->getCurrentOrgId()]
|
||||
);
|
||||
if (!$report) { $this->jsonError('Segnalazione non trovata', 404, 'NOT_FOUND'); }
|
||||
|
||||
$updates = [];
|
||||
$oldStatus = $report['status'];
|
||||
|
||||
foreach (['priority', 'resolution_notes', 'nis2_article'] as $field) {
|
||||
if ($this->hasParam($field)) $updates[$field] = $this->getParam($field);
|
||||
}
|
||||
|
||||
if ($this->hasParam('status')) {
|
||||
$newStatus = $this->getParam('status');
|
||||
$validStatuses = ['received','under_review','investigating','resolved','closed','rejected'];
|
||||
if (!in_array($newStatus, $validStatuses)) {
|
||||
$this->jsonError("Status non valido: {$newStatus}", 400, 'INVALID_STATUS');
|
||||
}
|
||||
$updates['status'] = $newStatus;
|
||||
|
||||
// Aggiungi voce timeline su cambio status
|
||||
if ($newStatus !== $oldStatus) {
|
||||
Database::insert('whistleblowing_timeline', [
|
||||
'report_id' => $id,
|
||||
'event_type' => 'status_change',
|
||||
'description' => "Status cambiato da '{$oldStatus}' a '{$newStatus}'.",
|
||||
'new_status' => $newStatus,
|
||||
'created_by' => $this->getCurrentUserId(),
|
||||
'is_visible_to_reporter' => 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($updates)) {
|
||||
Database::execute(
|
||||
'UPDATE whistleblowing_reports SET ' .
|
||||
implode(', ', array_map(fn($k) => "{$k} = ?", array_keys($updates))) .
|
||||
', updated_at = NOW() WHERE id = ?',
|
||||
array_merge(array_values($updates), [$id])
|
||||
);
|
||||
$this->logAudit('whistleblowing_updated', 'whistleblowing', $id, $updates);
|
||||
}
|
||||
|
||||
$this->jsonSuccess(null, 'Segnalazione aggiornata');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/whistleblowing/{id}/assign
|
||||
*/
|
||||
public function assign(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
||||
$this->validateRequired(['user_id']);
|
||||
|
||||
$report = Database::fetchOne(
|
||||
'SELECT * FROM whistleblowing_reports WHERE id = ? AND organization_id = ?',
|
||||
[$id, $this->getCurrentOrgId()]
|
||||
);
|
||||
if (!$report) { $this->jsonError('Segnalazione non trovata', 404, 'NOT_FOUND'); }
|
||||
|
||||
$userId = (int)$this->getParam('user_id');
|
||||
Database::execute(
|
||||
'UPDATE whistleblowing_reports SET assigned_to = ?, updated_at = NOW() WHERE id = ?',
|
||||
[$userId, $id]
|
||||
);
|
||||
|
||||
$user = Database::fetchOne('SELECT full_name FROM users WHERE id = ?', [$userId]);
|
||||
Database::insert('whistleblowing_timeline', [
|
||||
'report_id' => $id,
|
||||
'event_type' => 'assigned',
|
||||
'description' => "Segnalazione assegnata a " . ($user['full_name'] ?? "utente #{$userId}"),
|
||||
'created_by' => $this->getCurrentUserId(),
|
||||
]);
|
||||
|
||||
$this->jsonSuccess(null, 'Segnalazione assegnata');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/whistleblowing/{id}/close
|
||||
*/
|
||||
public function close(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
||||
|
||||
$report = Database::fetchOne(
|
||||
'SELECT * FROM whistleblowing_reports WHERE id = ? AND organization_id = ?',
|
||||
[$id, $this->getCurrentOrgId()]
|
||||
);
|
||||
if (!$report) { $this->jsonError('Segnalazione non trovata', 404, 'NOT_FOUND'); }
|
||||
|
||||
$resolution = trim($this->getParam('resolution_notes', ''));
|
||||
Database::execute(
|
||||
'UPDATE whistleblowing_reports SET status = "closed", closed_at = NOW(),
|
||||
closed_by = ?, resolution_notes = ?, updated_at = NOW() WHERE id = ?',
|
||||
[$this->getCurrentUserId(), $resolution, $id]
|
||||
);
|
||||
|
||||
Database::insert('whistleblowing_timeline', [
|
||||
'report_id' => $id,
|
||||
'event_type' => 'closed',
|
||||
'description' => 'Segnalazione chiusa.' . ($resolution ? " Note: {$resolution}" : ''),
|
||||
'created_by' => $this->getCurrentUserId(),
|
||||
'is_visible_to_reporter' => 1,
|
||||
]);
|
||||
|
||||
$this->logAudit('whistleblowing_closed', 'whistleblowing', $id, ['resolution' => $resolution]);
|
||||
$this->jsonSuccess(null, 'Segnalazione chiusa');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/whistleblowing/stats
|
||||
*/
|
||||
public function stats(): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
|
||||
$stats = Database::fetchOne(
|
||||
'SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = "received" THEN 1 ELSE 0 END) as received,
|
||||
SUM(CASE WHEN status = "under_review" THEN 1 ELSE 0 END) as under_review,
|
||||
SUM(CASE WHEN status = "investigating" THEN 1 ELSE 0 END) as investigating,
|
||||
SUM(CASE WHEN status IN ("resolved","closed") THEN 1 ELSE 0 END) as closed,
|
||||
SUM(CASE WHEN priority = "critical" THEN 1 ELSE 0 END) as critical,
|
||||
SUM(CASE WHEN priority = "high" THEN 1 ELSE 0 END) as high,
|
||||
SUM(CASE WHEN is_anonymous = 1 THEN 1 ELSE 0 END) as anonymous_count
|
||||
FROM whistleblowing_reports
|
||||
WHERE organization_id = ?',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
$byCategory = Database::fetchAll(
|
||||
'SELECT category, COUNT(*) as count
|
||||
FROM whistleblowing_reports
|
||||
WHERE organization_id = ?
|
||||
GROUP BY category
|
||||
ORDER BY count DESC',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
$this->jsonSuccess(['stats' => $stats, 'by_category' => $byCategory]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/whistleblowing/track-anonymous
|
||||
* Permette a segnalante anonimo di verificare stato via token.
|
||||
*/
|
||||
public function trackAnonymous(): void
|
||||
{
|
||||
$token = $this->getParam('token');
|
||||
if (!$token) { $this->jsonError('Token obbligatorio', 400, 'TOKEN_REQUIRED'); }
|
||||
|
||||
$report = Database::fetchOne(
|
||||
'SELECT id, report_code, category, status, created_at
|
||||
FROM whistleblowing_reports
|
||||
WHERE anonymous_token = ?',
|
||||
[$token]
|
||||
);
|
||||
|
||||
if (!$report) { $this->jsonError('Token non valido', 404, 'INVALID_TOKEN'); }
|
||||
|
||||
// Solo eventi visibili al reporter
|
||||
$timeline = Database::fetchAll(
|
||||
'SELECT event_type, description, created_at
|
||||
FROM whistleblowing_timeline
|
||||
WHERE report_id = ? AND is_visible_to_reporter = 1
|
||||
ORDER BY created_at ASC',
|
||||
[$report['id']]
|
||||
);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'report_code' => $report['report_code'],
|
||||
'category' => $report['category'],
|
||||
'status' => $report['status'],
|
||||
'created_at' => $report['created_at'],
|
||||
'timeline' => $timeline,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
<?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'],
|
||||
'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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user