Files
nis2-agile/application/controllers/WhistleblowingController.php
DevEnv nis2-agileandClaude Opus 4.8 d45bd472cd [FIX] Vocea scaffold: rotta tenantInfo non versionata + portalUrl reale + namespace vault
Correzioni dallo smoke live su api.vocea.cloud (23/6, VIGILE/Vocea):
- tenantInfo(): /tenants/{slug}/info è sotto /api/wb (NON /api/wb/v1) → request() ora
  accetta baseOverride; aggiunto $apiBase (base senza /vN). I 3 /integrations/* restano /v1.
- portalUrl(): path reale /{locale}/segnala/{slug} (era /wb/{slug}); aggiunto
  portalStatusUrl() /{locale}/stato/{slug}; channelStatus espone portal_status_url.
- Vault namespace corretto: tier1__nis2-app__vocea (l'app reale è nis2-app, non nis2-agile).

Scaffold resta DORMIENTE (VOCEA_ENABLED=false). NB: VIGILE segnala che le route
/api/wb/v1/integrations sono ancora 404 (deploy MS incompleto) → non attivare finché
Vocea non completa il deploy e l'API key non è nel vault.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:50:50 +02:00

465 lines
19 KiB
PHP

<?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';
require_once APP_PATH . '/services/VoceaService.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']);
// ── Delega a Vocea quando il canale esterno è attivo per l'org (dormiente di
// default: VOCEA_ENABLED=false → si salta del tutto e resta il modulo legacy).
if (VoceaService::enabled()) {
$org = $this->voceaOrg();
$svc = new VoceaService();
if ($org && $svc->isEnabledForOrg($org)) {
$this->listViaVocea($svc, $org);
return;
}
}
$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::query(
'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::query(
'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::query(
'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,
]);
}
// ══════════════════════════════════════════════════════════════════════
// VOCEA (integrazione whistleblowing zero-knowledge) — scaffold dormiente
// ══════════════════════════════════════════════════════════════════════
/**
* GET /api/whistleblowing/channelStatus
* Indica alla UI se mostrare il canale Vocea (embed wb-link.js) o il modulo legacy.
* Non espone segreti (mai l'API key).
*/
public function channelStatus(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
$enabled = VoceaService::enabled();
$org = $enabled ? $this->voceaOrg() : null; // query solo se attivo (colonne vocea_* presenti)
$slug = $org['vocea_tenant_slug'] ?? null;
$status = $org['vocea_channel_status'] ?? 'none';
$this->jsonSuccess([
'integration_enabled' => $enabled,
'channel_status' => $status,
'provisioned' => $enabled && !empty($slug) && $status === 'active',
'portal_url' => !empty($slug) ? VoceaService::portalUrl($slug) : null,
'portal_status_url' => !empty($slug) ? VoceaService::portalStatusUrl($slug) : null,
]);
}
/** Carica l'org corrente con le colonne vocea_* (chiamata solo quando VOCEA_ENABLED). */
private function voceaOrg(): ?array
{
return Database::fetchOne(
'SELECT * FROM organizations WHERE id = ?',
[$this->getCurrentOrgId()]
) ?: null;
}
/**
* Lista segnalazioni dal canale Vocea (metadati + ciphertext, zero-knowledge).
* Il plaintext NON transita mai: la decifratura avviene client-side dal gestore.
* NB: mapping campi da riconfermare allo smoke a MS live (shape risposta del MS).
*/
private function listViaVocea(VoceaService $svc, array $org): void
{
$query = [];
foreach (['status', 'category', 'page', 'per_page'] as $f) {
if ($this->hasParam($f)) $query[$f] = $this->getParam($f);
}
$res = $svc->listReports($org['vocea_tenant_slug'], $query);
if (!($res['ok'] ?? false)) {
$this->jsonError(
'Canale Vocea non raggiungibile: ' . ($res['error'] ?? 'errore'),
502,
'VOCEA_UPSTREAM'
);
}
$data = $res['data'] ?? [];
$reports = $data['items'] ?? ($data['reports'] ?? (is_array($data) ? $data : []));
$this->jsonSuccess([
'reports' => $reports,
'total' => $data['total'] ?? count($reports),
'source' => 'vocea',
]);
}
}