ADRADR-0013 · Le metier de l'agence et la production creative sont deux namespaces de permissions

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 Organization de 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 :

  1. hasPermission (src/lib/security/permissions.ts) rendait true pour TOUTE permission des role === "owner" ;
  2. ROLE_DEFAULTS.owner reenumerait les douze studio.* 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.read et studio.gate.read existent pour que regarder n'exige pas de pouvoir agir. studio.economics.read reste le cout de production d'UNE marque ; la comparaison de marge entre clients est agency.economics.read.
  • delegate est un role connu a plancher vide. Il portera l'acteur mandate quand AccessGrant arrivera, et il ferme immediatement un fail-open : isKnownRole rabattait tout role inconnu sur viewer, donc une chaine libre en base accordait store.read + billing.read + members.read en silence.

Alternatives écartées

OptionPourquoi non
Borner le seul court-circuit ownerNo-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_IDserverEnv 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 dashboardDeja 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 marchandeC'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 stockespermissions.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 tout store.*, billing.*, members.*, settings.update, agent.*, ai.use, workspace.*, ~/studio/concepts et la totalite de [storeSlug]/studio. Aucune donnee n'est touchee.
  • setMemberPermissions accepte 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.
  • TenantRole et Role gagnent delegate. Quatre modules de l'orchestrateur redeclaraient l'union des quatre roles a la main : ils importent desormais TenantRole, ce qui a supprime quatre copies d'un meme vocabulaire.

La dette acceptee.

  • ~/studio/concepts reste 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-admin de features/studio/guard.ts n'est pas touche : tout User.role === "ADMIN" peut toujours lever un Go/No-Go et vendre une cadence sur n'importe quelle organisation, trace comme via: "platform-admin".
  • Une agency.* n'est encore accordable que par un admin plateforme, puisque setMemberPermissions est derriere requireAdmin. 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.

PorteAvantApres
studioActorFor (ecriture)getOrgAccessgetStoreAccess(..., { require })
studioAccessFor (lecture, org)getOrgAccess + test apres coup{ require } et le test conserve
lecture d'un ensemble sur une boutiquegetStudioPermissions(orgId, …)getStoreStudioPermissions(storeId, …)
[storeSlug]/studio/page.tsxwhere d'appartenanceslugs apparies + autorisateur avant tout chargeur

Trois choix qui ne se lisent pas dans le diff :

  • Les deux gardes gardent leur hasPermission explicite apres avoir passe require. Deleguer entierement la decision au callee marchait ; le jour ou quelqu'un retire le require en 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. Le deny du 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.

MecanismeOu
L'exception au court-circuithasPermission, src/lib/security/permissions.ts
L'absence de la baselineROLE_DEFAULTS.owner, meme fichier
La constante partagee par les deuxAGENCY_PERMISSIONS, meme fichier
La traduction des ids d'avantreadPermissionOverride → LEGACY_PERMISSION_ALIASES
L'amorcageGET/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 chaque agency.* 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, un agency.* 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 all et delegate 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.