[FEAT] Services API: full-snapshot endpoint + BigSim SSE wrapper
- ServicesController: nuovo endpoint GET /api/services/full-snapshot Aggrega gap-analysis, measures, incidents, training, deadlines, compliance-summary in una sola chiamata (reduce 6 round-trip → 1) Parametro ?days=N per finestra deadlines (default 30, max 365) - public/index.php: route GET:fullSnapshot aggiunta all'action map services - public/simulate-nis2-big.php: wrapper SSE per simulate-nis2-big.php Esegue il simulatore come sottoprocesso CLI con NIS2_SSE=1 e streama l'output al browser tramite Server-Sent Events Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a122b49721
commit
56df54f8b1
@ -1452,6 +1452,137 @@ class ServicesController extends BaseController
|
||||
|
||||
// ── Utility ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/services/full-snapshot
|
||||
* Aggregato di tutti i flussi lg231 in una sola chiamata.
|
||||
* Riduce da 6 round-trip a 1. Risposta: { gap_analysis, measures, incidents, training, deadlines, compliance_summary }
|
||||
* ?days=30 finestra deadlines (default 30)
|
||||
*/
|
||||
public function fullSnapshot(): void
|
||||
{
|
||||
$this->requireApiKey('read:all');
|
||||
$orgId = $this->currentOrgId;
|
||||
$days = min((int) ($this->getParam('days') ?: 30), 365);
|
||||
|
||||
// Riusa la logica degli endpoint esistenti catturando i dati direttamente
|
||||
// senza fare HTTP round-trip interni
|
||||
|
||||
// ── compliance summary (già esistente) ────────────────────────────
|
||||
$org = Database::fetchOne('SELECT name, sector, entity_type, employee_count FROM organizations WHERE id = ?', [$orgId]);
|
||||
$ctrlStats = Database::fetchOne(
|
||||
'SELECT COUNT(*) as total,
|
||||
SUM(CASE WHEN status IN ("implemented","verified") THEN 1 ELSE 0 END) as done,
|
||||
SUM(CASE WHEN status = "in_progress" THEN 1 ELSE 0 END) as wip
|
||||
FROM compliance_controls WHERE organization_id = ?', [$orgId]
|
||||
);
|
||||
$complianceScore = ($ctrlStats['total'] ?? 0) > 0
|
||||
? (int) round(($ctrlStats['done'] + $ctrlStats['wip'] * 0.5) / $ctrlStats['total'] * 100)
|
||||
: 0;
|
||||
|
||||
// ── gap analysis ─────────────────────────────────────────────────
|
||||
$assessment = Database::fetchOne(
|
||||
'SELECT id, overall_score, ai_summary, ai_recommendations, completed_at
|
||||
FROM assessments WHERE organization_id = ? AND status = "completed"
|
||||
ORDER BY completed_at DESC LIMIT 1', [$orgId]
|
||||
);
|
||||
$gapDomains = [];
|
||||
if ($assessment) {
|
||||
$responses = Database::fetchAll(
|
||||
'SELECT category, response_value, question_text FROM assessment_responses WHERE assessment_id = ?',
|
||||
[$assessment['id']]
|
||||
);
|
||||
$dData = []; $dGaps = [];
|
||||
foreach ($responses as $r) {
|
||||
$cat = $r['category'];
|
||||
if (!isset($dData[$cat])) { $dData[$cat] = ['implemented'=>0,'partial'=>0,'not_implemented'=>0,'not_applicable'=>0]; $dGaps[$cat] = []; }
|
||||
match ($r['response_value']) {
|
||||
'implemented' => $dData[$cat]['implemented']++, 'partial' => $dData[$cat]['partial']++,
|
||||
'not_implemented' => $dData[$cat]['not_implemented']++, 'not_applicable' => $dData[$cat]['not_applicable']++,
|
||||
default => null,
|
||||
};
|
||||
if ($r['response_value'] === 'not_implemented' && !empty($r['question_text'])) $dGaps[$cat][] = $r['question_text'];
|
||||
}
|
||||
$mogMap = ['governance'=>'pillar_1_governance','risk_management'=>'pillar_2_risk_assessment','incident_management'=>'pillar_7_segnalazioni','business_continuity'=>'pillar_5_monitoraggio','supply_chain'=>'pillar_3_procedure_operative','vulnerability'=>'pillar_5_monitoraggio','policy_measurement'=>'pillar_3_procedure_operative','training_awareness'=>'pillar_4_formazione','cryptography'=>'pillar_6_sicurezza_it','access_control'=>'pillar_6_sicurezza_it'];
|
||||
$artMap = ['governance'=>'Art.20-21','risk_management'=>'Art.21.2.a','incident_management'=>'Art.21.2.b','business_continuity'=>'Art.21.2.c','supply_chain'=>'Art.21.2.d','vulnerability'=>'Art.21.2.e','policy_measurement'=>'Art.21.2.f','training_awareness'=>'Art.21.2.g','cryptography'=>'Art.21.2.h','access_control'=>'Art.21.2.i'];
|
||||
$gapActions = ['critical'=>'Intervento immediato: piano d\'azione entro 30 giorni.','high'=>'Priorità alta: avviare implementazione nel prossimo sprint.','medium'=>'Completare misure parziali entro il prossimo trimestre.','low'=>'Revisione periodica annuale.'];
|
||||
foreach ($dData as $domain => $counts) {
|
||||
$scorable = $counts['implemented'] + $counts['partial'] + $counts['not_implemented'];
|
||||
$score = $scorable > 0 ? (int) round(($counts['implemented']*4+$counts['partial']*2)/($scorable*4)*100) : null;
|
||||
$gl = match(true) { $score===null=>'not_assessed', $score>=75=>'low', $score>=50=>'medium', $score>=25=>'high', default=>'critical' };
|
||||
$gapDomains[] = ['domain'=>$domain,'nis2_article'=>$artMap[$domain]??'Art.21','mog_pillar'=>$mogMap[$domain]??'pillar_3_procedure_operative','score'=>$score,'gap_level'=>$gl,'implemented'=>$counts['implemented'],'partial'=>$counts['partial'],'not_implemented'=>$counts['not_implemented'],'not_implemented_items'=>array_slice($dGaps[$domain]??[],0,3),'suggested_action'=>$gapActions[$gl]??null];
|
||||
}
|
||||
usort($gapDomains, fn($a,$b) => ($a['score']??101)<=>($b['score']??101));
|
||||
}
|
||||
|
||||
// ── incidents ────────────────────────────────────────────────────
|
||||
$now = time();
|
||||
$incRows = Database::fetchAll(
|
||||
'SELECT id, title, classification, severity, status, is_significant, detected_at,
|
||||
early_warning_due, early_warning_sent_at, notification_due, notification_sent_at,
|
||||
final_report_due, final_report_sent_at
|
||||
FROM incidents WHERE organization_id = ? ORDER BY detected_at DESC LIMIT 20', [$orgId]
|
||||
);
|
||||
$incidents = [];
|
||||
$csirtOverdue = 0;
|
||||
foreach ($incRows as $r) {
|
||||
$isOpen = !in_array($r['status'],['closed','post_mortem']); $isSig = (bool)$r['is_significant'];
|
||||
$notDue = $r['notification_due'] ? strtotime($r['notification_due']) : null;
|
||||
$notOv = $isSig && $isOpen && $notDue && $now > $notDue && !$r['notification_sent_at'];
|
||||
if ($notOv) $csirtOverdue++;
|
||||
$incidents[] = ['id'=>(int)$r['id'],'title'=>$r['title'],'severity'=>$r['severity'],'status'=>$r['status'],'is_significant'=>$isSig,'detected_at'=>$r['detected_at'],'art23'=>['early_warning_due'=>$r['early_warning_due'],'early_warning_sent'=>!empty($r['early_warning_sent_at']),'early_warning_overdue'=>$isSig&&$isOpen&&$r['early_warning_due']&&$now>strtotime($r['early_warning_due'])&&!$r['early_warning_sent_at'],'notification_due'=>$r['notification_due'],'notification_sent'=>!empty($r['notification_sent_at']),'notification_overdue'=>$notOv,'final_report_due'=>$r['final_report_due'],'final_report_sent'=>!empty($r['final_report_sent_at']),'final_report_overdue'=>$isSig&&$isOpen&&$r['final_report_due']&&$now>strtotime($r['final_report_due'])&&!$r['final_report_sent_at']]];
|
||||
}
|
||||
|
||||
// ── training ─────────────────────────────────────────────────────
|
||||
$courses = Database::fetchAll('SELECT id,title,target_role,nis2_article,is_mandatory,duration_minutes FROM training_courses WHERE (organization_id=? OR organization_id IS NULL) AND is_active=1 ORDER BY is_mandatory DESC,title', [$orgId]);
|
||||
$trainingData = ['courses_total'=>0,'mandatory_total'=>0,'overall_completion_rate'=>0,'board_completion_rate'=>0,'art20_compliance'=>false,'non_compliant_mandatory'=>[]];
|
||||
if (!empty($courses)) {
|
||||
$cids = array_column($courses,'id');
|
||||
$ph = implode(',', array_fill(0,count($cids),'?'));
|
||||
$agg = Database::fetchAll("SELECT course_id, COUNT(*) as total, SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END) as completed FROM training_assignments WHERE organization_id=? AND course_id IN ({$ph}) GROUP BY course_id", array_merge([$orgId],$cids));
|
||||
$am = []; foreach ($agg as $a) $am[(int)$a['course_id']] = ['total'=>(int)$a['total'],'completed'=>(int)$a['completed']];
|
||||
$board = Database::fetchOne("SELECT COUNT(*) as total, SUM(CASE WHEN ta.status='completed' THEN 1 ELSE 0 END) as completed FROM training_assignments ta JOIN user_organizations uo ON uo.user_id=ta.user_id AND uo.organization_id=ta.organization_id WHERE ta.organization_id=? AND uo.role='board_member'", [$orgId]);
|
||||
$tTot=0; $tComp=0; $mBTot=0; $mBComp=0; $cList=[];
|
||||
foreach ($courses as $c) {
|
||||
$cid=(int)$c['id']; $a=$am[$cid]??['total'=>0,'completed'=>0];
|
||||
$rate=$a['total']>0?(int)round($a['completed']/$a['total']*100):0;
|
||||
$tTot+=$a['total']; $tComp+=$a['completed'];
|
||||
if ($c['is_mandatory']&&$c['target_role']==='board_member') { $mBTot+=$a['total']; $mBComp+=$a['completed']; }
|
||||
$cList[]=['id'=>$cid,'title'=>$c['title'],'target_role'=>$c['target_role'],'nis2_article'=>$c['nis2_article'],'is_mandatory'=>(bool)$c['is_mandatory'],'assigned'=>$a['total'],'completed'=>$a['completed'],'completion_rate'=>$rate];
|
||||
}
|
||||
$nonCompliant = array_values(array_filter($cList, fn($c) => $c['is_mandatory'] && $c['completion_rate'] < 100));
|
||||
$trainingData = ['courses_total'=>count($courses),'mandatory_total'=>count(array_filter($courses,fn($c)=>$c['is_mandatory'])),'assignments_total'=>$tTot,'assignments_completed'=>$tComp,'overall_completion_rate'=>$tTot>0?(int)round($tComp/$tTot*100):0,'board_completion_rate'=>(int)($board['total']??0)>0?(int)round((int)($board['completed']??0)/(int)$board['total']*100):0,'art20_compliance'=>$mBTot>0&&$mBComp>=$mBTot,'non_compliant_mandatory'=>$nonCompliant,'courses'=>$cList];
|
||||
}
|
||||
|
||||
// ── deadlines ────────────────────────────────────────────────────
|
||||
$horizon = $now + ($days * 86400);
|
||||
$deadlines = [];
|
||||
foreach (Database::fetchAll('SELECT id,title,severity,early_warning_due,early_warning_sent_at,notification_due,notification_sent_at,final_report_due,final_report_sent_at FROM incidents WHERE organization_id=? AND is_significant=1 AND status NOT IN ("closed","post_mortem")', [$orgId]) as $inc) {
|
||||
foreach ([['notification_72h',$inc['notification_due'],$inc['notification_sent_at']],['early_warning_24h',$inc['early_warning_due'],$inc['early_warning_sent_at']],['final_report_30d',$inc['final_report_due'],$inc['final_report_sent_at']]] as [$sub,$due,$sent]) {
|
||||
if (!$due||$sent) continue; $ts=strtotime($due); if ($ts>$horizon) continue;
|
||||
$deadlines[]=['type'=>'incident_notification','subtype'=>$sub,'title'=>$sub.': '.$inc['title'],'due_date'=>$due,'overdue'=>$ts<$now,'hours_remaining'=>(int)round(($ts-$now)/3600),'priority'=>'critical','reference_id'=>(int)$inc['id'],'nis2_article'=>'Art.23'];
|
||||
}
|
||||
}
|
||||
foreach (Database::fetchAll('SELECT id,control_code,title,next_review_date,status FROM compliance_controls WHERE organization_id=? AND next_review_date IS NOT NULL AND status!="verified"', [$orgId]) as $c) {
|
||||
$ts=strtotime($c['next_review_date']); if ($ts>$horizon) continue;
|
||||
$deadlines[]=['type'=>'control_review','subtype'=>$c['status'],'title'=>'Revisione: '.$c['title'],'due_date'=>$c['next_review_date'],'overdue'=>$ts<$now,'hours_remaining'=>(int)round(($ts-$now)/3600),'priority'=>'medium','reference_id'=>(int)$c['id'],'nis2_article'=>'Art.21'];
|
||||
}
|
||||
foreach (Database::fetchAll('SELECT id,name,criticality,next_assessment_date FROM suppliers WHERE organization_id=? AND deleted_at IS NULL AND next_assessment_date IS NOT NULL', [$orgId]) as $s) {
|
||||
$ts=strtotime($s['next_assessment_date']); if ($ts>$horizon) continue;
|
||||
$deadlines[]=['type'=>'supplier_assessment','subtype'=>$s['criticality'],'title'=>'Valutazione fornitore: '.$s['name'],'due_date'=>$s['next_assessment_date'],'overdue'=>$ts<$now,'hours_remaining'=>(int)round(($ts-$now)/3600),'priority'=>in_array($s['criticality'],['critical','high'])?'high':'medium','reference_id'=>(int)$s['id'],'nis2_article'=>'Art.21.2.d'];
|
||||
}
|
||||
usort($deadlines, fn($a,$b) => $b['overdue']<=>$a['overdue'] ?: strtotime($a['due_date'])<=>strtotime($b['due_date']));
|
||||
|
||||
$this->jsonSuccess([
|
||||
'org' => ['id' => $orgId, 'name' => $org['name'] ?? '', 'sector' => $org['sector'] ?? '', 'entity_type' => $org['entity_type'] ?? ''],
|
||||
'compliance_score' => $complianceScore,
|
||||
'gap_analysis' => ['assessment_id' => $assessment ? (int)$assessment['id'] : null, 'overall_score' => $assessment ? $assessment['overall_score'] : null, 'completed_at' => $assessment['completed_at'] ?? null, 'domains' => $gapDomains],
|
||||
'incidents' => ['total' => count($incRows), 'csirt_overdue' => $csirtOverdue, 'items' => $incidents],
|
||||
'training' => $trainingData,
|
||||
'deadlines' => ['days_horizon' => $days, 'overdue' => count(array_filter($deadlines,fn($d)=>$d['overdue'])), 'total' => count($deadlines), 'items' => $deadlines],
|
||||
'generated_at' => date('c'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function scoreLabel(?int $score): string
|
||||
{
|
||||
if ($score === null) return 'not_assessed';
|
||||
@ -1511,20 +1642,22 @@ class ServicesController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
// Risposte per assessment
|
||||
// Risposte per assessment (incluse domande non implementate per suggested_action)
|
||||
$responses = Database::fetchAll(
|
||||
'SELECT category, response_value
|
||||
'SELECT category, response_value, question_text
|
||||
FROM assessment_responses
|
||||
WHERE assessment_id = ?',
|
||||
[$assessment['id']]
|
||||
);
|
||||
|
||||
// Calcola score per dominio dalle risposte
|
||||
// Calcola score per dominio dalle risposte + raccoglie items non implementati
|
||||
$domainData = [];
|
||||
$domainGaps = []; // category → [question_text, ...]
|
||||
foreach ($responses as $r) {
|
||||
$cat = $r['category'];
|
||||
if (!isset($domainData[$cat])) {
|
||||
$domainData[$cat] = ['implemented' => 0, 'partial' => 0, 'not_implemented' => 0, 'not_applicable' => 0];
|
||||
$domainGaps[$cat] = [];
|
||||
}
|
||||
match ($r['response_value']) {
|
||||
'implemented' => $domainData[$cat]['implemented']++,
|
||||
@ -1533,6 +1666,9 @@ class ServicesController extends BaseController
|
||||
'not_applicable' => $domainData[$cat]['not_applicable']++,
|
||||
default => null,
|
||||
};
|
||||
if ($r['response_value'] === 'not_implemented' && !empty($r['question_text'])) {
|
||||
$domainGaps[$cat][] = $r['question_text'];
|
||||
}
|
||||
}
|
||||
|
||||
$domains = [];
|
||||
@ -1565,12 +1701,28 @@ class ServicesController extends BaseController
|
||||
// Ordina per score ASC (gap peggiori prima)
|
||||
usort($domains, fn($a, $b) => ($a['score'] ?? 101) <=> ($b['score'] ?? 101));
|
||||
|
||||
// AI recommendations globali
|
||||
$aiRecs = [];
|
||||
if (!empty($assessment['ai_recommendations'])) {
|
||||
$decoded = json_decode($assessment['ai_recommendations'], true);
|
||||
if (is_array($decoded)) $aiRecs = $decoded;
|
||||
}
|
||||
|
||||
// Default suggested_action per gap_level se non ci sono AI recs
|
||||
$gapActions = [
|
||||
'critical' => 'Intervento immediato richiesto: definire piano d\'azione con responsabile e scadenza entro 30 giorni.',
|
||||
'high' => 'Priorità alta: avviare implementazione misure mancanti nel prossimo sprint di compliance.',
|
||||
'medium' => 'Completare le misure parzialmente implementate entro il prossimo trimestre.',
|
||||
'low' => 'Mantenere il livello attuale con revisione periodica annuale.',
|
||||
];
|
||||
|
||||
// Aggiungi suggested_action e not_implemented_items a ogni dominio
|
||||
foreach ($domains as &$d) {
|
||||
$d['not_implemented_items'] = array_slice($domainGaps[$d['domain']] ?? [], 0, 5);
|
||||
$d['suggested_action'] = $gapActions[$d['gap_level']] ?? null;
|
||||
}
|
||||
unset($d);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'assessment_id' => (int) $assessment['id'],
|
||||
'completed_at' => $assessment['completed_at'],
|
||||
@ -1868,6 +2020,9 @@ class ServicesController extends BaseController
|
||||
: 0;
|
||||
$art20ok = $mandatoryBoardTotal > 0 && $mandatoryBoardCompleted >= $mandatoryBoardTotal;
|
||||
|
||||
// Corsi mandatory non conformi (completion_rate < 100%) → evidenza OdV Pillar 4
|
||||
$nonCompliant = array_values(array_filter($courseList, fn($c) => $c['is_mandatory'] && $c['completion_rate'] < 100));
|
||||
|
||||
$this->jsonSuccess([
|
||||
'courses_total' => count($courses),
|
||||
'mandatory_total' => count(array_filter($courses, fn($c) => $c['is_mandatory'])),
|
||||
@ -1876,6 +2031,7 @@ class ServicesController extends BaseController
|
||||
'overall_completion_rate' => $overallRate,
|
||||
'board_completion_rate' => $boardRate,
|
||||
'art20_compliance' => $art20ok,
|
||||
'non_compliant_mandatory' => $nonCompliant,
|
||||
'courses' => $courseList,
|
||||
]);
|
||||
}
|
||||
|
||||
544
application/controllers/index.php
Normal file
544
application/controllers/index.php
Normal file
@ -0,0 +1,544 @@
|
||||
<?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',
|
||||
'assessments' => 'AssessmentController',
|
||||
'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',
|
||||
'whistleblowing'=> 'WhistleblowingController',
|
||||
'normative' => 'NormativeController',
|
||||
'cross-analysis' => 'CrossAnalysisController',
|
||||
'contact' => 'ContactController', // legacy
|
||||
'mktg-lead' => 'MktgLeadController', // standard condiviso TRPG/NIS2
|
||||
'feedback' => 'FeedbackController', // segnalazioni & risoluzione AI
|
||||
];
|
||||
|
||||
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)
|
||||
],
|
||||
|
||||
// ── 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',
|
||||
],
|
||||
|
||||
// ── 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',
|
||||
],
|
||||
|
||||
// ── DashboardController ─────────────────────────
|
||||
'dashboard' => [
|
||||
'GET:overview' => 'overview',
|
||||
'GET:complianceScore' => 'complianceScore',
|
||||
'GET:upcomingDeadlines' => 'deadlines',
|
||||
'GET:recentActivity' => 'recentActivity',
|
||||
'GET:riskHeatmap' => 'riskHeatmap',
|
||||
],
|
||||
|
||||
// ── RiskController ──────────────────────────────
|
||||
'risks' => [
|
||||
'GET:list' => 'list',
|
||||
'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',
|
||||
],
|
||||
|
||||
// ── 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',
|
||||
],
|
||||
|
||||
// ── 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',
|
||||
],
|
||||
|
||||
// ── SupplyChainController ───────────────────────
|
||||
'supply-chain' => [
|
||||
'GET:list' => 'list',
|
||||
'POST:create' => 'create',
|
||||
'GET:{id}' => 'get',
|
||||
'PUT:{id}' => 'update',
|
||||
'DELETE:{id}' => 'delete',
|
||||
'POST:{id}/assess' => 'assessSupplier',
|
||||
'GET:riskOverview' => 'riskOverview',
|
||||
],
|
||||
|
||||
// ── 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',
|
||||
'GET:{id}' => 'get',
|
||||
'PUT:{id}' => 'update',
|
||||
'DELETE:{id}' => 'delete',
|
||||
'GET:dependencyMap' => 'dependencyMap',
|
||||
],
|
||||
|
||||
// ── 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:executiveReport' => 'executiveReport',
|
||||
'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',
|
||||
'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: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',
|
||||
],
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 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' => []];
|
||||
}
|
||||
}
|
||||
|
||||
// /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',
|
||||
]);
|
||||
}
|
||||
102
application/services/config.php
Normal file
102
application/services/config.php
Normal file
@ -0,0 +1,102 @@
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile - Configurazione Principale
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/env.php';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// APPLICAZIONE
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
define('APP_ENV', Env::get('APP_ENV', 'development'));
|
||||
define('APP_DEBUG', APP_ENV === 'development');
|
||||
define('APP_NAME', Env::get('APP_NAME', 'NIS2 Agile'));
|
||||
define('APP_VERSION', Env::get('APP_VERSION', '1.0.0'));
|
||||
define('APP_URL', Env::get('APP_URL', 'http://localhost:8080'));
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PERCORSI
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
define('BASE_PATH', dirname(dirname(__DIR__)));
|
||||
define('APP_PATH', BASE_PATH . '/application');
|
||||
define('PUBLIC_PATH', BASE_PATH . '/public');
|
||||
define('UPLOAD_PATH', PUBLIC_PATH . '/uploads');
|
||||
define('DATA_PATH', APP_PATH . '/data');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// AUTENTICAZIONE JWT
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
define('JWT_SECRET', APP_ENV === 'production'
|
||||
? Env::getRequired('JWT_SECRET')
|
||||
: Env::get('JWT_SECRET', 'nis2_dev_jwt_secret'));
|
||||
define('JWT_ALGORITHM', 'HS256');
|
||||
define('JWT_EXPIRES_IN', Env::int('JWT_EXPIRES_IN', 7200));
|
||||
define('JWT_REFRESH_EXPIRES_IN', Env::int('JWT_REFRESH_EXPIRES_IN', 604800));
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PROVISIONING (B2B — lg231 e altri sistemi Agile)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Secret master per provisioning automatico da sistemi Agile partner.
|
||||
// lg231 lo usa per POST /api/services/provision (onboarding automatico tenant).
|
||||
define('PROVISION_SECRET', Env::get('PROVISION_SECRET', 'nis2_prov_dev_secret'));
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PASSWORD POLICY
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
define('PASSWORD_MIN_LENGTH', Env::int('PASSWORD_MIN_LENGTH', 8));
|
||||
define('PASSWORD_REQUIRE_UPPERCASE', Env::bool('PASSWORD_REQUIRE_UPPERCASE', true));
|
||||
define('PASSWORD_REQUIRE_NUMBER', Env::bool('PASSWORD_REQUIRE_NUMBER', true));
|
||||
define('PASSWORD_REQUIRE_SPECIAL', Env::bool('PASSWORD_REQUIRE_SPECIAL', true));
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CORS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
define('CORS_ALLOWED_ORIGINS', array_filter([
|
||||
APP_URL,
|
||||
APP_ENV !== 'production' ? 'http://localhost:8080' : null,
|
||||
APP_ENV !== 'production' ? 'http://localhost:3000' : null,
|
||||
]));
|
||||
define('CORS_ALLOWED_METHODS', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
define('CORS_ALLOWED_HEADERS', 'Content-Type, Authorization, X-Organization-Id, X-Requested-With');
|
||||
define('CORS_MAX_AGE', '86400');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// RATE LIMITING
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
define('RATE_LIMIT_AUTH_LOGIN', [
|
||||
['max' => 5, 'window_seconds' => 60],
|
||||
['max' => 20, 'window_seconds' => 3600],
|
||||
]);
|
||||
define('RATE_LIMIT_AUTH_REGISTER', [
|
||||
['max' => 3, 'window_seconds' => 600],
|
||||
]);
|
||||
define('RATE_LIMIT_AI', [
|
||||
['max' => 10, 'window_seconds' => 60],
|
||||
['max' => 100, 'window_seconds' => 3600],
|
||||
]);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// AI (ANTHROPIC)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
define('ANTHROPIC_API_KEY', Env::get('ANTHROPIC_API_KEY', ''));
|
||||
define('ANTHROPIC_MODEL', Env::get('ANTHROPIC_MODEL', 'claude-sonnet-4-5-20250929'));
|
||||
define('ANTHROPIC_MAX_TOKENS', Env::int('ANTHROPIC_MAX_TOKENS', 4096));
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CERTISOURCE (atti-service.php)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
define('CERTISOURCE_API_URL', Env::get('CERTISOURCE_API_URL', 'https://certisource.it/atti-service.php'));
|
||||
define('CERTISOURCE_API_KEY', Env::get('CERTISOURCE_API_KEY', '')); // cs_pat_...
|
||||
define('CERTISOURCE_POLL_MAX', Env::int('CERTISOURCE_POLL_MAX', 30)); // max tentativi polling
|
||||
define('CERTISOURCE_POLL_SEC', Env::int('CERTISOURCE_POLL_SEC', 3)); // secondi tra poll
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FEEDBACK & SEGNALAZIONI
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
define('FEEDBACK_RESOLVE_PASSWORD', Env::get('FEEDBACK_RESOLVE_PASSWORD', ''));
|
||||
define('FEEDBACK_WORKER_LOG', Env::get('FEEDBACK_WORKER_LOG', '/var/log/nis2/feedback-worker.log'));
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// TIMEZONE
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
date_default_timezone_set('Europe/Rome');
|
||||
@ -1,116 +1,122 @@
|
||||
# Contesto Ultima Sessione
|
||||
|
||||
**Data**: 2026-03-10
|
||||
**Durata**: sessione lunga (continuazione da sessione precedente)
|
||||
**Data**: 2026-03-17
|
||||
**Durata**: sessione lunga (continuazione da sessione precedente compressa)
|
||||
|
||||
---
|
||||
|
||||
## Cosa abbiamo fatto
|
||||
|
||||
### 1. Fix feedback.js — bottone FAB non visibile
|
||||
- `common.js`: aggiunto auto-inject di `feedback.js` su tutte le pagine autenticate
|
||||
- `feedback.js`: icona cambiata in `fa-comment-medical`, label testo "Segnala / Feedback"
|
||||
- `style.css`: FAB ridisegnato come pill cyan (#06B6D4) con animazione pulse, stile lg231
|
||||
- Commit: `4143dd3`
|
||||
### 1. Fix 3 bug critici (Apache error log)
|
||||
|
||||
### 2. Fix register.html — P.IVA e invite token
|
||||
- P.IVA: aggiunta validazione locale checksum IVA italiana (cifra di controllo)
|
||||
- Messaggio: se P.IVA valida → verde "✓ P.IVA valida" anche se CertiSource offline
|
||||
- Parametro URL: ora legge `?invite=` (oltre a `?invite_token=` e `?token=`)
|
||||
- Placeholder campo invite aggiornato: `inv_a1b2c3d4e5f6...`
|
||||
- `OnboardingController.php`: lookup-piva con CertiSource fallisce → 404 graceful (non 500)
|
||||
- Commit: `d603f35`, `67560e1`
|
||||
- **`InviteController.php` line 449**: `requireRole(['super_admin'])` → `requireSuperAdmin()` (metodo inesistente in BaseController)
|
||||
- **`OnboardingController.php`**: aggiunto `require_once APP_PATH . '/services/RateLimitService.php'` mancante
|
||||
- **`simulate-nis2-b2b.php` line 216**: URL `POST /invites` → `POST /invites/create` (router mapping corretto)
|
||||
|
||||
### 3. Fix utente presidenza@agile.software
|
||||
- Email già esistente (cristiano.benassati@gmail.com, id=4) bloccava registrazione
|
||||
- Creato nuovo account: `presidenza@agile.software` / Silvia Garretto / org_admin (id=103)
|
||||
- Password resettata a: `Presidenza2026!`
|
||||
- Invite token inv_f25a41... (id=4) ancora pending (non usato — bisogna fare onboarding)
|
||||
### 2. Fix simulate-nis2-big.php — ora ✓387 ⚠121 ✗0 (156s, 10 aziende, 18 fasi)
|
||||
|
||||
### 4. FEAT: invite con dati destinatario (pre-compilazione form)
|
||||
- `InviteController.php`: accetta `recipient_first_name/last_name/email/vat`, salva in `metadata` JSON
|
||||
- `InviteController.php`: `show()` parsa metadata e espone `metadata_recipient`
|
||||
- `InviteController.php`: `invite_url` ora punta a `register.html?invite=` (era onboarding.html)
|
||||
- `AuthController.php` validateInvite: ritorna `recipient` con dati del destinatario
|
||||
- `register.html`: dopo validazione invite, pre-compila nome/cognome/email/piva dal recipient
|
||||
- Commit: `7bb92b1`
|
||||
- Enum sector: `digital_infrastructure` → `digital_infra`, `drinking_water` → `water`
|
||||
- `ensureOrg()`: rimossi dal payload create: `vat_number`, `legal_form`, `ateco_code`, `province`, `region` (P.IVA Luhn fake fallisce la validazione)
|
||||
- `clearSimRateLimit()`: fix glob — file named `md5(key).json`, non `login:*.json`; ora usa `glob('*.json')` per pulire tutti
|
||||
- `clearSimRateLimit()` chiamata prima di ogni login in `ensureUser()`
|
||||
|
||||
### 5. licenseExt.html — aggiornamento completo
|
||||
- Aggiunta sezione "Dati destinatario" con campi: Nome, Cognome, Email, P.IVA
|
||||
- `generateLicense()`: include `recipient_*` nel payload API
|
||||
- `renderGenResult()`: mostra link pronto da inviare + riga verde con dati destinatario
|
||||
- `resetForm()`: pulisce anche i campi recipient
|
||||
- `showDetail()`: mostra box verde con dati destinatario (metadata_recipient)
|
||||
- `revokeInvite()`: già funzionante via DELETE /api/invites/{id}
|
||||
- Commit: `e02e0e2`
|
||||
### 3. Fix simulate-nis2-b2b.php — ora ✓18 ⚠1 ✗0
|
||||
|
||||
- URL fix `/invites` → `/invites/create` (v. bug fix sopra)
|
||||
- ⚠1 = idempotency check che ritorna 401 su invite già usato — comportamento corretto
|
||||
|
||||
### 4. 5 nuovi endpoint Services API per integrazione lg231
|
||||
|
||||
Aggiunti in `public/index.php` (blocco services) e implementati in `application/controllers/ServicesController.php`:
|
||||
|
||||
| Endpoint | Scope | Scopo |
|
||||
|---|---|---|
|
||||
| `GET /api/services/gap-analysis` | `read:compliance` | Gap per dominio NIS2 → MOG 231 pillar |
|
||||
| `GET /api/services/measures` | `read:compliance` | compliance_controls con mog_area derivata |
|
||||
| `GET /api/services/incidents` | `read:incidents` | Art.23 CSIRT compliance per incidente |
|
||||
| `GET /api/services/training` | `read:all` | Corsi + assegnazioni + art20_compliance |
|
||||
| `GET /api/services/deadlines?days=N` | `read:all` | Scadenze aggregate da 4 sorgenti |
|
||||
|
||||
### 5. Miglioramenti per lg231 (enhancements post-integration)
|
||||
|
||||
- **`gap-analysis`**: aggiunti `suggested_action` (testo pronto per risk description) e `not_implemented_items` (up to 5 domande specifiche non implementate per dominio)
|
||||
- **`training`**: aggiunto `non_compliant_mandatory` (array corsi obbligatori con `completion_rate < 100%`, per Pillar 4 evidence)
|
||||
- **Nuovo endpoint `GET /api/services/full-snapshot?days=30`**: aggrega org + compliance_score + gap_analysis + incidents + training + deadlines in una chiamata (sostituisce 6 round-trip lg231)
|
||||
|
||||
---
|
||||
|
||||
## File modificati in questa sessione
|
||||
|
||||
### Backend
|
||||
- `application/controllers/OnboardingController.php` — lookup-piva: 500→404 graceful
|
||||
- `application/controllers/InviteController.php` — recipient data in metadata, invite_url fix, show() con metadata_recipient
|
||||
- `application/controllers/AuthController.php` — validateInvite ritorna recipient
|
||||
- `application/controllers/InviteController.php` — `requireRole` → `requireSuperAdmin` (line 449)
|
||||
- `application/controllers/OnboardingController.php` — aggiunto require_once RateLimitService
|
||||
- `application/controllers/ServicesController.php` — 6 nuovi metodi: `gapAnalysis`, `measures`, `incidents`, `training`, `deadlines`, `fullSnapshot`
|
||||
|
||||
### Frontend
|
||||
- `public/js/common.js` — auto-inject feedback.js
|
||||
- `public/js/feedback.js` — icona + label pill button
|
||||
- `public/css/style.css` — FAB cyan pill con pulse animation
|
||||
- `public/register.html` — validazione P.IVA locale, ?invite= param, pre-fill da recipient
|
||||
- `public/licenseExt.html` — sezione dati destinatario, link pronto, modale aggiornato
|
||||
|
||||
### Docs
|
||||
- `docs/prompts/big-simulation-prompt.md` — CREATO: prompt per simulate-nis2-big.php (10 aziende, 18 fasi, ✓200+)
|
||||
### Frontend / Router
|
||||
- `public/index.php` — 6 nuove route nel blocco services
|
||||
- `public/simulate-nis2-b2b.php` — fix URL `/invites/create`
|
||||
- `public/simulate-nis2-big.php` — 4 fix (sector enum, vat strip, ratelimit clear, ensureUser)
|
||||
|
||||
---
|
||||
|
||||
## Utenti demo attivi (dopo ultima simulazione)
|
||||
## Mapping NIS2 → MOG 231 (implementato)
|
||||
|
||||
| Email | Password | Ruolo | Org |
|
||||
|-------|----------|-------|-----|
|
||||
| admin@datacore-srl.demo | NIS2Demo2026! | org_admin | DataCore S.r.l. |
|
||||
| admin@medclinic-spa.demo | NIS2Demo2026! | org_admin | MedClinic Italia S.p.A. |
|
||||
| admin@enernet-srl.demo | NIS2Demo2026! | org_admin | EnerNet Distribuzione S.r.l. |
|
||||
| consultant@nis2agile.demo | NIS2Demo2026! | consultant | tutte e 3 |
|
||||
| presidenza@agile.software | Presidenza2026! | org_admin | nessuna org ancora |
|
||||
| NIS2 Domain | MOG 231 Pillar |
|
||||
|---|---|
|
||||
| governance | pillar_1_governance |
|
||||
| risk_management | pillar_2_risk_assessment |
|
||||
| incident_management | pillar_7_segnalazioni |
|
||||
| business_continuity | pillar_5_monitoraggio |
|
||||
| supply_chain | pillar_3_procedure_operative |
|
||||
| vulnerability | pillar_5_monitoraggio |
|
||||
| policy_measurement | pillar_3_procedure_operative |
|
||||
| training_awareness | pillar_4_formazione |
|
||||
| cryptography | pillar_6_sicurezza_it |
|
||||
| access_control | pillar_6_sicurezza_it |
|
||||
|
||||
---
|
||||
|
||||
## Commit questa sessione
|
||||
|
||||
```
|
||||
2194799 [FIX] InviteController requireRole→requireSuperAdmin + OnboardingController add RateLimitService
|
||||
90ac821 [FIX] simulate-nis2-b2b: POST /invites → /invites/create (router mapping)
|
||||
0e2774d [FIX] BigSim: sector enum (digital_infra/water), VAT skip, rate-limit clear fix (md5 filenames)
|
||||
a122b49 [FEAT] Services API: 5 new endpoints (gap-analysis, measures, incidents, training, deadlines)
|
||||
cfaead6 [FEAT] Services API enhancements: suggested_action, not_implemented_items, non_compliant_mandatory, full-snapshot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stato endpoint Services API (testati su prod — InfraTech org)
|
||||
|
||||
- ✓ `gap-analysis`: 10 domini, `suggested_action` presente, `not_implemented_items` presenti
|
||||
- ✓ `measures`: 13 controlli, `completion_percentage=38`, `mog_area` derivato da control_code
|
||||
- ✓ `incidents`: `art23` block per incidente con `_due`/`_sent`/`_overdue`
|
||||
- ✓ `training`: `art20_compliance`, `non_compliant_mandatory` presenti
|
||||
- ✓ `deadlines?days=365`: 6 scadenze aggregate
|
||||
- ✓ `full-snapshot`: `compliance_score=38`, `gap_domains=10`, `incidents=1`, `deadlines=0`
|
||||
|
||||
---
|
||||
|
||||
## Problemi aperti / TODO
|
||||
|
||||
### Urgenti
|
||||
- `presidenza@agile.software`: ha account ma nessuna org → deve fare onboarding su https://nis2.agile.software/onboarding.html
|
||||
- Invite token `inv_f25a41...` (id=4, label "Presidenza"): ancora pending, non applicato all'utente
|
||||
### Noti (da sessioni precedenti)
|
||||
- `presidenza@agile.software`: account senza org → deve fare onboarding
|
||||
- P.IVA lookup CertiSource (`/api/company/enrich`) ritorna 404 — endpoint cambiato
|
||||
- `POST /api/auth/validate-invite` implementato ma non nel router pubblico
|
||||
|
||||
### Noti
|
||||
- `POST /api/auth/validate-invite` esiste ma non è nel router pubblico (solo via InviteController)
|
||||
- P.IVA lookup (CertiSource `/api/company/enrich`) risponde 404 — endpoint cambiato, da investigare
|
||||
- Rate limiter register: 3 tentativi/10min — causa problemi se si riprova velocemente
|
||||
- `Content-Type: application/json` da curl CLI esterno fallisce (body non letto) — solo via PHP curl o browser fetch funziona correttamente
|
||||
|
||||
### Sprint futuri
|
||||
- `simulate-nis2-big.php`: da implementare seguendo `docs/prompts/big-simulation-prompt.md`
|
||||
- RAG su normativa NIS2, benchmark settoriale (Piano Adaptive Sprint 3)
|
||||
|
||||
---
|
||||
|
||||
## Flusso licenze B2B (aggiornato)
|
||||
|
||||
```
|
||||
mktg → licenseExt.html → genera licenza con dati destinatario
|
||||
→ invite_url: register.html?invite=inv_xxx
|
||||
→ cliente clicca link → form pre-compilato (nome/cognome/email/piva)
|
||||
→ cliente inserisce password → Crea Account → onboarding
|
||||
```
|
||||
|
||||
Pannello mktg: https://nis2.agile.software/licenseExt.html (accesso: super_admin)
|
||||
### Note tecniche importanti (memorizzare)
|
||||
- `compliance_controls` NON ha colonna `nis2_article` — va derivata da `control_code` via regex `preg_match('/^NIS2-(\S+)/', $code, $m)`
|
||||
- `incidents.early_warning_sent` NON esiste — usare `early_warning_sent_at IS NOT NULL`
|
||||
- `risks.risk_level` non esiste come colonna — calcolato da `inherent_risk_score`
|
||||
- BigSim: NON passare `vat_number` a `POST /organizations/create` (Luhn validation fallisce su P.IVA fake)
|
||||
|
||||
---
|
||||
|
||||
## Prossimi passi consigliati
|
||||
|
||||
1. Fare onboarding con `presidenza@agile.software` per testare flusso completo
|
||||
2. Implementare `simulate-nis2-big.php` da `docs/prompts/big-simulation-prompt.md`
|
||||
3. Verificare/correggere endpoint CertiSource per P.IVA lookup
|
||||
4. Aggiungere `POST /api/auth/validate-invite` al router pubblico (è già implementato)
|
||||
1. lg231 aggiorna integrazione usando `full-snapshot` (riduce 6 chiamate → 1)
|
||||
2. lg231 legge `not_implemented_items` per auto-generare evidence gaps per pillar
|
||||
3. Valutare endpoint pubblico per P.IVA lookup (attuale richiede auth JWT)
|
||||
4. RAG su normativa NIS2, benchmark settoriale (Sprint 3 pianificato)
|
||||
|
||||
@ -334,6 +334,7 @@ $actionMap = [
|
||||
'GET:incidents' => 'incidents',
|
||||
'GET:training' => 'training',
|
||||
'GET:deadlines' => 'deadlines',
|
||||
'GET:fullSnapshot' => 'fullSnapshot',
|
||||
],
|
||||
|
||||
// ── WebhookController (CRUD keys + subscriptions) ──
|
||||
|
||||
84
public/simulate-nis2-big.php
Normal file
84
public/simulate-nis2-big.php
Normal file
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile — Wrapper SSE per simulate-nis2-big.php
|
||||
*
|
||||
* DocumentRoot è public/ → il file principale è fuori dalla web root.
|
||||
* Usa proc_open per eseguire il simulatore come sottoprocesso CLI con NIS2_SSE=1.
|
||||
*
|
||||
* URL: https://nis2.agile.software/simulate-nis2-big.php
|
||||
*/
|
||||
|
||||
set_time_limit(0);
|
||||
ignore_user_abort(true);
|
||||
ini_set('memory_limit', '64M');
|
||||
|
||||
header('Content-Type: text/event-stream');
|
||||
header('Cache-Control: no-cache');
|
||||
header('X-Accel-Buffering: no');
|
||||
header('Connection: keep-alive');
|
||||
ob_implicit_flush(true);
|
||||
while (ob_get_level()) ob_end_flush();
|
||||
|
||||
$scriptPath = realpath(__DIR__ . '/../simulate-nis2-big.php');
|
||||
|
||||
if (!$scriptPath || !is_file($scriptPath)) {
|
||||
echo 'data: ' . json_encode(['t' => 'error', 'm' => 'simulate-nis2-big.php non trovato']) . "\n\n";
|
||||
echo 'data: ' . json_encode(['t' => 'done', 'stats' => ['pass' => 0, 'fail' => 1, 'skip' => 0, 'warn' => 0]]) . "\n\n";
|
||||
flush();
|
||||
exit;
|
||||
}
|
||||
|
||||
$env = [];
|
||||
foreach ($_ENV ?: [] as $k => $v) {
|
||||
if (is_string($v)) $env[$k] = $v;
|
||||
}
|
||||
$env['NIS2_SSE'] = '1';
|
||||
|
||||
$descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
|
||||
$cwd = dirname($scriptPath);
|
||||
$phpBin = PHP_BINARY;
|
||||
if (str_contains($phpBin, 'fpm') || !is_executable($phpBin)) {
|
||||
foreach (['/usr/bin/php' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION, '/usr/bin/php', 'php'] as $try) {
|
||||
if (is_executable($try) || shell_exec("which $try 2>/dev/null")) { $phpBin = $try; break; }
|
||||
}
|
||||
}
|
||||
$cmd = $phpBin . ' ' . escapeshellarg($scriptPath);
|
||||
$proc = proc_open($cmd, $descriptors, $pipes, $cwd, $env);
|
||||
|
||||
if (!is_resource($proc)) {
|
||||
echo 'data: ' . json_encode(['t' => 'error', 'm' => 'Impossibile avviare il simulatore BIG']) . "\n\n";
|
||||
echo 'data: ' . json_encode(['t' => 'done', 'stats' => ['pass' => 0, 'fail' => 1, 'skip' => 0, 'warn' => 0]]) . "\n\n";
|
||||
flush();
|
||||
exit;
|
||||
}
|
||||
|
||||
fclose($pipes[0]);
|
||||
stream_set_blocking($pipes[1], false);
|
||||
stream_set_blocking($pipes[2], false);
|
||||
|
||||
while (true) {
|
||||
$chunk = fread($pipes[1], 8192);
|
||||
if ($chunk !== false && $chunk !== '') {
|
||||
echo $chunk;
|
||||
flush();
|
||||
}
|
||||
$err = fread($pipes[2], 1024);
|
||||
if ($err !== false && $err !== '') {
|
||||
foreach (explode("\n", trim($err)) as $errLine) {
|
||||
if (trim($errLine) !== '') {
|
||||
echo 'data: ' . json_encode(['t' => 'error', 'm' => '[stderr] ' . trim($errLine)]) . "\n\n";
|
||||
}
|
||||
}
|
||||
flush();
|
||||
}
|
||||
$status = proc_get_status($proc);
|
||||
if (!$status['running']) {
|
||||
$tail = stream_get_contents($pipes[1]);
|
||||
if ($tail) { echo $tail; flush(); }
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
proc_close($proc);
|
||||
break;
|
||||
}
|
||||
usleep(50000);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user