[FEAT] ARIA data-aware: snapshot dati org dell'utente nel prompt (Fase B)

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) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-24 08:44:16 +02:00
co-authored by Claude Opus 4.8
parent 42529043d3
commit fe7d0555c6
2 changed files with 69 additions and 0 deletions
+16
View File
@@ -54,6 +54,22 @@ class AiController extends BaseController
'page_help' => $pageHelp, '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 { try {
$aiService = new AIService(); $aiService = new AIService();
$result = $aiService->askWithRag($question, $userContext); $result = $aiService->askWithRag($question, $userContext);
+53
View File
@@ -106,6 +106,51 @@ class AIService
return implode("\n", $lines) . "\n"; 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 * Analizza risultati gap analysis e genera raccomandazioni
*/ */
@@ -787,6 +832,14 @@ PROMPT;
$systemPrompt .= $this->relevanceGridBlock(); $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)) { if (!empty($contextBlock)) {
$systemPrompt .= "\n## Contesto documentale (knowledge base)\n" . $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."; . "\n\nQuando rispondi, cita esplicitamente i numeri tra parentesi quadre [1], [2], ... che corrispondono ai documenti del contesto.";