[FEAT] CertiSource atti-service.php integration: structured data, PAT auth, ATECO fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-03-17 14:07:16 +01:00
parent 75a678f60e
commit cfaead6121
2 changed files with 569 additions and 0 deletions

View File

@ -1460,4 +1460,567 @@ class ServicesController extends BaseController
if ($score >= 40) return 'partial';
return 'significant_gaps';
}
// ══════════════════════════════════════════════════════════════════════
// lg231 / GRC INTEGRATION ENDPOINTS
// ══════════════════════════════════════════════════════════════════════
/**
* GET /api/services/gap-analysis
* Gap analysis per dominio NIS2 Art.21 con mapping ai 7 pilastri MOG 231.
* Basato sull'assessment più recente completato.
*/
public function gapAnalysis(): void
{
$this->requireApiKey('read:compliance');
$orgId = $this->currentOrgId;
// Mapping NIS2 domain → MOG 231 pillar e articolo
$domainMeta = [
'governance' => ['article' => 'Art.20-21', 'mog_pillar' => 'pillar_1_governance'],
'risk_management' => ['article' => 'Art.21.2.a', 'mog_pillar' => 'pillar_2_risk_assessment'],
'incident_management' => ['article' => 'Art.21.2.b', 'mog_pillar' => 'pillar_7_segnalazioni'],
'business_continuity' => ['article' => 'Art.21.2.c', 'mog_pillar' => 'pillar_5_monitoraggio'],
'supply_chain' => ['article' => 'Art.21.2.d', 'mog_pillar' => 'pillar_3_procedure_operative'],
'vulnerability' => ['article' => 'Art.21.2.e', 'mog_pillar' => 'pillar_5_monitoraggio'],
'policy_measurement' => ['article' => 'Art.21.2.f', 'mog_pillar' => 'pillar_3_procedure_operative'],
'training_awareness' => ['article' => 'Art.21.2.g', 'mog_pillar' => 'pillar_4_formazione'],
'cryptography' => ['article' => 'Art.21.2.h', 'mog_pillar' => 'pillar_6_sicurezza_it'],
'access_control' => ['article' => 'Art.21.2.i', 'mog_pillar' => 'pillar_6_sicurezza_it'],
];
// Assessment più recente completato
$assessment = Database::fetchOne(
'SELECT id, overall_score, category_scores, ai_summary, ai_recommendations, completed_at
FROM assessments
WHERE organization_id = ? AND status = "completed"
ORDER BY completed_at DESC LIMIT 1',
[$orgId]
);
if (!$assessment) {
$this->jsonSuccess([
'assessment_id' => null,
'completed_at' => null,
'overall_score' => null,
'domains' => [],
'ai_summary' => null,
'ai_recommendations' => [],
'note' => 'Nessun assessment completato. Avvia un gap analysis dal modulo Assessment.',
]);
return;
}
// Risposte per assessment
$responses = Database::fetchAll(
'SELECT category, response_value
FROM assessment_responses
WHERE assessment_id = ?',
[$assessment['id']]
);
// Calcola score per dominio dalle risposte
$domainData = [];
foreach ($responses as $r) {
$cat = $r['category'];
if (!isset($domainData[$cat])) {
$domainData[$cat] = ['implemented' => 0, 'partial' => 0, 'not_implemented' => 0, 'not_applicable' => 0];
}
match ($r['response_value']) {
'implemented' => $domainData[$cat]['implemented']++,
'partial' => $domainData[$cat]['partial']++,
'not_implemented' => $domainData[$cat]['not_implemented']++,
'not_applicable' => $domainData[$cat]['not_applicable']++,
default => null,
};
}
$domains = [];
foreach ($domainData 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;
$gapLevel = match (true) {
$score === null => 'not_assessed',
$score >= 75 => 'low',
$score >= 50 => 'medium',
$score >= 25 => 'high',
default => 'critical',
};
$meta = $domainMeta[$domain] ?? ['article' => 'Art.21', 'mog_pillar' => 'pillar_3_procedure_operative'];
$domains[] = [
'domain' => $domain,
'nis2_article' => $meta['article'],
'mog_pillar' => $meta['mog_pillar'],
'score' => $score,
'gap_level' => $gapLevel,
'implemented' => $counts['implemented'],
'partial' => $counts['partial'],
'not_implemented' => $counts['not_implemented'],
'not_applicable' => $counts['not_applicable'],
];
}
// Ordina per score ASC (gap peggiori prima)
usort($domains, fn($a, $b) => ($a['score'] ?? 101) <=> ($b['score'] ?? 101));
$aiRecs = [];
if (!empty($assessment['ai_recommendations'])) {
$decoded = json_decode($assessment['ai_recommendations'], true);
if (is_array($decoded)) $aiRecs = $decoded;
}
$this->jsonSuccess([
'assessment_id' => (int) $assessment['id'],
'completed_at' => $assessment['completed_at'],
'overall_score' => $assessment['overall_score'] !== null ? (int) $assessment['overall_score'] : null,
'domains' => $domains,
'ai_summary' => $assessment['ai_summary'],
'ai_recommendations' => $aiRecs,
]);
}
/**
* GET /api/services/measures
* Misure di sicurezza Art.21 con stato implementazione e mapping MOG 231.
* ?framework=nis2|iso27001|both (default: tutti)
* ?status=not_started|in_progress|implemented|verified
*/
public function measures(): void
{
$this->requireApiKey('read:compliance');
$orgId = $this->currentOrgId;
// Mapping control_code prefix → mog_area
$mogAreaMap = [
'NIS2-20' => 'governance',
'NIS2-21' => 'governance',
'NIS2-21.2.a' => 'risk_assessment',
'NIS2-21.2.b' => 'incident_management',
'NIS2-21.2.c' => 'business_continuity',
'NIS2-21.2.d' => 'supply_chain',
'NIS2-21.2.e' => 'vulnerability_management',
'NIS2-21.2.f' => 'policy_procedure',
'NIS2-21.2.g' => 'training_awareness',
'NIS2-21.2.h' => 'cryptography',
'NIS2-21.2.i' => 'access_control',
'NIS2-21.2.j' => 'access_control',
'ISO' => 'iso27001_control',
];
$where = 'organization_id = ?';
$params = [$orgId];
$filterFramework = $this->getParam('framework');
if ($filterFramework && in_array($filterFramework, ['nis2', 'iso27001', 'both'])) {
$where .= ' AND framework = ?';
$params[] = $filterFramework;
}
$filterStatus = $this->getParam('status');
if ($filterStatus) {
$where .= ' AND status = ?';
$params[] = $filterStatus;
}
$controls = Database::fetchAll(
"SELECT id, control_code, framework, title, status,
implementation_percentage, nis2_article, next_review_date, updated_at
FROM compliance_controls
WHERE {$where}
ORDER BY framework, control_code",
$params
);
$stats = ['total' => 0, 'not_started' => 0, 'in_progress' => 0, 'implemented' => 0, 'verified' => 0];
$measures = [];
foreach ($controls as $c) {
$stats['total']++;
$s = $c['status'] ?? 'not_started';
if (isset($stats[$s])) $stats[$s]++;
// Derive mog_area from control_code
$mogArea = 'other';
$code = $c['control_code'] ?? '';
foreach ($mogAreaMap as $prefix => $area) {
if (str_starts_with($code, $prefix)) { $mogArea = $area; break; }
}
if ($mogArea === 'other' && str_starts_with($code, 'ISO')) $mogArea = 'iso27001_control';
$measures[] = [
'id' => (int) $c['id'],
'code' => $code,
'framework' => $c['framework'],
'title' => $c['title'],
'status' => $s,
'implementation_percentage' => (int) ($c['implementation_percentage'] ?? 0),
'nis2_article' => $c['nis2_article'],
'mog_area' => $mogArea,
'next_review_date' => $c['next_review_date'],
];
}
$completionPct = $stats['total'] > 0
? (int) round(($stats['implemented'] + $stats['verified'] + $stats['in_progress'] * 0.5) / $stats['total'] * 100)
: 0;
$this->jsonSuccess(array_merge($stats, [
'completion_percentage' => $completionPct,
'measures' => $measures,
]));
}
/**
* GET /api/services/incidents
* Incidenti con stato Art.23 CSIRT (diverso da incidentsFeed: focus compliance).
* ?significant=1 solo incidenti significativi
* ?status=open solo aperti (non closed/post_mortem)
* ?limit=50
*/
public function incidents(): void
{
$this->requireApiKey('read:incidents');
$orgId = $this->currentOrgId;
$limit = min((int) ($this->getParam('limit') ?: 50), 200);
$where = 'organization_id = ?';
$params = [$orgId];
if ($this->getParam('significant') === '1') {
$where .= ' AND is_significant = 1';
}
if ($this->getParam('status') === 'open') {
$where .= ' AND status NOT IN ("closed","post_mortem")';
}
$rows = Database::fetchAll(
"SELECT id, title, classification, severity, status, is_significant,
detected_at, closed_at,
early_warning_due, early_warning_sent_at,
notification_due, notification_sent_at,
final_report_due, final_report_sent_at,
affected_services, root_cause
FROM incidents
WHERE {$where}
ORDER BY detected_at DESC
LIMIT {$limit}",
$params
);
$now = time();
$csirtOverdue = 0;
$incidents = [];
foreach ($rows as $r) {
$isOpen = !in_array($r['status'], ['closed', 'post_mortem']);
$isSig = (bool) $r['is_significant'];
// Art.23 compliance per incidente significativo aperto
$ewDue = $r['early_warning_due'] ? strtotime($r['early_warning_due']) : null;
$notDue = $r['notification_due'] ? strtotime($r['notification_due']) : null;
$frDue = $r['final_report_due'] ? strtotime($r['final_report_due']) : null;
$notOverdue = $isSig && $isOpen && $notDue && $now > $notDue && !$r['notification_sent_at'];
if ($notOverdue) $csirtOverdue++;
$incidents[] = [
'id' => (int) $r['id'],
'title' => $r['title'],
'classification' => $r['classification'],
'severity' => $r['severity'],
'status' => $r['status'],
'is_significant' => $isSig,
'detected_at' => $r['detected_at'],
'closed_at' => $r['closed_at'],
'art23' => [
'early_warning_due' => $r['early_warning_due'],
'early_warning_sent' => !empty($r['early_warning_sent_at']),
'early_warning_overdue'=> $isSig && $isOpen && $ewDue && $now > $ewDue && !$r['early_warning_sent_at'],
'notification_due' => $r['notification_due'],
'notification_sent' => !empty($r['notification_sent_at']),
'notification_overdue' => $notOverdue,
'final_report_due' => $r['final_report_due'],
'final_report_sent' => !empty($r['final_report_sent_at']),
'final_report_overdue' => $isSig && $isOpen && $frDue && $now > $frDue && !$r['final_report_sent_at'],
],
];
}
// Statistiche totali (non solo quelle nella pagina)
$stats = Database::fetchOne(
'SELECT COUNT(*) as total,
SUM(CASE WHEN status NOT IN ("closed","post_mortem") THEN 1 ELSE 0 END) as open_count,
SUM(CASE WHEN is_significant = 1 THEN 1 ELSE 0 END) as significant
FROM incidents WHERE organization_id = ?',
[$orgId]
);
$this->jsonSuccess([
'total' => (int) ($stats['total'] ?? 0),
'open' => (int) ($stats['open_count'] ?? 0),
'significant' => (int) ($stats['significant'] ?? 0),
'csirt_overdue' => $csirtOverdue,
'fetched' => count($incidents),
'incidents' => $incidents,
]);
}
/**
* GET /api/services/training
* Formazione NIS2 Art.20 corsi, completamento, compliance board.
* Utile per lg231 per evidenza OdV Pillar 4 (Formazione).
*/
public function training(): void
{
$this->requireApiKey('read:all');
$orgId = $this->currentOrgId;
// Corsi dell'org + corsi globali (org_id IS NULL)
$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, target_role, title',
[$orgId]
);
if (empty($courses)) {
$this->jsonSuccess([
'courses_total' => 0, 'mandatory_total' => 0,
'assignments_total' => 0, 'assignments_completed' => 0,
'overall_completion_rate' => 0, 'board_completion_rate' => 0,
'art20_compliance' => false, 'courses' => [],
]);
return;
}
$courseIds = array_column($courses, 'id');
$placeholders = implode(',', array_fill(0, count($courseIds), '?'));
// Aggregazione assignments per corso
$assignRows = 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 ({$placeholders})
GROUP BY course_id",
array_merge([$orgId], $courseIds)
);
$assignMap = [];
foreach ($assignRows as $row) {
$assignMap[(int)$row['course_id']] = ['total' => (int)$row['total'], 'completed' => (int)$row['completed']];
}
// Completion board members: corsi mandatory × utenti board
$boardAssign = Database::fetchOne(
"SELECT COUNT(*) as total,
SUM(CASE WHEN ta.status = 'completed' THEN 1 ELSE 0 END) as completed
FROM training_assignments ta
JOIN users u ON u.id = ta.user_id
JOIN user_organizations uo ON uo.user_id = u.id AND uo.organization_id = ta.organization_id
WHERE ta.organization_id = ? AND uo.role = 'board_member'",
[$orgId]
);
$totalAssign = 0;
$totalCompleted = 0;
$mandatoryBoardTotal = 0;
$mandatoryBoardCompleted = 0;
$courseList = [];
foreach ($courses as $c) {
$cid = (int) $c['id'];
$a = $assignMap[$cid] ?? ['total' => 0, 'completed' => 0];
$rate = $a['total'] > 0 ? (int) round($a['completed'] / $a['total'] * 100) : 0;
$totalAssign += $a['total'];
$totalCompleted += $a['completed'];
if ($c['is_mandatory'] && $c['target_role'] === 'board_member') {
$mandatoryBoardTotal += $a['total'];
$mandatoryBoardCompleted += $a['completed'];
}
$courseList[] = [
'id' => $cid,
'title' => $c['title'],
'target_role' => $c['target_role'],
'nis2_article' => $c['nis2_article'],
'is_mandatory' => (bool) $c['is_mandatory'],
'duration_minutes' => (int) ($c['duration_minutes'] ?? 0),
'assigned' => $a['total'],
'completed' => $a['completed'],
'completion_rate' => $rate,
];
}
$overallRate = $totalAssign > 0 ? (int) round($totalCompleted / $totalAssign * 100) : 0;
$boardRate = ((int)($boardAssign['total'] ?? 0)) > 0
? (int) round((int)($boardAssign['completed'] ?? 0) / (int)$boardAssign['total'] * 100)
: 0;
$art20ok = $mandatoryBoardTotal > 0 && $mandatoryBoardCompleted >= $mandatoryBoardTotal;
$this->jsonSuccess([
'courses_total' => count($courses),
'mandatory_total' => count(array_filter($courses, fn($c) => $c['is_mandatory'])),
'assignments_total' => $totalAssign,
'assignments_completed' => $totalCompleted,
'overall_completion_rate' => $overallRate,
'board_completion_rate' => $boardRate,
'art20_compliance' => $art20ok,
'courses' => $courseList,
]);
}
/**
* GET /api/services/deadlines
* Scadenze aggregate da 4 sorgenti: incidenti CSIRT, revisioni controlli,
* assessment fornitori, training assignments.
* ?days=30 finestra (default 30, max 365)
*/
public function deadlines(): void
{
$this->requireApiKey('read:all');
$orgId = $this->currentOrgId;
$days = min((int) ($this->getParam('days') ?: 30), 365);
$now = time();
$horizon = $now + ($days * 86400);
$deadlines = [];
// ── 1. Notifiche CSIRT incidenti significativi aperti ──────────────
$openInc = 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]
);
foreach ($openInc as $inc) {
$steps = [
['subtype' => 'early_warning_24h', 'due' => $inc['early_warning_due'], 'sent' => $inc['early_warning_sent_at']],
['subtype' => 'notification_72h', 'due' => $inc['notification_due'], 'sent' => $inc['notification_sent_at']],
['subtype' => 'final_report_30d', 'due' => $inc['final_report_due'], 'sent' => $inc['final_report_sent_at']],
];
foreach ($steps as $step) {
if (!$step['due'] || $step['sent']) continue;
$dueTs = strtotime($step['due']);
if ($dueTs > $horizon) continue;
$hoursLeft = (int) round(($dueTs - $now) / 3600);
$deadlines[] = [
'type' => 'incident_notification',
'subtype' => $step['subtype'],
'title' => $step['subtype'] . ': ' . $inc['title'],
'due_date' => $step['due'],
'overdue' => $dueTs < $now,
'hours_remaining' => $hoursLeft,
'priority' => 'critical',
'reference_id' => (int) $inc['id'],
'nis2_article' => 'Art.23',
];
}
}
// ── 2. Revisioni controlli compliance ─────────────────────────────
$controls = 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]
);
foreach ($controls as $c) {
$dueTs = strtotime($c['next_review_date']);
if ($dueTs > $horizon) continue;
$hoursLeft = (int) round(($dueTs - $now) / 3600);
$deadlines[] = [
'type' => 'control_review',
'subtype' => $c['status'],
'title' => 'Revisione: ' . $c['title'],
'due_date' => $c['next_review_date'],
'overdue' => $dueTs < $now,
'hours_remaining' => $hoursLeft,
'priority' => 'medium',
'reference_id' => (int) $c['id'],
'nis2_article' => 'Art.21',
];
}
// ── 3. Assessment fornitori ────────────────────────────────────────
$suppliers = 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]
);
foreach ($suppliers as $s) {
$dueTs = strtotime($s['next_assessment_date']);
if ($dueTs > $horizon) continue;
$hoursLeft = (int) round(($dueTs - $now) / 3600);
$priority = in_array($s['criticality'], ['critical', 'high']) ? 'high' : 'medium';
$deadlines[] = [
'type' => 'supplier_assessment',
'subtype' => $s['criticality'],
'title' => 'Valutazione fornitore: ' . $s['name'],
'due_date' => $s['next_assessment_date'],
'overdue' => $dueTs < $now,
'hours_remaining' => $hoursLeft,
'priority' => $priority,
'reference_id' => (int) $s['id'],
'nis2_article' => 'Art.21.2.d',
];
}
// ── 4. Training assignments scaduti/in scadenza ────────────────────
$trainings = Database::fetchAll(
'SELECT ta.id, tc.title, ta.due_date, ta.status
FROM training_assignments ta
JOIN training_courses tc ON tc.id = ta.course_id
WHERE ta.organization_id = ? AND ta.status NOT IN ("completed")
AND ta.due_date IS NOT NULL',
[$orgId]
);
foreach ($trainings as $t) {
$dueTs = strtotime($t['due_date']);
if ($dueTs > $horizon) continue;
$hoursLeft = (int) round(($dueTs - $now) / 3600);
$deadlines[] = [
'type' => 'training_assignment',
'subtype' => $t['status'],
'title' => 'Formazione: ' . $t['title'],
'due_date' => $t['due_date'],
'overdue' => $dueTs < $now,
'hours_remaining' => $hoursLeft,
'priority' => 'low',
'reference_id' => (int) $t['id'],
'nis2_article' => 'Art.20',
];
}
// Ordina: overdue + urgenti prima
usort($deadlines, function ($a, $b) {
if ($a['overdue'] !== $b['overdue']) return $b['overdue'] <=> $a['overdue'];
return strtotime($a['due_date']) <=> strtotime($b['due_date']);
});
$overdue = count(array_filter($deadlines, fn($d) => $d['overdue']));
$due7days = count(array_filter($deadlines, fn($d) => !$d['overdue'] && strtotime($d['due_date']) <= $now + 7 * 86400));
$due30days = count(array_filter($deadlines, fn($d) => !$d['overdue']));
$this->jsonSuccess([
'days_horizon' => $days,
'overdue' => $overdue,
'due_7_days' => $due7days,
'due_30_days' => $due30days,
'total' => count($deadlines),
'deadlines' => $deadlines,
]);
}
}

View File

@ -328,6 +328,12 @@ $actionMap = [
'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',
],
// ── WebhookController (CRUD keys + subscriptions) ──