nis2-agile/application/controllers/WhistleblowingController.php
DevEnv nis2-agile 9ccf2a72b5 [FIX] Database::execute() → Database::query() in 5 controller
Database non ha metodo execute() — corretto in:
InviteController, ServicesController, WebhookController,
NormativeController, WhistleblowingController.
Causa del HTTP 500 su tutti gli endpoint /api/invites/*.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 16:49:58 +01:00

387 lines
15 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';
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::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,
]);
}
}