ADRADR-0006 · Trois niveaux de preuve d'identite, et la fleche va du store vers l'org

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 tenaitCe que ca prouvait
Store.domainune chaine tapee dans un formulaire
IntegrationConnection.accessTokenquelqu'un detenait les droits admin de la boutique Shopify
KycVerificationun 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.

NiveauCe qui le produitCe qu'il autorise
declaredl'operateur a tape la valeur, ou l'a choisie dans un annuaire publicrien. Jamais un garde, jamais un rendu « verifie » hors de l'app
controlledune preuve cryptographique de controle de l'actif : HMAC OAuth Shopify, echange client_credentials, enregistrement DNS TXTagir sur l'actif, et parler pour lui
attestedun 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 :

  1. companyDeclaredAt (nouveau) enregistre la declaration. Tous les chemins utilisateur ecrivent celui-la, aucun n'ecrit l'autre.
  2. companyVerifiedAt n'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 efface domainVerifiedAt : un certificat qui survit a son sujet n'en est pas un.
  3. Le drapeau controlled par store, dans intelligence.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.
  4. Store.domain reste @unique et 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 ligne AuditLog des deux cotes.

Alternatives écartées

OptionPourquoi 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 uniqueDeux 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 verificationCes 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 prealableCasserait 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 prouveeFerait 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 :

  • companyVerifiedAt est 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 declared indefiniment. 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é

RegleCe qui la rend effective
Aucun chemin utilisateur n'ecrit companyVerifiedAtsrc/test/identity-trust-vocabulary.test.ts — scan statique de tout src/, un seul redacteur autorise
Les chemins utilisateur enregistrent bien la declarationmeme fichier : l'assertion inverse, pour qu'on ne « corrige » pas en supprimant la donnee
Confirmation et controle restent couplesorg-coherence-check.test.ts : meme page, meme SIREN, deux resultats selon controlled
Une preuve l'emporte, une preuve ne se fait pas volerlib/security/domain-claim.test.ts (11 cas), dont la transaction unique et les deux lignes d'audit
Un conflit de domaine se nommeapi/stores/[storeId]/route.test.ts : P2002 sur domain ne dit plus « Slug already taken »