[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:
DevEnv nis2-agile
2026-03-07 13:20:24 +01:00
co-authored by Claude Sonnet 4.6
parent 3f4b451e2a
commit 86e9bdded2
22 changed files with 5080 additions and 10 deletions
+325 -5
View File
@@ -212,6 +212,8 @@
<button class="settings-tab" onclick="switchTab('profile')">Profilo</button>
<button class="settings-tab" onclick="switchTab('members')">Membri</button>
<button class="settings-tab" onclick="switchTab('security')">Sicurezza</button>
<button class="settings-tab" onclick="switchTab('apikeys')">API Keys</button>
<button class="settings-tab" onclick="switchTab('webhooks')">Webhook</button>
</div>
<!-- ══════════════ TAB: Organizzazione ══════════════ -->
@@ -479,6 +481,64 @@
</div>
</div>
</div>
<!-- ══════════════ TAB: API Keys ══════════════ -->
<div class="tab-panel" id="tab-apikeys">
<div class="card mb-24">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
<div>
<h3>API Keys</h3>
<p style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">
Chiavi per accesso esterno alle API NIS2 Agile (SIEM, GRC, dashboard esterne).
</p>
</div>
<button class="btn btn-primary" onclick="showCreateApiKeyModal()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
Nuova API Key
</button>
</div>
<div class="card-body" style="padding:0;">
<div id="apikeys-container"><div class="spinner" style="margin:40px auto;"></div></div>
</div>
</div>
<div class="card">
<div class="card-header"><h3>Scope Disponibili</h3></div>
<div class="card-body">
<div id="available-scopes-container"></div>
</div>
</div>
</div>
<!-- ══════════════ TAB: Webhook ══════════════ -->
<div class="tab-panel" id="tab-webhooks">
<div class="card mb-24">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
<div>
<h3>Webhook Subscriptions</h3>
<p style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">
Notifiche push verso sistemi esterni (SIEM, 231 Agile, SustainAI) su eventi NIS2.
</p>
</div>
<button class="btn btn-primary" onclick="showCreateWebhookModal()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
Nuovo Webhook
</button>
</div>
<div class="card-body" style="padding:0;">
<div id="webhooks-container"><div class="spinner" style="margin:40px auto;"></div></div>
</div>
</div>
<div class="card">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3>Delivery Log</h3>
<button class="btn btn-sm btn-secondary" onclick="loadDeliveries()">Aggiorna</button>
</div>
<div class="card-body" style="padding:0;">
<div id="deliveries-container"><div class="empty-state" style="padding:32px;"><p>Seleziona un webhook per vedere i delivery.</p></div></div>
</div>
</div>
</div>
</div>
</main>
</div>
@@ -506,20 +566,19 @@
// ── Tab Navigation ───────────────────────────────────────
function switchTab(tab) {
// Rimuovi active da tutti i tab e pannelli
document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
// Attiva il tab selezionato
const tabMap = { org: 0, profile: 1, members: 2, security: 3 };
const panelMap = { org: 'tab-org', profile: 'tab-profile', members: 'tab-members', security: 'tab-security' };
const tabMap = { org: 0, profile: 1, members: 2, security: 3, apikeys: 4, webhooks: 5 };
const panelMap = { org: 'tab-org', profile: 'tab-profile', members: 'tab-members', security: 'tab-security', apikeys: 'tab-apikeys', webhooks: 'tab-webhooks' };
document.querySelectorAll('.settings-tab')[tabMap[tab]].classList.add('active');
document.getElementById(panelMap[tab]).classList.add('active');
// Carica dati del tab se necessario
if (tab === 'members') loadMembers();
if (tab === 'security') loadAuditLog();
if (tab === 'apikeys') loadApiKeys();
if (tab === 'webhooks') { loadWebhooks(); loadDeliveries(); }
}
// ── Settori NIS2 ─────────────────────────────────────────
@@ -979,6 +1038,267 @@
}
}
// ── API Keys ─────────────────────────────────────────────
async function loadApiKeys() {
const container = document.getElementById('apikeys-container');
container.innerHTML = '<div class="spinner" style="margin:40px auto;"></div>';
try {
const result = await api.request('GET', '/webhooks/api-keys');
if (result.success) {
renderApiKeys(result.data.api_keys || []);
renderAvailableScopes(result.data.available_scopes || {});
}
} catch (e) { container.innerHTML = '<div class="empty-state"><h4>Errore caricamento API Keys</h4></div>'; }
}
function renderApiKeys(keys) {
const container = document.getElementById('apikeys-container');
if (!keys.length) {
container.innerHTML = `<div class="empty-state" style="padding:40px;"><svg viewBox="0 0 20 20" fill="currentColor" width="40" height="40" style="color:var(--gray-300)"><path fill-rule="evenodd" d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z" clip-rule="evenodd"/></svg><h4>Nessuna API Key</h4><p>Crea una chiave per integrare sistemi esterni.</p></div>`;
return;
}
let html = `<div class="table-container"><table><thead><tr><th>Nome</th><th>Prefisso</th><th>Scopes</th><th>Ultimo Uso</th><th>Scadenza</th><th>Stato</th><th>Azioni</th></tr></thead><tbody>`;
keys.forEach(k => {
const active = k.is_active ? '<span class="badge badge-success">Attiva</span>' : '<span class="badge badge-danger">Revocata</span>';
const scopes = (k.scopes || []).map(s => `<span class="badge badge-neutral" style="font-size:0.65rem;">${escapeHtml(s)}</span>`).join(' ');
html += `<tr>
<td><strong>${escapeHtml(k.name)}</strong><br><small style="color:var(--gray-400);">Creata da ${escapeHtml(k.created_by_name || '-')}</small></td>
<td><code style="font-size:0.8rem;">${escapeHtml(k.key_prefix)}...</code></td>
<td>${scopes}</td>
<td>${k.last_used_at ? formatDateTime(k.last_used_at) : '<span style="color:var(--gray-400);">Mai</span>'}</td>
<td>${k.expires_at ? formatDate(k.expires_at) : '<span style="color:var(--gray-400);">Nessuna</span>'}</td>
<td>${active}</td>
<td>${k.is_active ? `<button class="btn-icon-action btn-danger-hover" onclick="revokeApiKey(${k.id}, '${escapeHtml(k.name)}')" title="Revoca"><svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9z" clip-rule="evenodd"/></svg></button>` : ''}</td>
</tr>`;
});
html += '</tbody></table></div>';
container.innerHTML = html;
}
function renderAvailableScopes(scopes) {
const container = document.getElementById('available-scopes-container');
let html = '<div style="display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:12px;">';
Object.entries(scopes).forEach(([key, desc]) => {
html += `<div style="padding:12px; background:var(--gray-50); border-radius:var(--border-radius); border:1px solid var(--gray-200);">
<code style="font-size:0.8rem; color:var(--primary);">${escapeHtml(key)}</code>
<p style="font-size:0.8rem; color:var(--gray-600); margin-top:4px;">${escapeHtml(desc)}</p>
</div>`;
});
html += '</div>';
container.innerHTML = html;
}
function showCreateApiKeyModal() {
showModal('Crea API Key', `
<div class="form-group">
<label class="form-label">Nome *</label>
<input type="text" id="apikey-name" class="form-control" placeholder="Es. SIEM Integration, Dashboard ESG..." required>
</div>
<div class="form-group">
<label class="form-label">Scopes *</label>
<div id="apikey-scopes-checkboxes" style="display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:8px;">
${['read:all','read:compliance','read:risks','read:incidents','read:assets','read:supply_chain','read:policies'].map(s =>
`<label style="display:flex; align-items:center; gap:8px; font-size:0.875rem; cursor:pointer;">
<input type="checkbox" name="scope" value="${s}" style="accent-color:var(--primary);">
<code style="font-size:0.75rem;">${s}</code>
</label>`
).join('')}
</div>
</div>
<div class="form-group">
<label class="form-label">Scadenza (opzionale)</label>
<input type="date" id="apikey-expires" class="form-control">
</div>
`, '<button class="btn btn-primary" onclick="createApiKey()">Crea API Key</button>');
}
async function createApiKey() {
const name = document.getElementById('apikey-name').value.trim();
const scopes = [...document.querySelectorAll('input[name="scope"]:checked')].map(cb => cb.value);
const expires = document.getElementById('apikey-expires').value;
if (!name) { showNotification('Inserisci un nome per la chiave.', 'error'); return; }
if (!scopes.length) { showNotification('Seleziona almeno uno scope.', 'error'); return; }
try {
const result = await api.request('POST', '/webhooks/api-keys', { name, scopes, expires_at: expires || null });
if (result.success) {
closeModal();
showModal('API Key Creata', `
<div style="padding:16px; background:var(--warning-bg); border:1px solid var(--warning); border-radius:var(--border-radius); margin-bottom:16px;">
<strong style="color:#a16207;">⚠ Salva questa chiave ora. Non sarà più visibile.</strong>
</div>
<div style="padding:12px; background:var(--gray-900); border-radius:var(--border-radius); font-family:monospace; font-size:0.875rem; color:#34d399; word-break:break-all; user-select:all;">
${escapeHtml(result.data.key)}
</div>
<p style="font-size:0.8rem; color:var(--gray-500); margin-top:8px;">Usa come header: <code>X-API-Key: ${escapeHtml(result.data.key)}</code></p>
`, '<button class="btn btn-primary" onclick="closeModal(); loadApiKeys();">Ho salvato la chiave</button>');
} else { showNotification(result.message || 'Errore.', 'error'); }
} catch (e) { showNotification('Errore di connessione.', 'error'); }
}
async function revokeApiKey(id, name) {
if (!confirm(`Revocare la chiave "${name}"? L'operazione è irreversibile.`)) return;
try {
const result = await api.request('DELETE', `/webhooks/api-keys/${id}`);
if (result.success) { showNotification('API Key revocata.', 'success'); loadApiKeys(); }
else showNotification(result.message || 'Errore.', 'error');
} catch (e) { showNotification('Errore di connessione.', 'error'); }
}
// ── Webhooks ─────────────────────────────────────────────
const availableEvents = {
'incident.created': 'Nuovo incidente', 'incident.updated': 'Incidente aggiornato',
'incident.significant': 'Incidente significativo (Art.23)', 'incident.deadline_warning': 'Scadenza Art.23 imminente',
'risk.high_created': 'Rischio HIGH/CRITICAL', 'risk.updated': 'Rischio aggiornato',
'compliance.score_changed': 'Variazione compliance >5%', 'policy.approved': 'Policy approvata',
'policy.created': 'Nuova policy', 'supplier.risk_flagged': 'Fornitore a rischio',
'assessment.completed': 'Assessment completato', 'whistleblowing.received': 'Nuova segnalazione',
'normative.update': 'Aggiornamento normativo', 'webhook.test': 'Test',
'*': 'Tutti gli eventi (wildcard)'
};
async function loadWebhooks() {
const container = document.getElementById('webhooks-container');
container.innerHTML = '<div class="spinner" style="margin:40px auto;"></div>';
try {
const result = await api.request('GET', '/webhooks/subscriptions');
if (result.success) renderWebhooks(result.data.subscriptions || []);
} catch (e) { container.innerHTML = '<div class="empty-state"><h4>Errore caricamento webhook</h4></div>'; }
}
function renderWebhooks(subs) {
const container = document.getElementById('webhooks-container');
if (!subs.length) {
container.innerHTML = `<div class="empty-state" style="padding:40px;"><svg viewBox="0 0 20 20" fill="currentColor" width="40" height="40" style="color:var(--gray-300)"><path d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z"/><path d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767c.28.149.599.233.938.233h2l3 3v-3h2a2 2 0 002-2V9a2 2 0 00-2-2h-1z"/></svg><h4>Nessun Webhook</h4><p>Configura webhook per notifiche push verso SIEM e sistemi esterni.</p></div>`;
return;
}
let html = `<div class="table-container"><table><thead><tr><th>Nome</th><th>URL</th><th>Eventi</th><th>Delivery</th><th>Stato</th><th>Azioni</th></tr></thead><tbody>`;
subs.forEach(s => {
const evts = (s.events || []).map(e => `<span class="badge badge-neutral" style="font-size:0.65rem;">${escapeHtml(e)}</span>`).join(' ');
const active = s.is_active ? '<span class="badge badge-success">Attivo</span>' : '<span class="badge badge-warning">Pausa</span>';
const deliveryInfo = `<small style="color:var(--gray-500);">${s.success_deliveries||0}✓ ${s.failed_deliveries||0}✗</small>`;
html += `<tr>
<td><strong>${escapeHtml(s.name)}</strong>${s.failure_count >= 5 ? '<br><span style="color:var(--danger);font-size:0.75rem;">⚠ '+s.failure_count+' errori</span>' : ''}</td>
<td><code style="font-size:0.75rem; word-break:break-all;">${escapeHtml(s.url)}</code></td>
<td>${evts}</td>
<td>${deliveryInfo}</td>
<td>${active}</td>
<td style="white-space:nowrap;">
<button class="btn-icon-action" onclick="testWebhook(${s.id})" title="Test Ping">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/></svg>
</button>
<button class="btn-icon-action" onclick="toggleWebhook(${s.id}, ${s.is_active})" title="${s.is_active ? 'Disabilita' : 'Abilita'}">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/></svg>
</button>
<button class="btn-icon-action btn-danger-hover" onclick="deleteWebhook(${s.id}, '${escapeHtml(s.name)}')" title="Elimina">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9z" clip-rule="evenodd"/></svg>
</button>
</td>
</tr>`;
});
html += '</tbody></table></div>';
container.innerHTML = html;
}
function showCreateWebhookModal() {
const evtCheckboxes = Object.entries(availableEvents).map(([key, label]) =>
`<label style="display:flex; align-items:center; gap:8px; font-size:0.8rem; cursor:pointer; padding:4px 0;">
<input type="checkbox" name="wh-event" value="${key}" style="accent-color:var(--primary);">
<span><code style="font-size:0.7rem;">${key}</code> — ${escapeHtml(label)}</span>
</label>`
).join('');
showModal('Crea Webhook', `
<div class="form-group">
<label class="form-label">Nome *</label>
<input type="text" id="wh-name" class="form-control" placeholder="Es. SIEM Integration, 231 Agile Notify...">
</div>
<div class="form-group">
<label class="form-label">URL Endpoint * (https://...)</label>
<input type="url" id="wh-url" class="form-control" placeholder="https://your-siem.example.com/webhooks/nis2">
</div>
<div class="form-group">
<label class="form-label">Eventi da ascoltare *</label>
<div style="max-height:200px; overflow-y:auto; padding:8px; border:1px solid var(--gray-200); border-radius:var(--border-radius); margin-top:8px;">${evtCheckboxes}</div>
</div>
`, '<button class="btn btn-primary" onclick="createWebhook()">Crea Webhook</button>');
}
async function createWebhook() {
const name = document.getElementById('wh-name').value.trim();
const url = document.getElementById('wh-url').value.trim();
const events = [...document.querySelectorAll('input[name="wh-event"]:checked')].map(cb => cb.value);
if (!name || !url) { showNotification('Nome e URL obbligatori.', 'error'); return; }
if (!events.length) { showNotification('Seleziona almeno un evento.', 'error'); return; }
try {
const result = await api.request('POST', '/webhooks/subscriptions', { name, url, events });
if (result.success) {
closeModal();
showModal('Webhook Creato', `
<div style="padding:16px; background:var(--warning-bg); border:1px solid var(--warning); border-radius:var(--border-radius); margin-bottom:16px;">
<strong style="color:#a16207;">⚠ Salva il secret per verificare la firma HMAC. Non sarà più visibile.</strong>
</div>
<p style="font-size:0.875rem; color:var(--gray-600); margin-bottom:8px;">Usa questo secret per verificare l'header <code>X-NIS2-Signature</code>:</p>
<div style="padding:12px; background:var(--gray-900); border-radius:var(--border-radius); font-family:monospace; font-size:0.875rem; color:#34d399; word-break:break-all; user-select:all;">
${escapeHtml(result.data.secret)}
</div>
<p style="font-size:0.75rem; color:var(--gray-500); margin-top:8px;">Firma: <code>sha256=HMAC_SHA256(body, secret)</code></p>
`, '<button class="btn btn-primary" onclick="closeModal(); loadWebhooks();">Ho salvato il secret</button>');
} else showNotification(result.message || 'Errore.', 'error');
} catch (e) { showNotification('Errore di connessione.', 'error'); }
}
async function testWebhook(id) {
try {
const result = await api.request('POST', `/webhooks/subscriptions/${id}/test`);
if (result.success) { showNotification('Ping di test inviato. Controlla i delivery log.', 'success'); loadDeliveries(); }
else showNotification(result.message || 'Errore.', 'error');
} catch (e) { showNotification('Errore di connessione.', 'error'); }
}
async function toggleWebhook(id, currentActive) {
try {
const result = await api.request('PUT', `/webhooks/subscriptions/${id}`, { is_active: currentActive ? 0 : 1 });
if (result.success) { loadWebhooks(); showNotification(currentActive ? 'Webhook disabilitato.' : 'Webhook abilitato.', 'success'); }
} catch (e) { showNotification('Errore di connessione.', 'error'); }
}
async function deleteWebhook(id, name) {
if (!confirm(`Eliminare il webhook "${name}"?`)) return;
try {
const result = await api.request('DELETE', `/webhooks/subscriptions/${id}`);
if (result.success) { showNotification('Webhook eliminato.', 'success'); loadWebhooks(); }
} catch (e) { showNotification('Errore di connessione.', 'error'); }
}
async function loadDeliveries(subscriptionId) {
const container = document.getElementById('deliveries-container');
container.innerHTML = '<div class="spinner" style="margin:40px auto;"></div>';
try {
const url = subscriptionId ? `/webhooks/deliveries?subscription_id=${subscriptionId}` : '/webhooks/deliveries';
const result = await api.request('GET', url);
if (result.success) {
const deliveries = result.data.deliveries || [];
if (!deliveries.length) {
container.innerHTML = '<div class="empty-state" style="padding:32px;"><h4>Nessun delivery registrato</h4></div>';
return;
}
let html = `<div class="table-container"><table><thead><tr><th>Evento</th><th>Webhook</th><th>Stato</th><th>HTTP</th><th>Tentativo</th><th>Data</th></tr></thead><tbody>`;
deliveries.forEach(d => {
const statusClass = d.status === 'delivered' ? 'success' : d.status === 'retrying' ? 'warning' : 'danger';
html += `<tr>
<td><code style="font-size:0.75rem;">${escapeHtml(d.event_type)}</code></td>
<td style="font-size:0.8rem;">${escapeHtml(d.subscription_name || '-')}</td>
<td><span class="badge badge-${statusClass}">${escapeHtml(d.status)}</span></td>
<td>${d.http_status ? `<code>${d.http_status}</code>` : '-'}</td>
<td>${d.attempt}/3</td>
<td style="font-size:0.8rem;">${formatDateTime(d.created_at)}</td>
</tr>`;
});
html += '</tbody></table></div>';
container.innerHTML = html;
}
} catch (e) { container.innerHTML = '<div class="empty-state"><h4>Errore caricamento delivery</h4></div>'; }
}
// ── Audit Log ────────────────────────────────────────────
async function loadAuditLog() {
const container = document.getElementById('audit-log-container');