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.";