Files
DevEnv nis2-agileandClaude Opus 4.8 8a0bc5ca39 [FEAT] Gestione Rischi: tabella 12 colonne (inerente+residuo) — integrazione prototipo "Modulo Azioni" (Simon)
Integra la spec di Simon per la pagina Rischi con le correzioni normative
(parere nis2-expert + iso-27001-expert) applicate come eccezioni motivate:
- 12 colonne: Codice (link → Misure e Requisiti), Descrizione (RISCHIO DEFAULT =
  cfg_nis2_rischi.risk_descr), VALUTAZIONE RISCHIO INERENTE (P/I/PxI), Alert,
  Rischio Inerente, VALUTAZIONE RISCHIO RESIDUO (P/I/PxI), Esito residuo, Rischio Residuo.
- Editing INLINE di Probabilità/Impatto (1-5), salvataggio automatico.
- Alert a bande (15-25 Prioritaria / 9-14 Critica / 3-8 Necessaria / 1-2 Suggerita)
  → popup azioni + "crea azione" (tabella requisito_actions, seme Modulo Azioni).
- Eccezione #1: il rischio RESIDUO NON aggiorna in automatico lo STATO DI CONFORMITÀ del
  requisito (piani distinti). Col.11 "Esito residuo" = flag Rivalutare/Adeguato; conformità esplicita.
- Eccezione #3: banda 13-14 inclusa in "Critica" (9-14). "Danno €" → "Impatto" (1-5).
  Soglie = metodo interno (ISO 27001 §6.1.2), non obbligo (help/UI lo dichiarano).

Backend: mig 067; derivedList (+risk_descr +residuo), setState (+residuo), requisitoActions list/create.
help.js + i18n IT/EN. Cache-buster api/i18n/help 20260730a. Prossima mig=068.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 10:49:52 +02:00

895 lines
43 KiB
PHP

<?php
/**
* NIS2 Agile - Front Controller / Router
*
* Tutte le richieste API passano da qui.
* URL Pattern: /api/{controller}/{action}/{id?}
*/
// ═══════════════════════════════════════════════════════════════════════════
// BOOTSTRAP
// ═══════════════════════════════════════════════════════════════════════════
require_once __DIR__ . '/../application/config/config.php';
require_once __DIR__ . '/../application/config/database.php';
// ═══════════════════════════════════════════════════════════════════════════
// CORS HEADERS
// ═══════════════════════════════════════════════════════════════════════════
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, CORS_ALLOWED_ORIGINS)) {
header("Access-Control-Allow-Origin: {$origin}");
}
// NOTE: No wildcard CORS even in debug mode — use CORS_ALLOWED_ORIGINS in config
header("Access-Control-Allow-Methods: " . CORS_ALLOWED_METHODS);
header("Access-Control-Allow-Headers: " . CORS_ALLOWED_HEADERS);
header("Access-Control-Max-Age: " . CORS_MAX_AGE);
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
// ═══════════════════════════════════════════════════════════════════════════
// ROUTING
// ═══════════════════════════════════════════════════════════════════════════
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
$basePath = '';
$path = parse_url($requestUri, PHP_URL_PATH);
$path = preg_replace("#^{$basePath}#", '', $path);
$path = trim($path, '/');
// Se non è una richiesta API, servi file statici o index.html
if (!preg_match('#^api/#', $path)) {
$staticFile = __DIR__ . '/' . $path;
if ($path && file_exists($staticFile) && is_file($staticFile)) {
return false;
}
if (empty($path) || $path === 'index.html') {
include __DIR__ . '/index.html';
exit;
}
// Prova a servire come HTML page
$htmlFile = __DIR__ . '/' . $path;
if (!str_ends_with($path, '.html')) {
$htmlFile = __DIR__ . '/' . $path . '.html';
}
if (file_exists($htmlFile) && is_file($htmlFile)) {
include $htmlFile;
exit;
}
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Not Found']);
exit;
}
// ═══════════════════════════════════════════════════════════════════════════
// API ROUTING
// ═══════════════════════════════════════════════════════════════════════════
$parts = explode('/', $path);
array_shift($parts); // Rimuovi "api"
$controllerName = $parts[0] ?? 'index';
$actionName = $parts[1] ?? 'index';
$resourceId = isset($parts[2]) ? $parts[2] : null;
$subAction = $parts[3] ?? null;
$subResourceId = isset($parts[4]) ? $parts[4] : null;
// Mappa controller
$controllerMap = [
'auth' => 'AuthController',
'organizations' => 'OrganizationController',
'org-roles' => 'OrgRoleController', // A4 Fase 4.1 — Organigramma (ruoli/gerarchia, Art.23 governance)
'competences' => 'CompetenceController', // A4 Fase 4.2 — Competenze (skill, requisiti ruolo, gap, alert->azione)
'raci' => 'RaciController', // A4 Fase 4.3 — Matrice RACI + link m2m (procedure/inventario/rischi/misure)
'review-schedule' => 'ReviewScheduleController', // A4 Fase 4.4 — Scadenziario centralizzato (revisioni periodiche GV.PO-02/GV.SC-07/PR.AT/DE.CM)
'framework' => 'FrameworkController', // Epic C / C1 — Elenco canonico Misure/Requisiti (cfg_nis2_*, read-only)
'consultant' => 'ConsultantController', // Cruscotto Studio — vista d'insieme aziende gestite + scadenze aggregate
'stakeholders' => 'StakeholderController', // Epic C / C5 — Registro stakeholder + matrice di Mendelow
'stakeholder-activities' => 'StakeholderActivityController', // Epic C / C5.2 — Attività stakeholder (questionari/azioni + calendario)
'stakeholder-portal' => 'StakeholderPortalController', // Epic C / C5.2b — Portale esterno stakeholder (token magic-link, NO JWT)
'internal-audits' => 'InternalAuditController', // Modulo A — Audit interni ISO 27001 §9.2
'management-reviews' => 'ManagementReviewController', // Modulo B — Riesame di Direzione ISO 27001 §9.3
'calendar' => 'CalendarController', // Modulo C — Calendario unico scadenze (aggregatore sola lettura)
'periodic-controls' => 'PeriodicControlController', // Controlli periodici (control monitoring §9.1/A.8.16: frequenza + esecuzioni/evidenza + NC/AC)
'assessments' => 'AssessmentController',
'isms' => 'IsmsModelController', // Modello Organizzativo SGSI (ISO 27001/27017/27018 + SoA)
'dashboard' => 'DashboardController',
'risks' => 'RiskController',
'incidents' => 'IncidentController',
'policies' => 'PolicyController',
'supply-chain' => 'SupplyChainController',
'training' => 'TrainingController',
'assets' => 'AssetController',
'audit' => 'AuditController',
'admin' => 'AdminController',
'onboarding' => 'OnboardingController',
'ncr' => 'NonConformityController',
'services' => 'ServicesController',
'invites' => 'InviteController',
'webhooks' => 'WebhookController',
'discovery-connectors' => 'DiscoveryConnectorController', // Connettori discovery rete/cloud → auto-popola Inventario
'institutional-docs' => 'InstitutionalDocsController', // Documenti istituzionali per-org (dashboard) — ticket #499
'whistleblowing'=> 'WhistleblowingController',
'normative' => 'NormativeController',
'cross-analysis' => 'CrossAnalysisController',
'contact' => 'ContactController', // legacy
'mktg-lead' => 'MktgLeadController', // standard condiviso TRPG/NIS2
'feedback' => 'FeedbackController', // segnalazioni & risoluzione AI
'knowledgebase' => 'KnowledgeBaseController', // KB multi-livello (Migration 012-014)
'ai' => 'AiController', // Assistente AI ARIA (askWithRag + fonti certe)
'branding' => 'BrandingController', // White-label firm (Fase 5 / G16)
'supplier-portal' => 'SupplierPortalController', // Portale fornitore Fase 3 (auth OTP/magic-link separata)
'demo' => 'DemoController', // Avatar di prodotto — demo guidata (product-demo-protocol v1.0.1)
];
if (!isset($controllerMap[$controllerName])) {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'Endpoint non trovato',
'error_code' => 'NOT_FOUND',
]);
exit;
}
$controllerClass = $controllerMap[$controllerName];
$controllerFile = APP_PATH . "/controllers/{$controllerClass}.php";
if (!file_exists($controllerFile)) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'Controller non disponibile',
]);
exit;
}
require_once $controllerFile;
// ═══════════════════════════════════════════════════════════════════════════
// MAPPA AZIONI PER METODO HTTP
// ═══════════════════════════════════════════════════════════════════════════
$method = $_SERVER['REQUEST_METHOD'];
// Converti action name con trattini in camelCase
$actionName = str_replace('-', '', lcfirst(ucwords($actionName, '-')));
$actionMap = [
// ── AuthController ──────────────────────────────
'auth' => [
'POST:register' => 'register',
'POST:login' => 'login',
'POST:logout' => 'logout',
'POST:refresh' => 'refresh',
'GET:me' => 'me',
'PUT:profile' => 'updateProfile',
'POST:changePassword' => 'changePassword',
'POST:validateInvite' => 'validateInvite', // valida invite_token (no auth)
// Multi-device sessions (Fase 2 / G06)
'GET:sessions' => 'listSessions',
'DELETE:sessions/{id}' => 'revokeSession',
'DELETE:sessions' => 'revokeAllSessions',
// Password reset (Fase 3 / G08, no auth)
'POST:forgotPassword' => 'forgotPassword',
'POST:resetPassword' => 'resetPassword',
// Context switch (Fase 3 / G09)
'POST:switchContext' => 'switchContext',
// Impersonate (Fase 4 / G11)
'POST:impersonate' => 'impersonate',
// Preferences (Fase 4 / G12)
'GET:preferences' => 'getPreferences',
'PUT:preferences' => 'updatePreferences',
],
// ── OrganizationController ──────────────────────
'organizations' => [
'POST:create' => 'create',
'GET:current' => 'getCurrent',
'GET:list' => 'list',
'PUT:{id}' => 'update',
'GET:{id}/members' => 'listMembers',
'POST:{id}/invite' => 'inviteMember',
'DELETE:{id}/members/{subId}' => 'removeMember',
'POST:classify' => 'classifyEntity',
// Connettori per-azienda (Evidence Automation) — config non segreta. Type nel body (router cattura subId solo se numerico).
'GET:{id}/connectors' => 'listConnectors',
'PUT:{id}/connectors' => 'saveConnector',
'DELETE:{id}/connectors' => 'deleteConnector',
],
// ── OrgRoleController — Organigramma (A4 Fase 4.1) ──
'org-roles' => [
'GET:list' => 'list',
'GET:assignableUsers' => 'assignableUsers',
'POST:create' => 'create',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete',
],
// ── CompetenceController — Competenze (A4 Fase 4.2) ──
'competences' => [
'GET:catalog' => 'catalog',
'POST:skills' => 'createSkill',
'PUT:skills/{subId}' => 'updateSkill',
'DELETE:skills/{subId}' => 'deleteSkill',
'GET:roleSkills/{subId}' => 'roleSkills', // {subId} = role_id
'POST:roleSkills' => 'setRoleSkill',
'DELETE:roleSkills/{subId}' => 'removeRoleSkill',
'GET:userSkills/{subId}' => 'userSkills', // {subId} = user_id
'POST:userSkills' => 'setUserSkill',
'DELETE:userSkills/{subId}' => 'removeUserSkill',
'POST:skillCourses' => 'mapCourse',
'DELETE:skillCourses/{subId}' => 'unmapCourse',
'GET:gapGrid' => 'gapGrid',
'POST:openAction' => 'openAction',
],
// ── RaciController — Matrice RACI + link m2m (A4 Fase 4.3) ──
'raci' => [
'GET:matrix' => 'matrix',
'POST:assign' => 'assign',
'DELETE:assign' => 'unassign',
'GET:objects' => 'objects',
'GET:links' => 'links',
'POST:link' => 'link',
'DELETE:link' => 'unlink',
],
// ── ReviewScheduleController — Scadenziario centralizzato (A4 Fase 4.4) ──
// Revisioni periodiche (GV.PO-02, GV.SC-07, PR.AT, DE.CM). Status calcolato live.
// sync = upsert idempotente da policies/compliance_controls/risk_treatments (org-scoped).
// I DELETE usano {id} numerico in URL → nessun body necessario.
'review-schedule' => [
'GET:list' => 'list',
'POST:create' => 'create',
'POST:sync' => 'sync',
'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete',
'POST:{id}/complete' => 'complete',
],
// ── FrameworkController — Elenco canonico Misure/Requisiti (Epic C / C1) ──
// Sola lettura: catalogo cfg_nis2_* (misure 43 / requisiti 116 + procedura e
// rischio di default per requisito). Nessuna scrittura (codifica non variabile).
'framework' => [
'GET:catalog' => 'catalog',
'POST:state' => 'setState', // upsert valutazione/stato per requisito (org-scoped)
],
// ── ConsultantController — Cruscotto Studio (vista d'insieme aziende gestite) ──
'consultant' => [
'GET:portfolio' => 'portfolio',
],
// ── AssessmentController ────────────────────────
'assessments' => [
'GET:list' => 'list',
'POST:create' => 'create',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'GET:{id}/questions' => 'getQuestions',
'POST:{id}/respond' => 'saveResponse',
'POST:{id}/complete' => 'complete',
'GET:{id}/report' => 'getReport',
'POST:{id}/aiAnalyze' => 'aiAnalyze',
],
// ── IsmsModelController — Modello Organizzativo SGSI (ISO 27001/27017/27018) ──
'isms' => [
'GET:model' => 'getModel',
'POST:model' => 'saveModel',
'PUT:model' => 'saveModel',
'GET:annexControls' => 'annexControls',
'GET:soa' => 'getSoa',
'POST:soa/derive' => 'deriveSoa',
'PUT:soa' => 'updateSoaControl',
'GET:roles' => 'listRoles',
'POST:roles' => 'saveRole',
'DELETE:roles/{subId}' => 'deleteRole',
'GET:documents' => 'listDocuments',
'POST:documents' => 'createDocument',
'POST:documents/aiGenerate' => 'aiGenerateDocument',
'PUT:documents/{subId}' => 'updateDocument',
'POST:documents/{subId}/submit' => 'submitDocument', // ciclo di vita: bozza->revisione
'POST:documents/{subId}/approve' => 'approveDocument', // revisione->approvato
'POST:documents/{subId}/publish' => 'publishDocument', // approvato->pubblicato
'POST:documents/{subId}/reject' => 'rejectDocument', // revisione->bozza (con nota)
'POST:documents/{subId}/archive' => 'archiveDocument', // pubblicato->archiviato
'POST:documents/{subId}/newVersion' => 'newVersionDocument', // pubblicato->nuova bozza
'GET:documents/{subId}/versions' => 'documentVersions',
'GET:documents/{subId}/word' => 'exportDocumentWord', // download .doc Word
'GET:documentsWordAll' => 'exportAllDocumentsWord', // download UNICO di tutto il set
'GET:docPermissions' => 'getDocPermissions', // matrice permessi documentali (Ruolo x Azione)
'PUT:docPermissions' => 'saveDocPermissions',
'GET:documents/{subId}' => 'getDocument', // documento completo (editor) — {subId} numerico
'GET:readiness' => 'readiness',
'GET:export' => 'export',
],
// ── DashboardController ─────────────────────────
'dashboard' => [
'GET:overview' => 'overview',
'GET:complianceScore' => 'complianceScore',
'GET:upcomingDeadlines' => 'deadlines',
'GET:recentActivity' => 'recentActivity',
'GET:riskHeatmap' => 'riskHeatmap',
'GET:sectorBenchmark' => 'sectorBenchmark', // P2: benchmark settoriale anonimizzato
],
// ── RiskController ──────────────────────────────
'risks' => [
'GET:list' => 'list',
'GET:derived' => 'derivedList', // #500 fase 2: rischi derivati dai requisiti LEX&ISO
'POST:create' => 'create',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete',
'POST:{id}/treatments' => 'addTreatment',
'PUT:treatments/{subId}' => 'updateTreatment',
'GET:matrix' => 'getRiskMatrix',
'POST:aiSuggest' => 'aiSuggestRisks',
'GET:fairRegister' => 'fairRegister', // P2: registro quantitativo FAIR
'POST:{id}/fair' => 'computeFair', // P2: calcolo ALE Monte Carlo
'GET:kri' => 'listKri', // P2: dashboard KRI
'POST:kri' => 'createKri',
'PUT:kri/{subId}' => 'updateKri',
'GET:requisitoActions' => 'requisitoActionsList', // #500/Modulo Azioni: azioni sul rischio-da-requisito
'POST:requisitoActions' => 'requisitoActionsCreate',
],
// ── IncidentController ──────────────────────────
'incidents' => [
'GET:list' => 'list',
'POST:create' => 'create',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'POST:{id}/timeline' => 'addTimelineEvent',
'POST:{id}/earlyWarning' => 'sendEarlyWarning',
'POST:{id}/notification' => 'sendNotification',
'POST:{id}/finalReport' => 'sendFinalReport',
'POST:{id}/aiClassify' => 'aiClassify',
'GET:{id}/metrics' => 'metrics',
'GET:{id}/pir' => 'getPir',
'POST:{id}/pir' => 'savePir',
],
// ── PolicyController ────────────────────────────
'policies' => [
'GET:list' => 'list',
'POST:create' => 'create',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete',
'POST:{id}/approve' => 'approve',
'POST:aiGenerate' => 'aiGeneratePolicy',
'GET:templates' => 'getTemplates',
// Attestation + versioning (P3)
'GET:pendingAttestations' => 'pendingAttestations',
'GET:pending-attestations' => 'pendingAttestations',
'POST:{id}/attest' => 'attest',
'GET:{id}/attestations' => 'attestations',
'GET:{id}/versions' => 'versions',
'GET:{id}/diff' => 'diff',
],
// ── SupplyChainController ───────────────────────
'supply-chain' => [
'GET:list' => 'list',
'POST:create' => 'create',
'GET:riskOverview' => 'riskOverview',
// Self-assessment fornitori (P3)
'GET:publicQuestionnaire' => 'publicQuestionnaire', // NO AUTH (token)
'GET:public-questionnaire' => 'publicQuestionnaire', // NO AUTH (token)
'POST:submitPublicQuestionnaire' => 'submitPublicQuestionnaire', // NO AUTH (token)
'POST:submit-public-questionnaire' => 'submitPublicQuestionnaire', // NO AUTH (token)
'POST:{id}/sendQuestionnaire' => 'sendQuestionnaire',
'POST:{id}/send-questionnaire' => 'sendQuestionnaire',
'GET:{id}/questionnaireStatus' => 'questionnaireStatus',
'GET:{id}/questionnaire-status' => 'questionnaireStatus',
'POST:{id}/assess' => 'assessSupplier',
// Modulo questionari configurabile (Fase 1): categorie, template, domande, import
'GET:categories' => 'categories',
'POST:categories' => 'createCategory',
'PUT:categories/{subId}' => 'updateCategory',
'DELETE:categories/{subId}' => 'deleteCategory',
'GET:templates' => 'templates',
'POST:templates' => 'createTemplate',
'GET:templates/{subId}' => 'getTemplate',
'PUT:templates/{subId}' => 'updateTemplate',
'POST:{id}/questions' => 'addTemplateQuestion', // {id} = template id
'PUT:questions/{subId}' => 'updateTemplateQuestion',
'DELETE:questions/{subId}' => 'deleteTemplateQuestion',
'POST:import' => 'importSuppliers',
// Campagne questionario (Fase 2): cruscotto + invio template a fornitore
'GET:campaigns' => 'campaigns',
'POST:{id}/campaigns' => 'createCampaign', // {id} = supplier id
// Stakeholder estesi (A4 Fase 4.5): vista di sintesi fornitori+clienti+partner (GV.SC-02). Riusa suppliers.
'GET:stakeholderMap' => 'stakeholderMap', // URL: /api/supply-chain/stakeholder-map
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete',
],
// ── SupplierPortalController — portale fornitore (auth OTP/magic-link, SEPARATA da users) ──
'supplier-portal' => [
'POST:requestOtp' => 'requestOtp', // NO AUTH (opaco)
'POST:request-otp' => 'requestOtp',
'POST:verifyOtp' => 'verifyOtp', // NO AUTH (apre sessione)
'POST:verify-otp' => 'verifyOtp',
'GET:me' => 'me', // sessione fornitore
'GET:{id}' => 'getQuestionnaire',
'POST:{id}/submit' => 'submitQuestionnaire',
'PATCH:{id}/answers' => 'saveAnswers', // autosave
],
// ── TrainingController ──────────────────────────
'training' => [
'GET:courses' => 'listCourses',
'POST:courses' => 'createCourse',
'GET:assignments' => 'myAssignments',
'POST:assign' => 'assignCourse',
'PUT:assignments/{subId}' => 'updateAssignment',
'GET:complianceStatus' => 'complianceStatus',
],
// ── AssetController ─────────────────────────────
'assets' => [
'GET:list' => 'list',
'POST:create' => 'create',
'POST:import' => 'import',
'GET:scoringGrid' => 'scoringGrid',
'GET:relevantSystems' => 'relevantSystems',
'GET:dependencyMap' => 'dependencyMap',
'GET:subclasses' => 'subclasses', // Epic C / C4 — voci + sottoclassi
'POST:subclasses' => 'addSubclass', // Epic C / C4 — aggiunta sottoclasse org
'PUT:subclasses/{subId}' => 'updateSubclass', // C4 — rinomina sottoclasse org (#426)
'DELETE:subclasses/{subId}' => 'deleteSubclass', // C4 — rimuove sottoclasse org (#426)
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete',
'POST:{id}/score' => 'score',
],
// ── StakeholderController (Epic C / C5 — registro + Mendelow) ─────────────
'stakeholders' => [
'GET:list' => 'list',
'GET:types' => 'types',
'POST:types' => 'addType',
'GET:quadrants' => 'quadrants',
'GET:pickers' => 'pickers',
'POST:create' => 'create',
'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete',
],
// ── StakeholderActivityController (Epic C / C5.2 — attività + questionari tipo) ──
'stakeholder-activities' => [
'GET:templates' => 'templates',
'POST:templates' => 'createTemplate',
'GET:templates/{subId}' => 'getTemplate',
'PUT:templates/{subId}' => 'updateTemplate',
'DELETE:templates/{subId}'=> 'deleteTemplate',
'GET:pickers' => 'pickers',
'GET:list' => 'list',
'POST:create' => 'create',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete',
'POST:{id}/assign' => 'assign',
'POST:{id}/send' => 'send',
'GET:{id}/feedback' => 'feedback',
'GET:{id}/comments' => 'comments',
'POST:{id}/comments' => 'addComment',
'GET:{id}/attachments' => 'attachments',
'POST:{id}/attachments' => 'uploadAttachment',
],
// ── StakeholderPortalController (Epic C / C5.2b — portale esterno, token magic-link, NO JWT) ──
'stakeholder-portal' => [
'GET:access' => 'access',
'POST:respond' => 'respond',
'POST:acknowledge'=> 'acknowledge',
'POST:comment' => 'comment',
'POST:attachment' => 'attachment',
],
// ── InternalAuditController (Modulo A — Audit interni ISO 27001 §9.2) ──
'internal-audits' => [
'GET:list' => 'list',
'POST:create' => 'create',
'POST:items' => 'addItem',
'PUT:items/{subId}' => 'updateItem',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete',
'POST:{id}/raiseNcr' => 'raiseNcr',
'GET:{id}/report' => 'report',
],
// ── ManagementReviewController — Riesame di Direzione (ISO 27001 §9.3, Modulo B) ──
'management-reviews' => [
'GET:list' => 'list',
'GET:gather' => 'gather',
'POST:create' => 'create',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'POST:{id}/decisions' => 'addDecision',
'PUT:decisions/{subId}' => 'updateDecision',
'POST:{id}/approve' => 'approve',
'GET:{id}/report' => 'report',
],
// ── CalendarController (Modulo C — calendario unico scadenze, sola lettura) ──
'calendar' => [
'GET:events' => 'events',
'GET:summary' => 'summary',
],
// ── PeriodicControlController (Controlli periodici — control monitoring §9.1/A.8.16) ──
'periodic-controls' => [
'GET:list' => 'list',
'POST:create' => 'create',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete',
'POST:{id}/executions'=> 'addExecution',
'GET:{id}/report' => 'report',
],
// ── AuditController ─────────────────────────────
'audit' => [
'GET:controls' => 'listControls',
'PUT:controls/{subId}' => 'updateControl',
'POST:evidence/upload' => 'uploadEvidence',
'GET:evidence/list' => 'listEvidence',
'GET:report' => 'generateReport',
'GET:logs' => 'getAuditLogs',
'GET:iso27001Mapping' => 'getIsoMapping',
'GET:nistCsfMapping' => 'getNistCsfMapping',
'GET:controlsMonitoring' => 'controlsMonitoring',
'GET:acnRequirements' => 'acnRequirements',
'PUT:acnRequirements/{subId}' => 'updateAcnRequirement',
'GET:executiveReport' => 'executiveReport',
'GET:relevantSystemsRegister' => 'relevantSystemsRegister',
'GET:export' => 'export',
'GET:chainVerify' => 'chainVerify',
'GET:exportCertified' => 'exportCertified',
],
// ── AdminController ─────────────────────────────
'admin' => [
'GET:organizations' => 'listOrganizations',
'GET:users' => 'listUsers',
'GET:stats' => 'platformStats',
],
// ── OnboardingController ──────────────────────
'onboarding' => [
'POST:uploadVisura' => 'uploadVisura',
'POST:fetchCompany' => 'fetchCompany',
'POST:lookupPiva' => 'lookupPiva', // lookup P.IVA pubblico (no auth, da register)
'POST:complete' => 'complete',
],
// ── NonConformityController (NCR & CAPA) ───────
'ncr' => [
'POST:create' => 'create',
'GET:list' => 'list',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'POST:{id}/capa' => 'addCapa',
'PUT:capa/{subId}' => 'updateCapa',
'POST:fromAssessment' => 'fromAssessment',
'GET:stats' => 'stats',
'POST:{id}/sync' => 'syncExternal',
'POST:webhook' => 'webhook',
],
// ── InviteController (licenze/inviti B2B) ──────
'invites' => [
'POST:create' => 'create',
'GET:index' => 'index',
'GET:list' => 'index',
'GET:{id}' => 'show',
'DELETE:{id}' => 'revoke',
'POST:{id}/regenerate' => 'regenerate',
'GET:validate' => 'validate', // ?token=inv_...
],
// ── ServicesController (API pubblica) ──────────
'services' => [
'POST:provision' => 'provision',
'POST:token' => 'token',
'POST:sso' => 'sso',
'GET:status' => 'status',
'GET:complianceSummary' => 'complianceSummary',
'GET:risksFeed' => 'risksFeed',
'GET:incidentsFeed' => 'incidentsFeed',
'POST:incidentsIngest' => 'ingestIncident', // P1: ingestion alert SIEM/SOC/EDR
'POST:evidenceIngest' => 'ingestEvidence', // P1: evidence automation (collector connettori)
'GET:controlsMonitoring' => 'controlsMonitoring', // P1: continuous control monitoring
'POST:assetsIngest' => 'ingestAssets', // P2: import asset CMDB/cloud + scoring GV.OC-04
'GET:controlsStatus' => 'controlsStatus',
'GET:assetsCritical' => 'assetsCritical',
'GET:suppliersRisk' => 'suppliersRisk',
'GET:policiesApproved' => 'policiesApproved',
'GET:openapi' => 'openapi',
// lg231 / GRC integration endpoints
'GET:gapAnalysis' => 'gapAnalysis',
'GET:measures' => 'measures',
'GET:incidents' => 'incidents',
'GET:training' => 'training',
'GET:deadlines' => 'deadlines',
'GET:fullSnapshot' => 'fullSnapshot',
],
// ── WebhookController (CRUD keys + subscriptions) ──
'webhooks' => [
'GET:apiKeys' => 'listApiKeys',
'POST:apiKeys' => 'createApiKey',
'DELETE:apiKeys/{subId}' => 'deleteApiKey',
'GET:subscriptions' => 'listSubscriptions',
'POST:subscriptions' => 'createSubscription',
'PUT:subscriptions/{subId}' => 'updateSubscription',
'DELETE:subscriptions/{subId}' => 'deleteSubscription',
'POST:subscriptions/{subId}/test' => 'testSubscription',
'GET:deliveries' => 'listDeliveries',
'POST:retry' => 'processRetry',
],
// ── WhistleblowingController (Art.32 NIS2) ─────
'whistleblowing' => [
'POST:submit' => 'submit',
'GET:list' => 'list',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'POST:{id}/assign' => 'assign',
'POST:{id}/close' => 'close',
'GET:stats' => 'stats',
'GET:channelStatus' => 'channelStatus',
'GET:trackAnonymous' => 'trackAnonymous',
],
// ── NormativeController (Feed NIS2/ACN) ─────────
'normative' => [
'GET:list' => 'list',
'GET:{id}' => 'get',
'POST:{id}/ack' => 'acknowledge',
'GET:pending' => 'pending',
'GET:stats' => 'stats',
'POST:create' => 'create',
],
// ── CrossAnalysisController (L4 AI cross-org) ──
'cross-analysis' => [
'POST:analyze' => 'analyze',
'GET:history' => 'history',
'GET:portfolio' => 'portfolio',
],
// ── ContactController (lead / richiesta invito) — legacy ──
'contact' => [
'POST:requestInvite' => 'requestInvite',
],
// ── MktgLeadController — standard condiviso TRPG/NIS2 ──
'mktg-lead' => [
'POST:submit' => 'submit',
],
// ── FeedbackController — segnalazioni & risoluzione AI ──
'feedback' => [
'POST:submit' => 'submit',
'GET:mine' => 'mine',
'GET:list' => 'list',
'GET:{id}' => 'show',
'PUT:{id}' => 'update',
'POST:{id}/resolve' => 'resolve',
],
// ── KnowledgeBaseController — KB multi-livello (Migration 012-014) ──
'knowledgebase' => [
'POST:ingest' => 'ingest',
'GET:list' => 'list',
'GET:firmOrgs' => 'firmOrgs',
'POST:search' => 'search',
'DELETE:{id}' => 'delete',
],
'ai' => [
'POST:ask' => 'ask',
'POST:tts' => 'tts', // voce naturale ARIA (proxy nexus-voice-ms)
],
// ── DiscoveryConnectorController — connettori mappatura rete/cloud (org_admin) ──
'discovery-connectors' => [
'GET:list' => 'list',
'POST:create' => 'create',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete',
'POST:{id}/rotateKey' => 'rotateKey',
],
// ── InstitutionalDocsController — Documenti istituzionali dashboard (ticket #499) ──
'institutional-docs' => [
'GET:list' => 'list',
'POST:save' => 'save',
'DELETE:{id}' => 'delete',
],
// ── BrandingController — White-label firm (Fase 5 / G16) ──
'branding' => [
'GET:current' => 'getCurrent',
'PUT:index' => 'update',
],
// ── DemoController — Avatar di prodotto (product-demo-protocol v1.0.1) ──
// NB: il router camelCasa il segmento URL (session-start → sessionStart), quindi le chiavi
// dei multi-parola vanno in camelCase. URL esterni: /api/demo/session-start, /reset-dataset.
'demo' => [
'POST:sessionStart' => 'sessionStart',
'GET:manifest' => 'manifest',
'POST:event' => 'event',
'GET:credentials' => 'credentials',
'POST:resetDataset' => 'resetDataset',
],
];
// ═══════════════════════════════════════════════════════════════════════════
// RISOLUZIONE AZIONE
// ═══════════════════════════════════════════════════════════════════════════
$actions = $actionMap[$controllerName] ?? [];
$resolvedAction = null;
$callArgs = [];
// Helper: convert kebab-case to camelCase (same logic as $actionName conversion)
$toCamel = function (string $s): string {
return str_replace('-', '', lcfirst(ucwords($s, '-')));
};
// Costruisci candidati pattern → argomenti (ordine: più specifico prima)
$candidates = [];
if (is_numeric($actionName)) {
// Il primo segmento è un ID numerico:
// /controller/123 → METHOD:{id}
// /controller/123/sub → METHOD:{id}/sub
// /controller/123/sub/456 → METHOD:{id}/sub/{subId}
$numericId = (int) $actionName;
$sub = $resourceId !== null ? $toCamel($resourceId) : null;
$subId = $subAction !== null && is_numeric($subAction) ? (int) $subAction : null;
if ($sub !== null && $subId !== null) {
$candidates[] = ['p' => "{$method}:{id}/{$sub}/{subId}", 'a' => [$numericId, $subId]];
}
if ($sub !== null) {
$candidates[] = ['p' => "{$method}:{id}/{$sub}", 'a' => [$numericId]];
}
$candidates[] = ['p' => "{$method}:{id}", 'a' => [$numericId]];
} else {
// Il primo segmento è un'azione nominale:
// /controller/action → METHOD:action
// /controller/action/123 → METHOD:action/{subId} oppure METHOD:{id}
// /controller/action/sub → METHOD:action/sub
// /controller/action/123/sub → METHOD:{id}/sub
// /controller/action/123/sub/456 → METHOD:{id}/sub/{subId}
if ($subAction !== null && $resourceId !== null && is_numeric($resourceId)) {
$rid = (int) $resourceId;
$camelSub = $toCamel($subAction);
if ($subResourceId !== null && is_numeric($subResourceId)) {
$sid = (int) $subResourceId;
$candidates[] = ['p' => "{$method}:{id}/{$camelSub}/{subId}", 'a' => [$rid, $sid]];
}
// e.g. POST:subscriptions/{subId}/test
$candidates[] = ['p' => "{$method}:{$actionName}/{subId}/{$camelSub}", 'a' => [$rid]];
$candidates[] = ['p' => "{$method}:{id}/{$camelSub}", 'a' => [$rid]];
$candidates[] = ['p' => "{$method}:{$actionName}/{subId}", 'a' => [$rid]];
}
if ($resourceId !== null && $subAction === null) {
if (is_numeric($resourceId)) {
// /controller/action/123 → potrebbe essere action/{subId} o {id}
$rid = (int) $resourceId;
$candidates[] = ['p' => "{$method}:{$actionName}/{subId}", 'a' => [$rid]];
$candidates[] = ['p' => "{$method}:{id}", 'a' => [$rid]];
} else {
// /controller/action/subAction → nome composto (es: evidence/upload)
$camelResource = $toCamel($resourceId);
$candidates[] = ['p' => "{$method}:{$actionName}/{$camelResource}", 'a' => []];
// Fallback: id NON numerico (es. jti esadecimale di una sessione,
// token) passato come STRINGA → match su {method}:{action}/{id}.
// Necessario per DELETE /auth/sessions/<jti-hex> (revoca sessione singola).
$candidates[] = ['p' => "{$method}:{$actionName}/{id}", 'a' => [$resourceId]];
}
}
// /controller/action
$candidates[] = ['p' => "{$method}:{$actionName}", 'a' => []];
}
// Cerca primo match
foreach ($candidates as $candidate) {
if (isset($actions[$candidate['p']])) {
$resolvedAction = $actions[$candidate['p']];
$callArgs = $candidate['a'];
break;
}
}
if (!$resolvedAction) {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => "Azione non trovata: {$method} /{$controllerName}/{$actionName}",
'error_code' => 'ACTION_NOT_FOUND',
]);
exit;
}
// ═══════════════════════════════════════════════════════════════════════════
// ESECUZIONE
// ═══════════════════════════════════════════════════════════════════════════
try {
$controller = new $controllerClass();
if (!method_exists($controller, $resolvedAction)) {
http_response_code(501);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => "Metodo '{$resolvedAction}' non implementato",
]);
exit;
}
// Chiama con gli argomenti risolti
$controller->$resolvedAction(...$callArgs);
} catch (RuntimeException $e) {
// Rate limit exceeded (429)
if ($e->getCode() === 429) {
http_response_code(429);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => $e->getMessage(),
'error_code' => 'RATE_LIMITED',
]);
exit;
}
throw $e;
} catch (PDOException $e) {
error_log('[DB_ERROR] ' . $e->getMessage());
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => APP_DEBUG ? 'Errore database: ' . $e->getMessage() : 'Errore interno del server',
]);
} catch (Throwable $e) {
error_log('[ERROR] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => APP_DEBUG ? $e->getMessage() : 'Errore interno del server',
]);
}