ADR-0013 — Le metier de l'agence et la production creative sont deux namespaces de permissions
Precise l'ADR 0004, ne le revoque pas. L'agence reste une Organization de la plateforme, operee par ses membres, sans back-office parallele. Ce que ce document change est la frontiere INTERNE de son Studio.
Statut
Accepté · 2026-09-12
Piliers : security-identity, app-shell, ai-platform
Precise l'ADR 0004, ne le revoque pas. L'agence reste une
Organizationde la plateforme, operee par ses membres, sans back-office parallele. Ce que ce document change est la frontiere INTERNE de son Studio.
Contexte
L'ADR 0004 a pose une regle qui a bien tenu : « si une fonctionnalite n'a
de sens que pour l'agence, elle est une permission, pas une branche de
code ». Douze permissions studio.* ont ete creees dans cet esprit, et
trois portes (les pages, les outils @Atlas, la route REST) les lisent par
un resolveur unique.
Le defaut n'est pas dans cette regle. Il est dans le fait que les douze permissions ont ete traitees comme une seule famille, alors qu'elles recouvrent deux metiers differents :
- prospecter des marques, decider si on produit pour elles, rendre un verdict de production, vendre une cadence, comparer la marge par client — c'est ce que BoostEcom FAIT ;
- ecrire un concept, generer une creative, voir sa file de revue, lire ses livraisons, lire le cout de sa propre production — c'est ce que BoostEcom VEND.
Deux mecanismes indépendants faisaient que la premiere famille etait livree a tout le monde :
hasPermission(src/lib/security/permissions.ts) rendaittruepour TOUTE permission desrole === "owner";ROLE_DEFAULTS.ownerreenumerait les douzestudio.*en dur.
Et getOrgAccess (tenant-auth.ts) synthetise role: "owner" depuis
Organization.ownerId, sans exiger de ligne d'adhesion. Consequence
mesurable : tout utilisateur ayant clique « creer une organisation »
detenait le pipeline de prospection commerciale, les gates Go/No-Go et le
cockpit de marge par marque de sa propre organisation, et voyait l'onglet
Studio dans sa navigation.
Un second defaut, du meme geste : la surface MARCHANDE
(/[orgSlug]/[storeSlug]/studio) etait gatee sur studio.qc.review pour
AFFICHER la file de revue et sur studio.drop.publish pour afficher les
livraisons. Voir exigeait donc de pouvoir juger, et de pouvoir publier.
Personne ne s'en apercevait parce que les marchands tenaient les deux par
le court-circuit ; mais aucun reviewer, aucun prestataire, aucune marque
ne pouvait REGARDER sans pouvoir TOUT FAIRE.
Décision
Les permissions du process creatif se separent en deux namespaces :
agency.* pour le metier de la plateforme, studio.* pour la production
qu'elle vend. Les agency.* sont exclues du court-circuit owner ET
absentes de toute baseline de role, y compris celle du proprietaire.
Sept agency.* : prospect.read, prospect.manage, client.read,
client.manage, qc.review, cadence.manage, economics.read.
Elles s'obtiennent par un allow explicite sur la ligne d'adhesion, ou
par un preset de role metier. Jamais par la propriete d'une organisation.
Deux corollaires livres avec :
- Les paires lecture/ecriture sont separees cote produit :
studio.qc.read,studio.drop.readetstudio.gate.readexistent pour que regarder n'exige pas de pouvoir agir.studio.economics.readreste le cout de production d'UNE marque ; la comparaison de marge entre clients estagency.economics.read. delegateest un role connu a plancher vide. Il portera l'acteur mandate quandAccessGrantarrivera, et il ferme immediatement un fail-open :isKnownRolerabattait tout role inconnu surviewer, donc une chaine libre en base accordaitstore.read+billing.read+members.readen silence.
Alternatives écartées
| Option | Pourquoi non |
|---|---|
| Borner le seul court-circuit owner | No-op silencieux. Un owner prive du court-circuit retombe a l'etape 5 du resolveur et recupere la permission par ROLE_DEFAULTS.owner, ou les douze ids etaient reenumeres en dur. C'est le piege principal de ce changement, et la raison pour laquelle AGENCY_PERMISSIONS est une constante partagee par les deux lecteurs plutot que deux listes |
Un flag Organization.kind = "agency" | Circulaire : si l'agence est « une org qui fait le metier », tout owner se declare agence. Et une colonne declarative derive de la realite. Le signal employe (EXISTS Prospect) est DERIVE, il ne peut pas mentir |
Une variable d'env PLATFORM_ORG_ID | serverEnv est fail-soft sur "" : la variable absente en preview changerait silencieusement le comportement d'autorisation. Une garde qui depend d'une variable d'environnement non posee est une garde ouverte |
Un espace /agency separe du dashboard | Deja ecarte par l'ADR 0004, et pour la meme raison : ca duplique la navigation, l'auth et le scoping tenant, et ca fait cesser l'agence d'etre utilisatrice du produit. La frontiere est une permission, pas une route |
| Supprimer les sections agence de la surface marchande | C'etait le plan initial, et lire le code l'a invalide. La carte de gate et la carte d'economie marchandes sont des fonctionnalites CLIENT deliberees, documentees dans leur propre en-tete : la marque a le droit de lire sa gate (savoir quelles conditions manquent est ce qui lui permet de les fournir) et le cout de sa production. Les amputer aurait ete une regression vendue comme une separation. Elles ont donc gagne leur propre permission de lecture |
| Un script de migration renommant les ids stockes | permissions.allow / deny sont des string[] non valides a la lecture : renommer sans traduire rend une permission silencieusement fausse (includes() ne refuse jamais, il cesse de matcher). La traduction se fait a la lecture (LEGACY_PERMISSION_ALIASES dans readPermissionOverride), donc aucune base n'est touchee pour deployer, et un retour arriere ne laisse pas de lignes reecrites derriere lui |
Conséquences
Ce que ca coute.
- Un owner d'organisation perd
~/studio/{pipeline,clients,cockpit}, la board QC agence et la vente de cadence. Il conserve toutstore.*,billing.*,members.*,settings.update,agent.*,ai.use,workspace.*,~/studio/conceptset la totalite de[storeSlug]/studio. Aucune donnee n'est touchee. setMemberPermissionsaccepte desormais d'ecrire sur une ligne owner. Il le refusait au motif que « les permissions d'un owner sont implicites » — vrai tant que le court-circuit couvrait tout, et desormais faux : sans cette ecriture, l'organisation qui fait le metier n'aurait aucun porteur possible pour son propre metier.- Deux nouvelles permissions de lecture cote produit, plus
studio.gate.read. Trois entrees de plus dans la matrice admin. TenantRoleetRolegagnentdelegate. Quatre modules de l'orchestrateur redeclaraient l'union des quatre roles a la main : ils importent desormaisTenantRole, ce qui a supprime quatre copies d'un meme vocabulaire.
La dette acceptee.
~/studio/conceptsreste la meme page sur les memes chargeurs pour l'agence et pour le marchand, scopee a l'organisation. Un membre d'une org agence lit donc les concepts de toutes ses marques. Borner cela a une marque demande un scope par store, qui n'existe pas encore.- Le repli
platform-admindefeatures/studio/guard.tsn'est pas touche : toutUser.role === "ADMIN"peut toujours lever un Go/No-Go et vendre une cadence sur n'importe quelle organisation, trace commevia: "platform-admin". - Une
agency.*n'est encore accordable que par un admin plateforme, puisquesetMemberPermissionsest derriererequireAdmin. C'est le manque que la suite doit combler, et il est juridique avant d'etre ergonomique : l'autorisation ecrite prealable d'un responsable de traitement ne peut pas etre donnee par un tiers.
Suite : le mandat (phase 3, livree)
AccessGrant est la troisieme source d'identite du meme resolveur —
(sujet, presets, portee, fenetre), et jamais un second resolveur : un
mandat alimente hasPermission par la meme forme PermissionOverride.
La regle qui le rend sur : un mandat n'est jamais lu par defaut, mais
seulement par un appelant qui NOMME la permission qu'il exige. Plus de
quarante des ~94 appelants non-test de getStoreAccess ne consultent
jamais hasPermission et traitent un retour non-null comme
l'autorisation ; une source lue par defaut leur aurait ouvert d'un coup
les transcripts @Atlas, la memoire store, le ledger et les callbacks OAuth
des connecteurs. getOrgAccess et getStoreAccess gagnent donc un
{ require } optionnel, et sans lui rendent bit-a-bit ce qu'ils rendaient
avant.
Quatre portes migrent ensemble, et c'est deliberе : migrer l'ecriture sans les lectures aurait produit un mandataire capable d'APPROUVER une creative sans pouvoir afficher la file qui la contient, soit l'inverse exact de ce qui est vendable.
| Porte | Avant | Apres |
|---|---|---|
studioActorFor (ecriture) | getOrgAccess | getStoreAccess(..., { require }) |
studioAccessFor (lecture, org) | getOrgAccess + test apres coup | { require } et le test conserve |
| lecture d'un ensemble sur une boutique | getStudioPermissions(orgId, …) | getStoreStudioPermissions(storeId, …) |
[storeSlug]/studio/page.tsx | where d'appartenance | slugs apparies + autorisateur avant tout chargeur |
Trois choix qui ne se lisent pas dans le diff :
- Les deux gardes gardent leur
hasPermissionexplicite apres avoir passerequire. Deleguer entierement la decision au callee marchait ; le jour ou quelqu'un retire lerequireen resolvant un conflit, la porte cesserait de verifier quoi que ce soit. Le controle explicite coute une comparaison en memoire et rend cette suppression visible. - Le mandat est purement ADDITIF, sans
deny(le choix d'Azure, d'AWS et de Salesforce) : deux couches de soustraction concurrentes n'ont pas de precedence defendable. Ledenydu membre reste donc la derniere soustraction, et il bat un mandat — sinon retirer un acte a quelqu'un se contournerait en lui en emettant un. - Aucun cache au-dela de
React.cache. Une permission mise en cache est une revocation qui n'existe pas ; borner a la requete fait qu'une revocation prend effet a la suivante.
Ce que la phase 3 ne fait PAS : l'octroi reste admin-plateforme, parce que
/api/me livre encore a tout membre l'abonnement, le solde et le roster
avec les e-mails. Une console d'octroi sans projection scopee est une
fuite avec un bouton.
Le signal qui indiquerait de revisiter. Si une organisation cliente
demande legitimement une des sept agency.* — par exemple un marchand
qui veut voir la marge que nous faisons sur lui —, alors la permission a
ete rangee du mauvais cote et c'est le NAMESPACE qui doit bouger, pas
l'attribution au cas par cas.
Comment c'est appliqué
Pas une regle en prose : quatre points de code et des gardes derivees.
| Mecanisme | Ou |
|---|---|
| L'exception au court-circuit | hasPermission, src/lib/security/permissions.ts |
| L'absence de la baseline | ROLE_DEFAULTS.owner, meme fichier |
| La constante partagee par les deux | AGENCY_PERMISSIONS, meme fichier |
| La traduction des ids d'avant | readPermissionOverride → LEGACY_PERMISSION_ALIASES |
| L'amorcage | GET/POST /api/admin/access/agency-bootstrap, dry-run obligatoire, idempotent, signal EXISTS (Prospect) |
Les gardes qui empechent la frontiere de se refermer, toutes dans
src/lib/security/permissions.test.ts sauf mention :
NOT EVEN THE OWNER holds one— pour chaqueagency.*du registre.no role baseline carries one — owner's included— l'autre moitie du verrou.the registry's agency namespace and AGENCY_PERMISSIONS are the same set— sans elle, unagency.*ajoute au registre mais oublie dans la constante serait recupere par le proprietaire via la baseline, sans qu'aucune autre assertion ne bouge.the owner short-circuit still covers every non-agency permission— la scission ne doit rien affaiblir ailleurs.an unknown role holds nothing at alletdelegate is a KNOWN role, and its baseline is empty— le fail-open ferme, verifie sur chaque entree du registre.asks for no agency permission(src/test/store-studio-conventions.test.ts) — la surface marchande ne peut pas redevenir une surface d'agence par glissement.
Une remarque sur la garde qui existait deja. Le bloc qui epinglait « the org owner holds all of them » derivait son univers de
id.startsWith("studio."). Au moment de la scission, les sept ids renommes en seraient donc SORTIS sans bruit — emportant l'assertion qui mesurait le court-circuit, exactement la ou il bougeait. Le test serait reste vert en cessant de servir. C'est le mode de defaillance des gardes a univers derive, et la raison pour laquelle les deux namespaces sont maintenant derives separement, le second asserte a l'inverse du premier.