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>
256 lines
9.3 KiB
PHP
256 lines
9.3 KiB
PHP
<?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);
|
|
}
|
|
}
|