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