Aller au contenu principal

SEC-P0-001B5 — AI backoffice authorization hardening

Scope

  • Composant corrigé : App\Modules\AI\Controllers\AIController, dérivation du flag noCreditDebit.
  • Domaine : exemption de débit quota/crédit accordée aux appels IA « backoffice ».
  • Hors périmètre : B6 cache actor-bound, unicité ai_user_quotas, DB-001, refonte auth admin, refonte backoffice, Stripe, nouvelle permission IA, audit log central.

1. Routes et surfaces admin auditées

Routes IA métier (générant potentiellement une exemption)

Middleware commun : api + identify.app + auth:sanctum + throttle:ai (app/Modules/AI/Routes/api.php).

RouteMéthodenoCreditDebit (avant B5)RBAC acteur
/api/v1/ai/publication/generatePOSTisBackofficeSource()manage_publications si actor_id
/api/v1/ai/publication/improvePOSTisBackofficeSource()manage_publications si actor_id
/api/v1/ai/publication/variantsPOSTtrue (toujours gratuit, tous utilisateurs)manage_publications si actor_id
/api/v1/ai/publication/tagsPOSTisBackofficeSource()aucun (actor_id absent)
/api/v1/ai/service/generatePOSTisBackofficeSource()manage_actor si actor_id
/api/v1/ai/service/improvePOSTisBackofficeSource()manage_actor si actor_id
/api/v1/ai/onboarding/suggestPOSTisBackofficeSource()manage_actor si actor_id
/api/v1/ai/quotaGETn/aisOwner (B4)

Six routes dérivaient donc l'exemption depuis isBackofficeSource(). variants reste gratuit pour tous par conception produit (comportement inchangé, non lié à un privilège). quota ne débite pas (B4).

Routes IA admin

Sous identify.app + auth:sanctum + ensure.admin (app/Modules/Admin/Routes/api.php) : GET/PUT/PATCH /api/v1/admin/ai/prompts*, GET /api/v1/admin/ai/logs. Elles n'appellent pas le provider et ne consomment pas de crédit. La garde ensure.admin s'appuie sur Profile::isAdmin() et n'a pas été modifiée : un utilisateur non-admin, même avec X-App-Source: backoffice, reçoit 403.

2. Mécanisme X-App-Source

app/Http/Middleware/IdentifyApp.php lit le header client X-App-Source, vérifie qu'il appartient à {dmv, backoffice, playloop, assosuite, mairie}, puis expose la valeur (app_source). Aucune liaison à l'authentification ou à un rôle : la valeur est intégralement contrôlée par le client. Consommateurs réels : dmv-backoffice envoie X-App-Source: backoffice (src/lib/api.js) ; dmv-workspace/dmv-public utilisent d'autres sources. Le header est donc, par nature, une donnée déclarative non fiable pour l'autorisation.

3. Mécanisme noCreditDebit

  • Défini dans AIController (variable locale par handler), passé en argument nommé à AIGatewayService::execute(..., noCreditDebit: ...).
  • Lu dans AIGatewayService::execute : si true, saute le AIQuotaService::check() (ligne ~97), force creditsConsumed = 0 (ligne ~162), saute BoostUsageService::consumeAssistantBoost() et AIQuotaService::consume() (lignes ~182-189). Effet : bypass complet du débit quota/crédit.
  • Origine avant B5 : dérivé du seul header X-App-Source via isBackofficeSource(). Jamais lu depuis le payload ni la query — mais influençable par le client via le header.

4. Vulnérabilité confirmée

CONFIRMÉE — élévation de privilège économique par header falsifié. Tout utilisateur authentifié (Workspace, public, ou appel API direct), sur 6 routes IA, pouvait envoyer X-App-Source: backoffice pour obtenir noCreditDebit=true et consommer l'IA sans débiter son quota/crédit.

Résultats des scénarios d'attaque :

ScénarioVerdict
Utilisateur Workspace envoyant X-App-Source: backofficeCONFIRMÉ (avant B5) → corrigé
Header falsifié → bypass quotaCONFIRMÉ (avant B5) → corrigé
Header falsifié → comportement admin sur /admin/ai/*INFIRMÉ (protégé par ensure.admin)
Utilisateur non-admin sur /admin/ai/*INFIRMÉ (403)
noCreditDebit via payload/queryINFIRMÉ (jamais lu du client) → test de non-régression ajouté
Requête sans source reconnue traitée comme backofficeINFIRMÉ (X-App-Source obligatoire, 400 sinon ; seule la valeur exacte backoffice déclenchait l'exemption)
Appel API direct hors frontendCONFIRMÉ (avant B5) → corrigé

5. Source de vérité retenue

Décision (arbitrage produit/sécurité, option retenue « Admin ET source backoffice ») :

L'exemption de débit n'est accordée que si l'utilisateur authentifié est administrateur global (Profile::isAdmin(), source de vérité déjà utilisée par ensure.admin et ActorAccessService) ET que la requête provient du contexte backoffice.

Le header X-App-Source reste informatif : falsifié seul par un non-admin, il n'accorde aucun privilège. Le contexte privilégié est déterminé côté serveur après authentification, conformément à l'invariant « un header client seul n'accorde jamais un privilège » et à la décision ouverte B0 (« Droit admin réel, jamais header seul »).

6. Correction

AIController (app/Modules/AI/Controllers/AIController.php) :

  • isBackofficeSource() conservée mais explicitement documentée comme lecture informative du header.
  • Nouvelle méthode serveur shouldExemptCreditDebit(Request): bool :
return $this->isBackofficeSource($request)
&& auth()->user()?->isAdmin() === true;
  • Les 6 handlers dérivant l'exemption remplacent $noCreditDebit = $this->isBackofficeSource($request); par $noCreditDebit = $this->shouldExemptCreditDebit($request);.
  • generateVariants (gratuit pour tous, non lié au header) est inchangé.

Correction locale minimale, sans nouvelle infrastructure ni migration, alignée sur l'ordre cible B0 (autorisation/contexte privilégié avant quota).

7. Comportement Backoffice préservé

  • Un administrateur global opérant via le backoffice (X-App-Source: backoffice) conserve l'exemption de débit (test ai_b5_admin_global_via_backoffice_est_exempte_de_debit).
  • Les routes, payloads et réponses existants sont inchangés (aucune signature modifiée).
  • Changement de comportement assumé : un opérateur backoffice non-admin n'obtient plus l'exemption et consomme désormais son quota. C'est l'objet même du durcissement. À valider côté opérations : tous les comptes backoffice devant bénéficier de la gratuité doivent porter le rôle admin/platform_admin.

8. Tests

Ajoutés dans tests/Feature/AI/AITest.php (section SEC-P0-001B5) :

  1. ai_b5_spoof_x_app_source_backoffice_non_admin_consomme_le_quota — régression spoof (débit maintenu, credits_used=1, monthly_used=1).
  2. ai_b5_admin_global_via_backoffice_est_exempte_de_debit — backoffice admin exempté (credits_used=0).
  3. ai_b5_admin_global_hors_backoffice_consomme_le_quota — source backoffice nécessaire (admin sur dmv débité).
  4. ai_b5_appel_standard_non_admin_consomme_le_quota — débit normal d'un appel standard.
  5. ai_b5_no_credit_debit_non_controlable_par_payload_ou_query — aucune élévation via payload/query.
  6. ai_b5_spoof_backoffice_non_admin_sur_tags_consomme_le_quota — couverture d'une seconde route.
  7. ai_b5_route_admin_ia_refusee_pour_non_admin_meme_source_backoffice — surface admin verrouillée (403, aucun provider).

Suite complète : 590 tests / 590 verts ; AITest : 57/57.

9. Garde-fous

  • Invariant : noCreditDebit provient exclusivement d'une décision serveur (isAdmin() + contexte backoffice), jamais d'une donnée cliente.
  • Tests de non-régression explicites pour chaque spoof confirmé (header, payload, query).
  • isBackofficeSource() documentée comme non-autorisante pour prévenir toute réutilisation abusive.
  • Surface admin IA inchangée et couverte par un test dédié.

10. Dette B6 restante

  • Cache IA non actor-bound (AICacheService::buildKey sur promptKey + inputHash) : risque de partage inter-acteurs — traité en B6.
  • Unicité structurelle ai_user_quotas (quota personnel vs acteur) — hors périmètre, décision migration.
  • Façade applicative IA neutre (appels hors HTTP) : la logique d'exemption reste dans l'adaptateur HTTP AIController ; à réévaluer si un adaptateur non-HTTP apparaît (cf. B0 §10).
  • Traçabilité sécurité des tentatives de spoof : non ajoutée (audit log central hors périmètre).

Validation

  • API : git diff --check, php -l, php artisan route:list, php artisan test --filter=AITest (57/57), suite complète (590/590), vendor/bin/pint --test (passed).
  • Docs : git diff --check, npm run build.
  • Aucune migration, aucun changement de schéma, aucun frontend modifié, aucun commit créé.