From fe7d0555c6f9113f7f41a12735e235b95f8cf22d Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Wed, 24 Jun 2026 08:44:16 +0200 Subject: [PATCH] [FEAT] ARIA data-aware: snapshot dati org dell'utente nel prompt (Fase B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Richiesta: "AI deve sapere i contenuti dei dati a cui ha accesso l'utente". - AIService::orgDataSnapshotBlock($orgId): aggregati LIVE dell'org (compliance score, rischi aperti per livello + top 5, incidenti aperti, asset attivi/rilevanti NIS2, fornitori critici scoperti, NC aperte, formazione, policy per stato). Query con colonne reali (riuso DashboardController), ognuna in try/catch (degrada, non rompe). - Iniettato in askWithRag SOLO se org_data_ok. - SICUREZZA multi-tenant: AiController verifica membership in user_organizations (super_admin bypassa) → org_data_ok; scope rigoroso organization_id → niente leak cross-org da X-Organization-Id falsificato. PRIVACY: niente nome/fatturato (anonimizz.). Smoke: con membership ARIA cita score 80% + 14 rischi (org 996003); senza membership nega l'accesso ai dati. php-fpm reload. Co-Authored-By: Claude Opus 4.8 (1M context) --- application/controllers/AiController.php | 16 +++++++ application/services/AIService.php | 53 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/application/controllers/AiController.php b/application/controllers/AiController.php index d54ba95..82f4481 100644 --- a/application/controllers/AiController.php +++ b/application/controllers/AiController.php @@ -54,6 +54,22 @@ class AiController extends BaseController 'page_help' => $pageHelp, ]; + // Membership verificata → ARIA può iniettare lo snapshot DATI dell'org + // (anti-spoof X-Organization-Id; super_admin bypassa, come da modello ruoli). + $orgId = (int) ($userContext['organization_id'] ?? 0); + $userContext['org_data_ok'] = false; + if ($orgId > 0) { + if (($user['role'] ?? '') === 'super_admin') { + $userContext['org_data_ok'] = true; + } else { + $member = Database::fetchOne( + 'SELECT 1 FROM user_organizations WHERE user_id = ? AND organization_id = ?', + [$userId, $orgId] + ); + $userContext['org_data_ok'] = (bool) $member; + } + } + try { $aiService = new AIService(); $result = $aiService->askWithRag($question, $userContext); diff --git a/application/services/AIService.php b/application/services/AIService.php index caca338..44fa037 100644 --- a/application/services/AIService.php +++ b/application/services/AIService.php @@ -106,6 +106,51 @@ class AIService return implode("\n", $lines) . "\n"; } + /** + * Snapshot LIVE dei dati dell'organizzazione dell'utente, da iniettare nel + * system prompt di ARIA così può rispondere su numeri SPECIFICI (rischi, + * incidenti, asset, score...) e non solo su normativa/guida. + * + * SICUREZZA: chiamato SOLO dopo verifica membership (AiController) → scope + * rigoroso `organization_id = $orgId`. PRIVACY: niente nome/fatturato dell'org + * (coerente con l'anonimizzazione già in uso); solo aggregati + titoli operativi. + * Ogni query è in try/catch: una colonna assente non rompe ARIA (degrada). + */ + private function orgDataSnapshotBlock(int $orgId): string + { + if ($orgId <= 0) return ''; + $q = function ($sql, $p = []) { try { return Database::fetchOne($sql, $p); } catch (\Throwable $e) { return null; } }; + $all = function ($sql, $p = []) { try { return Database::fetchAll($sql, $p); } catch (\Throwable $e) { return []; } }; + + $org = $q("SELECT sector, entity_type, voluntary_compliance FROM organizations WHERE id=?", [$orgId]); + $asmt = $q("SELECT overall_score FROM assessments WHERE organization_id=? AND status='completed' ORDER BY completed_at DESC LIMIT 1", [$orgId]); + $risk = $q("SELECT COUNT(*) tot, SUM(inherent_risk_score>=20) ch, SUM(inherent_risk_score BETWEEN 10 AND 19) med, SUM(inherent_risk_score<10) low FROM risks WHERE organization_id=? AND status!='closed'", [$orgId]); + $top = $all("SELECT title, inherent_risk_score s FROM risks WHERE organization_id=? AND status!='closed' ORDER BY inherent_risk_score DESC LIMIT 5", [$orgId]); + $inc = $q("SELECT COUNT(*) c FROM incidents WHERE organization_id=? AND status NOT IN ('closed','post_mortem')", [$orgId]); + $asTot = $q("SELECT COUNT(*) c FROM assets WHERE organization_id=? AND status='active'", [$orgId]); + $asRel = $q("SELECT COUNT(*) c FROM assets WHERE organization_id=? AND is_nis2_relevant=1", [$orgId]); + $sup = $q("SELECT COUNT(*) c FROM suppliers WHERE organization_id=? AND criticality IN ('high','critical') AND security_requirements_met=0", [$orgId]); + $nc = $q("SELECT COUNT(*) c FROM non_conformities WHERE organization_id=? AND status NOT IN ('closed','resolved','verified')", [$orgId]); + $tr = $q("SELECT COUNT(*) tot, SUM(status='completed') done, SUM(status='overdue') ovd FROM training_assignments WHERE organization_id=?", [$orgId]); + $pol = $all("SELECT status, COUNT(*) n FROM policies WHERE organization_id=? GROUP BY status", [$orgId]); + + $L = []; + $L[] = "\n## DATI DELL'ORGANIZZAZIONE DELL'UTENTE (snapshot live — SOLO la sua org)"; + $L[] = "Usa questi numeri per risposte specifiche sulla sua situazione. Sono dati riservati della sua organizzazione: non confrontarli con altre org. Se un dato è 0/assente, dillo con chiarezza."; + if ($org) $L[] = "- Classificazione: " . ($org['entity_type'] ?: 'n/d') . (!empty($org['voluntary_compliance']) ? ' (adesione volontaria)' : '') . ($org['sector'] ? ", settore " . $org['sector'] : ''); + $L[] = "- Compliance score (ultimo assessment): " . ($asmt && $asmt['overall_score'] !== null ? round((float)$asmt['overall_score']) . '%' : 'nessun assessment completato'); + if ($risk) $L[] = "- Rischi aperti: " . (int)$risk['tot'] . " (critico/alto " . (int)$risk['ch'] . ", medio " . (int)$risk['med'] . ", basso " . (int)$risk['low'] . ")"; + if ($top) $L[] = " Top rischi: " . implode('; ', array_map(fn($x) => $x['title'] . ' (' . (int)$x['s'] . ')', $top)); + $L[] = "- Incidenti aperti: " . (int)($inc['c'] ?? 0); + $L[] = "- Asset attivi: " . (int)($asTot['c'] ?? 0) . " (rilevanti NIS2: " . (int)($asRel['c'] ?? 0) . ")"; + $L[] = "- Fornitori critici con requisiti sicurezza NON soddisfatti: " . (int)($sup['c'] ?? 0); + $L[] = "- Non conformità aperte: " . (int)($nc['c'] ?? 0); + if ($tr && (int)$tr['tot'] > 0) $L[] = "- Formazione: " . (int)$tr['done'] . "/" . (int)$tr['tot'] . " completate, " . (int)$tr['ovd'] . " scadute"; + if ($pol) $L[] = "- Policy: " . implode(', ', array_map(fn($x) => $x['status'] . ' ' . $x['n'], $pol)); + + return implode("\n", $L) . "\n"; + } + /** * Analizza risultati gap analysis e genera raccomandazioni */ @@ -787,6 +832,14 @@ PROMPT; $systemPrompt .= $this->relevanceGridBlock(); } + // DATI ORG dell'utente: iniettati SOLO se la membership è stata verificata + // (AiController imposta org_data_ok dopo il check user_organizations / super_admin) + // → scope rigoroso, niente leak cross-org da X-Organization-Id falsificato. + $snapOrgId = (int) ($userContext['organization_id'] ?? 0); + if ($snapOrgId > 0 && !empty($userContext['org_data_ok'])) { + $systemPrompt .= $this->orgDataSnapshotBlock($snapOrgId); + } + if (!empty($contextBlock)) { $systemPrompt .= "\n## Contesto documentale (knowledge base)\n" . $contextBlock . "\n\nQuando rispondi, cita esplicitamente i numeri tra parentesi quadre [1], [2], ... che corrispondono ai documenti del contesto.";