SEC-P0-001B6A — AI cache actor-bound (correction sûre resolvePlanLimit incluse)
Ce rapport a été corrigé après une vérification pré-finalisation dédiée (consultation de dumps locaux du schéma réellement déployé). La version initiale affirmait à tort que la contrainte
UNIQUE(user_id)observée dans la base de test locale était représentative de la production. Ce n'est pas le cas — voir §8. B6A ferme uniquement la vulnérabilité de cache et la correction sûre deresolvePlanLimit(). Toute question relative à l'intégrité deai_user_quotasreste ouverte et hors périmètre B6A (§12).
Scope B6A
- Composants corrigés :
AICacheService,AIGatewayService(site d'appel du cache),AIQuotaService::resolvePlanLimit()(correction de compatibilité mineure, verdict A). - Hors périmètre B6A : toute modification de
AIQuotaService::getOrCreate(), toute migration (ai_user_quotas,actor_subscriptionsou autre), DB-001 global, la question métier « quota acteur vs quota (utilisateur × acteur) », DOC-001, Stripe, billing, RBAC global, audit log central, refonte AIGateway, changement provider/modèle, frontend, cache distribué, optimisation performance non sécuritaire. AIQuotaService::resolvePlanLimit(): découverte non prévue au périmètre initial, traitée car strictement dans le fichier audité et corrigible sans migration (voir §5bis). Reclassée verdict A — correction sûre après vérification pré-finalisation (§5bis, preuves de production).
1. Architecture cache observée
AIGatewayService::execute() calcule inputHash = sha256($userPrompt) (texte du prompt rendu, dépendant uniquement des variables métier fournies par le client), puis construit la clé via AICacheService::buildKey() et interroge AiCache (table ai_cache, cache_key varchar(64) UNIQUE). Un cache hit court-circuite l'appel provider, l'écriture cache et la consommation quota/boost, mais pas le AIQuotaService::check() (disponibilité), déjà effectué en amont.
2. Clé exacte avant B6A
// app/Modules/AI/Services/AICacheService.php (avant)
public function buildKey(string $promptKey, string $inputHash): string
{
return hash('sha256', $promptKey.'|'.$inputHash);
}
Composants réels de la clé : promptKey (ex. publication.improve) et inputHash (hash du texte du prompt rendu à partir des variables du payload client). Absents de la clé : user_id, actor_id, model, source applicative (X-App-Source). Site d'appel (AIGatewayService.php:102, avant B6A) : buildKey($promptKey, $inputHash) — ni userId ni actorId, pourtant tous deux disponibles dans execute().
3. Tests de collision (résultats empiriques)
Reproduction end-to-end via /api/v1/ai/publication/improve, deux utilisateurs distincts, deux acteurs distincts, texte strictement identique :
[PREUVE A2] Acteur A (user A) -> cached=false texte=Réponse UNIQUE pour A
[PREUVE A2] Acteur B (user B) -> cached=true texte=Réponse UNIQUE pour A
[PREUVE A2] VULNERABILITE CONFIRMEE : acteur B (user B) a reçu la réponse cachée d'acteur A (user A).
- Même user + même acteur + même contenu → cache hit conservé (comportement historique, souhaité).
- Deux utilisateurs différents + même acteur + même requête → cache hit légitime, souhaité (partager le cache entre collaborateurs d'un même acteur ne traverse aucune frontière d'autorisation, indépendamment de la question ouverte §12 sur l'identité métier du quota).
- Même user + même acteur + sources applicatives différentes → non pertinent : la clé n'a jamais contenu
X-App-Source, et B0 ne demande pas de l'y inclure.
4. Données réellement cachées
response_json (contenu généré : texte de publication/service/onboarding, tags, variantes), tokens, coût estimé. Le contenu est dérivé uniquement des variables textuelles fournies par le client dans la requête (texte, ton, commune, nom, activite, etc.) — jamais de données DB privées de l'acteur. Risque qualifié : collision d'attribution économique et de contenu entre acteurs non liés, pas une fuite de données confidentielles issues de la base.
5. Vulnérabilité cache — CONFIRMÉE et CORRIGÉE
Le cache n'était pas actor-bound. Correction appliquée (AICacheService::buildKey() + site d'appel AIGatewayService) : la clé intègre désormais actor_id quand il est fourni ; null sinon (routes non actor-bound, ex. /ai/publication/tags, inchangées — conforme à la recommandation B0 §8). Aucune dimension supplémentaire ajoutée (pas de user_id, pas de model, pas de source applicative, pas de rôle, pas de permission, pas de plan).
5bis. Correction de compatibilité — AIQuotaService::resolvePlanLimit() (verdict A)
Pendant l'audit, tout appel réel (non mocké) à AIQuotaService::getOrCreate() avec un acteur_id non nul échouait systématiquement (500) :
SQLSTATE[42703]: Undefined column: 7 ERROR: column "started_at" does not exist
Cause locale : la migration 000065_add_missing_columns_to_actor_subscriptions_table.php trie lexicalement avant 0000_00_00_000014_create_actor_subscriptions_table.php. Elle s'exécute donc avant que la table existe, se neutralise via son garde Schema::hasTable(), et n'est jamais rejouée. Conséquence dans la base de test locale uniquement : actor_subscriptions n'y possède pas started_at. Ce défaut d'ordonnancement est une occurrence DB-001, non traitée dans B6A.
Correction appliquée (verdict A — correction sûre) :
// avant
->orderByDesc('started_at')
->orderByDesc('created_at')
->value('plan_id');
// après (B6A)
->orderByDesc('created_at')
->value('plan_id');
Preuve du verdict A (vérification pré-finalisation, deux dumps locaux distincts de production — 2026-08-07 et 2026-08-13, ce dernier étant le plus récent disponible) : actor_subscriptions porte réellement started_at en production, et une contrainte actor_subscriptions_acteur_id_key = UNIQUE(acteur_id). Il ne peut donc structurellement exister qu'une seule ligne par acteur_id, quel que soit l'environnement. Le tri par started_at n'a par conséquent jamais pu départager plusieurs lignes candidates — ni en base de test (où il provoquait une erreur SQL avant B6A), ni en production (où une seule ligne existe toujours). Le retrait ne change donc la sélection du plan dans aucun environnement inspecté. Détail complet : rapport de vérification pré-finalisation SEC-P0-001B6 (§1).
Impact : ce bug rendait invisibles, dans la suite de tests existante, tous les scénarios getOrCreate/quota réellement actor-scoped — les tests B1-B4 « autorisé → 200 » mockent tous AIGatewayService ou AIQuotaService. C'est cette correction qui rend exécutables les tests de non-régression B4/B5 sur chemin acteur réel (§14).
6. Architecture quota observée (information, non modifiée par B6A)
AIQuotaService::getOrCreate(userId, ?acteurId) : si acteurId !== null, recherche exclusivement par WHERE acteur_id = ? (le user_id n'entre pas dans le WHERE) ; sinon WHERE user_id = ? AND acteur_id IS NULL. Non modifié par B6A.
7. Divergence constatée : code vs schéma déployé (question ouverte, non tranchée)
- Code (
AIQuotaService::getOrCreate) : recherche principalement paracteur_idseul quand il est présent — ce qui suppose un modèle « un quota partagé par acteur ». - Dernier dump disponible du schéma déployé (§8) : la contrainte réelle est
UNIQUE(user_id, acteur_id) WHERE acteur_id IS NOT NULL— ce qui suggère un modèle « un quota par couple (utilisateur, acteur) ».
Ces deux sources ne s'accordent pas entre elles. B6A ne tranche pas laquelle est correcte : c'est une question d'architecture/produit (§12), pas un simple défaut technique.
8. Contraintes ai_user_quotas — par source, explicitement distinguées
Base de test Laravel locale (dmv_test, PostgreSQL, migrations Laravel exécutées telles quelles — interrogation directe d'information_schema) :
Indexes:
"ai_user_quotas_pkey" PRIMARY KEY, btree (id)
"ai_user_quotas_acteur_id_index" btree (acteur_id) -- INDEX simple, PAS unique
"ai_user_quotas_user_id_index" btree (user_id)
"ai_user_quotas_user_id_unique" UNIQUE CONSTRAINT, btree (user_id) -- UNIQUE(user_id) GLOBAL
Dernier dump disponible du schéma déployé, daté du 13/08/2026 (/Users/sylvain/dev/dmv/backups/2026-08-13_09-00-03/full.backup, extrait localement via pg_restore --schema-only, sans connexion réseau) :
CREATE UNIQUE INDEX ai_user_quotas_user_actor_uniq
ON public.ai_user_quotas USING btree (user_id, acteur_id) WHERE (acteur_id IS NOT NULL);
CREATE UNIQUE INDEX ai_user_quotas_user_null_actor_uniq
ON public.ai_user_quotas USING btree (user_id) WHERE (acteur_id IS NULL);
Aucune contrainte UNIQUE(user_id) globale dans ce dump. Les deux sources sont différentes l'une de l'autre, pas seulement différentes d'une hypothétique cible commune.
⚠️ La production live n'a pas été interrogée. Le dump du 13/08/2026 est un instantané local figé, restauré temporairement dans une base jetable (schéma + les deux tables concernées uniquement) pour cette vérification, puis supprimée. Ce n'est pas une preuve de l'état de la production au moment de la rédaction de ce rapport — seulement le dernier instantané disponible localement.
Données réelles observées dans ce dump (7 lignes au total) : 0 doublon par acteur_id, 0 doublon personnel ; un même user_id y possède légitimement 6 lignes liées à 6 acteurs différents plus 1 ligne personnelle — situation qu'une contrainte UNIQUE(user_id) globale (celle de la base de test locale) interdirait.
9. Comportement actor_id NULL
Sémantique applicative du code : whereNull('acteur_id') explicite pour les quotas personnels — non ambiguë. Le dump du 13/08/2026 protège ce cas via ai_user_quotas_user_null_actor_uniq = UNIQUE(user_id) WHERE acteur_id IS NULL, cohérent avec cette sémantique. B6A ne modifie rien ici.
10. Concurrence — état requalifié
getOrCreate() fait un SELECT puis un INSERT séparés, sans transaction, sans lockForUpdate, sans firstOrCreate/upsert, sans gestion de UniqueViolation, sans retry — ceci reste vrai dans tous les environnements. Cependant, l'affirmation précédente de ce rapport (« un même user_id sur 2 acteurs lève systématiquement une violation de contrainte ») décrivait un comportement de la base de test locale uniquement (UNIQUE(user_id) global, §8) — pas un comportement confirmé du schéma réellement déployé, qui utilise une contrainte différente et où, empiriquement (dump 13/08/2026), ce cas existe et fonctionne sans erreur. Cette section ne peut donc plus être présentée comme une preuve de dette de production ; elle documente un écart entre base de test et dernier dump disponible, à traiter comme DB-001 (§13), et une question de sémantique métier encore ouverte (§7, §12).
11. Corrections effectuées (B6A)
AICacheService::buildKey()— clé scopée paractor_id(cache).- Site d'appel
AIGatewayService::execute()— passeactorIdàbuildKey(). AIQuotaService::resolvePlanLimit()— retrait de la référence à la colonnestarted_at, verdict A (correction sûre, sans effet sur la sélection métier dans aucun environnement inspecté).
Aucune migration. Aucune modification de AIQuotaService::getOrCreate(). Aucune modification de contrainte DB.
12. Dette reportée — question métier quota (hors B6A, hors DB-001 automatique)
La cible de migration précédemment proposée dans ce rapport (UNIQUE(acteur_id) WHERE acteur_id IS NOT NULL) est abandonnée : elle ne correspond ni au schéma de test, ni au dernier dump disponible du schéma déployé, et n'a jamais été validée par une preuve de production.
Question ouverte, distincte de DB-001, nécessitant un arbitrage produit/architecture :
Le quota IA actor-bound appartient-il à l'acteur (une ligne partagée entre tous ses collaborateurs, ce que suppose le code actuel de
getOrCreate) ou au couple utilisateur × acteur (une ligne par collaborateur, ce que suggère la contrainte du dernier dump disponible) ?
Cette question n'est pas classée automatiquement comme DB-001 : DB-001 porte sur l'alignement de schémas déjà décidés ; ici, c'est le modèle métier lui-même qui doit être choisi avant qu'un alignement ait un sens. Tant qu'elle n'est pas tranchée, aucune migration ni modification de getOrCreate() ne doit être entreprise.
13. État DB-001
Non traité dans B6A. Preuves supplémentaires apportées par la vérification pré-finalisation (deux tables auditées divergent entre base de test locale et dernier dump disponible du schéma déployé) :
actor_subscriptions: la base de test locale n'a passtarted_at/current_period_start/current_period_end/periode/granted_by/admin_note/updated_at, présentes dans le dernier dump disponible (défaut d'ordonnancement de la migration000065).ai_user_quotas: la base de test locale porteUNIQUE(user_id)global, absent du dernier dump disponible, qui porte à la place deux index UNIQUE partiels différents (§8).
Alignement (migrations Laravel / schéma de test / dernier dump disponible) à traiter dans un chantier DB-001 dédié, séparé de B6A et de la question métier §12.
14. Non-régression B1–B5
- Suite
AITest(B1-B5) : 57/57, inchangée. X-App-Sourcereste purement informatif nulle part transformé en source d'autorisation (aucune modification deAIController/IdentifyApp/ActorAccessServicedans B6A).- Test
non_regression_b5_admin_backoffice_exemption_scope_acteur_reel: exemption admin+backoffice B5 vérifiée sur un appel acteur réel (non mocké), désormais exécutable grâce à la correction §5bis. - Test
non_regression_b4_quota_owner_acteur_reel_fonctionne_sans_mock:/ai/quotaowner fonctionne end-to-end sans mock. - Tests B6A dédiés (
AICacheQuotaHardeningTest) : 7 (5 cache + 2 non-régression B4/B5) — les 3 tests documentant un comportement quota présenté à tort comme représentatif de production ont été retirés du périmètre B6A (cf. rapport de vérification pré-finalisation).
État DOC-001
Inchangé. IA-GOUVERNANCE.md reste absent et n'a pas été recréé ; non traité dans cette mission.
Validation
- API :
git diff --check,php -l,route:list,AITest57/57, tests B6A dédiés 7/7, suite complète,pint --test. - Docs :
git diff --check,npm run build. - Aucune migration créée. Aucun frontend modifié. Aucun commit créé.