[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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user