ADR-0006 — Trois niveaux de preuve d'identite, et la fleche va du store vers l'org
Deux questions differentes se sont retrouvees derriere le meme mot.
Statut
Accepté · 2026-09-01
Piliers : security-identity, app-shell, integrations, marketplace, intelligence
Contexte
Deux questions differentes se sont retrouvees derriere le meme mot.
L'autorisation — « cet utilisateur peut-il lire cette ligne ? » — est
close et prouvable. Elle est resolue : une seule frontiere, l'appartenance
a l'organisation (Organization.ownerId ou une ligne
OrganizationMember), l'acces store en est derive
(getStoreAccess → getOrgAccess), le layout [orgSlug] scope sa
requete au demandeur, resolveRequestOrg prouve l'appartenance avant
d'honorer un ?orgId=, et tenant-route-coverage.test.ts echoue en CI
si une route sous [orgId] / [storeId] arrive sans garde. Il n'existe
que deux portes d'entree dans une org : la creer, ou accepter une
invitation dont seul le sha256 du jeton est en base.
L'identite — « cette organisation EST-ELLE la societe X ? » — n'est
pas decidable par du code, et c'est la que le vocabulaire a derape. Trois
mecanismes de force radicalement differente s'ecrivaient de la meme
facon, un timestamp …VerifiedAt :
| Ce qu'on tenait | Ce que ca prouvait |
|---|---|
Store.domain | une chaine tapee dans un formulaire |
IntegrationConnection.accessToken | quelqu'un detenait les droits admin de la boutique Shopify |
KycVerification | un tiers a regarde une piece d'identite |
Organization.companyVerifiedAt etait pose a new Date() des qu'une
charge utile company arrivait sur POST /api/organizations, source: "manual" comprise — valeur presente dans l'enum accepte et joignable en
postant la route directement, autocomplete ou pas. La colonne disait
« verifie » et signifiait « quelqu'un a tape une societe le jour J ».
Elle ne gardait rien, ce qui est la seule raison pour laquelle c'etait un
defaut latent et pas une porte ouverte : il suffisait d'un
if (org.companyVerifiedAt) devant un versement ou une annonce
marketplace pour qu'une identite auto-declaree devienne un privilege.
En parallele, Store.domain etait @unique a l'echelle de la plateforme
et attribue au premier arrive, sans preuve nulle part sur le chemin
d'ecriture. Un utilisateur pouvait enregistrer nike.com sur un store de
son org. Il n'y gagnait rien — aucun acces Shopify, aucune donnee — mais
le vrai marchand ne pouvait alors plus jamais enregistrer sa propre
adresse, et le chemin de connexion qui, lui, portait une vraie preuve
plantait sur la contrainte d'unicite au lieu de s'en servir.
Décision
Toute affirmation d'identite porte l'un de trois niveaux, nommes dans
src/lib/security/identity-trust.ts, et une preuve l'emporte sur une
declaration.
| Niveau | Ce qui le produit | Ce qu'il autorise |
|---|---|---|
declared | l'operateur a tape la valeur, ou l'a choisie dans un annuaire public | rien. Jamais un garde, jamais un rendu « verifie » hors de l'app |
controlled | une preuve cryptographique de controle de l'actif : HMAC OAuth Shopify, echange client_credentials, enregistrement DNS TXT | agir sur l'actif, et parler pour lui |
attested | un tiers independant a atteste l'identite elle-meme (Stripe Identity / Connect, KycVerification) | porter une revendication legale |
Et surtout : une organisation ne prouve pas son identite, elle en herite de ses stores. La fleche que l'intuition suggere — l'utilisateur verifie l'org, l'org verifie le store — n'existe pas : un utilisateur ne peut rien prouver par declaration. Ce qu'il peut prouver, c'est la possession d'un actif. La chaine reelle va donc dans l'autre sens :
user --(HMAC OAuth / client_credentials / DNS TXT)--> store
--(mentions legales publiees par l'exploitant)--> identite legale
--> org
Consequences directes, toutes appliquees :
companyDeclaredAt(nouveau) enregistre la declaration. Tous les chemins utilisateur ecrivent celui-la, aucun n'ecrit l'autre.companyVerifiedAtn'a plus qu'un seul redacteur : le balayage de coherence hebdomadaire, quand un store que l'org controle (domainVerifiedAt, ou une connexion Shopify active) publie sur ses propres pages legales le SIREN / SIRET / TVA declare. Une edition manuelle d'un champ societe l'efface, pour la meme raison qu'un changement de domaine effacedomainVerifiedAt: un certificat qui survit a son sujet n'en est pas un.- Le drapeau
controlledpar store, dansintelligence.org-coherence-check, est ce qui empeche la boucle de se refermer sur elle-meme : scraper un domaine non controle et croire ce qu'il raconte donnerait a un squatteur l'identite legale imprimee sur le site qu'il squatte. L'algorithme fabriquerait exactement la preuve qu'il existe pour exiger. Store.domainreste@uniqueet attribue au premier arrive par declaration, mais une revendication non prouvee cede devant une preuve (lib/security/domain-claim.ts). Une revendication prouvee ne cede jamais : deux preuves sur une meme vitrine est un cas support, pas quelque chose a trancher en laissant gagner le second appelant. Chaque transfert ecrit une ligneAuditLogdes deux cotes.
Alternatives écartées
| Option | Pourquoi non |
|---|---|
| Verifier l'identite legale a l'inscription (KYC pour tout le monde) | On ferait payer a chaque utilisateur le cout d'un controle dont la valeur n'existe que la ou de l'argent bouge. GitHub, Slack, Vercel et Stripe attribuent tous le nom d'espace au premier arrive. Le KYC reste ou il sert : marketplace, versements. |
Rendre Organization.siren unique | Deux orgs peuvent legitimement declarer le meme SIREN (holding, plusieurs espaces de travail d'une meme societe). L'unicite transformerait une donnee descriptive en verrou, et le premier a taper un SIREN interdirait a son propre comptable de creer un espace. |
| Considerer un hit d'annuaire (INSEE / VIES / OpenCorporates) comme une verification | Ces jeux de donnees sont publics : n'importe qui lit le SIREN de Nike. Le hit prouve que la societe existe, jamais que celui qui tape en fait partie. C'est exactement la confusion que cet ADR corrige. |
Laisser companyVerifiedAt tel quel puisque rien ne le gardait | « Rien ne le garde » est un etat, pas une propriete. Le defaut n'etait pas ce que la colonne faisait, c'est ce que le prochain lecteur allait en conclure — et le meme mot avait deja induit la meme erreur cote store (domain-trust-vocabulary.test.ts). |
| Interdire la saisie d'un domaine sans preuve prealable | Casserait l'onboarding : le marchand donne son adresse avant de savoir connecter quoi que ce soit, et la quasi-totalite des stores existants n'ont jamais publie d'enregistrement TXT. La declaration reste permise, elle est juste devenue perdable. |
| Laisser une preuve prendre un domaine a une autre revendication prouvee | Ferait de la connexion Shopify une arme : celui qui se connecte en dernier gagne. Refuser et escalader est la seule reponse qui ne cree pas de course. |
Conséquences
Ce que ca coute :
companyVerifiedAtest desormais null pour presque toutes les organisations, y compris celles qui affichaient une date hier. C'est voulu — la date d'hier ne certifiait rien — mais toute lecture qui comptait dessus voit son compte tomber. Aucune ne gardait quoi que ce soit, ce qui rend la bascule sans risque ; elle n'est pas sans effet visuel (la ligne « Derniere verification » des reglages disparait tant que le balayage n'a pas confirme).- La confirmation est hebdomadaire et best-effort. Elle depend d'un
scrape de page legale, donc d'un theme et d'une redaction qu'on ne
controle pas. Une org parfaitement legitime peut rester
declaredindefiniment. C'est acceptable tant que rien ne se garde dessus — et c'est precisement pour ca que rien ne doit s'y garder sans revenir ici. - Le vol de domaine par squat devient reparable, pas impossible. Un squatteur peut toujours bloquer une adresse tant que le vrai marchand n'a produit aucune preuve.
Le signal qui indiquerait de revisiter : le jour ou une fonctionnalite a
besoin de attested sur une organisation (versement direct a l'org,
facturation B2B engageante, revendication de marque sur la marketplace).
Ce jour-la, ce n'est pas ce document qu'on assouplit, c'est
KycVerification qu'on branche sur l'org.
Comment c'est appliqué
| Regle | Ce qui la rend effective |
|---|---|
Aucun chemin utilisateur n'ecrit companyVerifiedAt | src/test/identity-trust-vocabulary.test.ts — scan statique de tout src/, un seul redacteur autorise |
| Les chemins utilisateur enregistrent bien la declaration | meme fichier : l'assertion inverse, pour qu'on ne « corrige » pas en supprimant la donnee |
| Confirmation et controle restent couples | org-coherence-check.test.ts : meme page, meme SIREN, deux resultats selon controlled |
| Une preuve l'emporte, une preuve ne se fait pas voler | lib/security/domain-claim.test.ts (11 cas), dont la transaction unique et les deux lignes d'audit |
| Un conflit de domaine se nomme | api/stores/[storeId]/route.test.ts : P2002 sur domain ne dit plus « Slug already taken » |