SEC-P0-001B5 — AI backoffice authorization hardening
Scope
- Composant corrigé :
App\Modules\AI\Controllers\AIController, dérivation du flagnoCreditDebit. - 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).
| Route | Méthode | noCreditDebit (avant B5) | RBAC acteur |
|---|---|---|---|
/api/v1/ai/publication/generate | POST | isBackofficeSource() | manage_publications si actor_id |
/api/v1/ai/publication/improve | POST | isBackofficeSource() | manage_publications si actor_id |
/api/v1/ai/publication/variants | POST | true (toujours gratuit, tous utilisateurs) | manage_publications si actor_id |
/api/v1/ai/publication/tags | POST | isBackofficeSource() | aucun (actor_id absent) |
/api/v1/ai/service/generate | POST | isBackofficeSource() | manage_actor si actor_id |
/api/v1/ai/service/improve | POST | isBackofficeSource() | manage_actor si actor_id |
/api/v1/ai/onboarding/suggest | POST | isBackofficeSource() | manage_actor si actor_id |
/api/v1/ai/quota | GET | n/a | isOwner (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: sitrue, saute leAIQuotaService::check()(ligne ~97), forcecreditsConsumed = 0(ligne ~162), sauteBoostUsageService::consumeAssistantBoost()etAIQuotaService::consume()(lignes ~182-189). Effet : bypass complet du débit quota/crédit. - Origine avant B5 : dérivé du seul header
X-App-SourceviaisBackofficeSource(). 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énario | Verdict |
|---|---|
Utilisateur Workspace envoyant X-App-Source: backoffice | CONFIRMÉ (avant B5) → corrigé |
| Header falsifié → bypass quota | CONFIRMÉ (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/query | INFIRMÉ (jamais lu du client) → test de non-régression ajouté |
| Requête sans source reconnue traitée comme backoffice | INFIRMÉ (X-App-Source obligatoire, 400 sinon ; seule la valeur exacte backoffice déclenchait l'exemption) |
| Appel API direct hors frontend | CONFIRMÉ (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 parensure.adminetActorAccessService) 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 (testai_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) :
ai_b5_spoof_x_app_source_backoffice_non_admin_consomme_le_quota— régression spoof (débit maintenu,credits_used=1,monthly_used=1).ai_b5_admin_global_via_backoffice_est_exempte_de_debit— backoffice admin exempté (credits_used=0).ai_b5_admin_global_hors_backoffice_consomme_le_quota— source backoffice nécessaire (admin surdmvdébité).ai_b5_appel_standard_non_admin_consomme_le_quota— débit normal d'un appel standard.ai_b5_no_credit_debit_non_controlable_par_payload_ou_query— aucune élévation via payload/query.ai_b5_spoof_backoffice_non_admin_sur_tags_consomme_le_quota— couverture d'une seconde route.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 :
noCreditDebitprovient 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::buildKeysurpromptKey + 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éé.