Audits · août 2026Audit — pilier data-platform

Audit — pilier data-platform

Premier tour de la rotation trimestrielle (docs/team/roster.md, @codebase-auditor). Douze piliers n'avaient aucun audit au dossier ; data-platform est le premier dans l'ordre du roster. Lecture seule sur le code. Aucun…

Premier tour de la rotation trimestrielle (docs/team/roster.md, @codebase-auditor). Douze piliers n'avaient aucun audit au dossier ; data-platform est le premier dans l'ordre du roster.

Lecture seule sur le code. Aucun fichier de src/ ou prisma/ n'est modifie par cet audit : l'auditeur qui corrige devient juge et partie. Ce document et les trois items de backlog sont la sortie complete.

Perimetre : prisma/**, src/services/database/**, src/lib/core/**, src/lib/cache/**, src/lib/utils|format|severity|storage/**, src/app/api/admin/db/**, scripts/generate-schema-guard.mjs, scripts/marketplace-fts-index.mjs.

Etat du depot : origin/main a 49c783c7, 25 aout 2026.

Ce qui a ete verifie

SondeResultat
Relations tenant (storeId / orgId / userId) sans onDelete explicite0 sur 76 — 69 Cascade, 7 SetNull
Colonnes SetNull non nullables (incoherence qui casse la suppression)0 sur 7
Cles etrangeres sans index couvrant4 sur ~180
Paires d'index dont l'un est le prefixe de l'autre12
Tables citees dans CLAUDE.md absentes du schema0 sur 28
Modeles cites en prose dans CLAUDE.md et inexistants0
Instanciations de PrismaClient en production1 (le singleton, le reste est en specs d'integration)
/api/admin/db derriere une garde adminoui — withAdminRoute
pnpm deadcode (knip) sur le perimetresort 0 ; exports inutilises listes ci-dessous

Le schema est en bon etat. 147 modeles, 47 enums, et aucune relation tenant ne laisse son comportement de suppression au hasard : c'est le resultat le plus important de cet audit et il est bon. Les constats ci-dessous sont reels mais aucun n'est une urgence.


Constat 1 — le cache d'un membre retire n'est jamais invalide

Severite : moyenne. → backlog/data-platform/0047

invalidateOrgCache (src/lib/cache/me-cache.ts:101) liste les membres a purger en interrogeant la base :

prisma.organizationMember.findMany({ where: { orgId }, select: { userId: true } })

Les deux chemins qui retirent un membre suppriment la ligne avant d'appeler cette fonction :

CheminSequence
DELETE /api/organizations/[orgId]/members/[memberId]organizationMember.delete (l.134) → invalidateOrgCache (l.150)
POST /api/organizations/[orgId]/leaveorganizationMember.delete (l.58) → invalidateOrgCache (l.74)

Au moment de l'appel, l'utilisateur retire n'est plus dans le findMany. Sa cle me:<sha>:<userId> n'est donc jamais supprimee, et son /api/me continue de renvoyer l'organisation pendant la duree du TTL : 30 secondes (CACHE_TTL_SECONDS, src/app/api/me/route.ts:41).

C'est la forme classique du defaut : invalider en demandant a la base qui est concerne, apres avoir modifie la base. Le seul utilisateur qu'il faut purger est precisement celui que la requete ne trouve plus.

Ce que ce n'est pas. Pas une elevation de privilege. L'autorisation est re-derivee a chaque requete par getOrgAccess, pas lue depuis ce cache : un membre retire ne peut rien faire dans l'organisation. Ce qui persiste est le payload, son interface continue de lister l'org pendant 30 s.

Pourquoi ca merite quand meme un item. Le proprietaire est couvert (la fonction ajoute Organization.ownerId explicitement), ce qui montre que le cas « quelqu'un que le findMany ne renvoie pas » a deja ete rencontre et traite une fois : pour le proprietaire, pas pour le membre retire. La correction est du meme ordre : passer l'id concerne au lieu de le redemander.


Constat 2 — un fichier orphelin fabrique des secrets, et son nom masque le vrai

Severite : moyenne (risque latent, aucune exploitation actuelle). → backlog/data-platform/0048

src/lib/utils/id.ts n'est importe nulle part : aucun import dans src/, aucun barrel ne le re-exporte. Il definit trois fonctions, dont deux fabriquent des secrets.

Depuis : le fichier a ete supprime, donc ce nom n'est plus un lien. Le constat reste tel quel — c'est un compte rendu date, pas une page tenue a jour — et seul le renvoi mort a ete retire (platform-ops/0071).

Le probleme n'est pas qu'il soit mort. C'est que generateApiKey existe deux fois dans le depot, sous le meme nom, avec deux contrats opposes :

lib/utils/id.ts:11 (orphelin)lib/security/api-keys.ts:170 (le vrai)
SignaturegenerateApiKey(storeID)generateApiKey(type, entityId)
Retourune chaine nue{ key, keyHash, keyPrefix }
HashaucunhashApiKey(key)
Entropiecrypto.randomUUID()generateRandomBytes(24)
Testenonoui (api-keys.test.ts)
Exporte par lib/security/index.tsnonoui
Appelepersonneapi-keys.ts:227

Le vrai renvoie un hash parce que le secret en clair ne doit jamais etre stocke. L'orphelin renvoie la chaine seule : quiconque l'autocomplete et la persiste ecrit un secret en clair, sans qu'aucune revue ne voie passer un appel a hashApiKey.

generateWebhookSecret() a le meme defaut d'une autre facon : elle fabrique un whsec_<hex>, alors que dans ce depot les whsec_ sont fournis par Svix/Resend et seulement consommes (src/modules/email/webhook-signature.ts:72), ou ils sont decodes en base64. Un secret genere ici ne serait verifie par rien.

Du code mort est du poids. Du code mort qui ressemble a la facon sanctionnee de fabriquer une credential est un piege.


Constat 3 — douze index qui coutent des ecritures et ne servent rien

Severite : faible. → backlog/data-platform/0049

Un index sur [a] est entierement servi par un index sur [a, b] : le court ne repond a aucune requete que le long ne repond pas, et il est maintenu a chaque INSERT / UPDATE. Douze paires de ce type :

ModeleRedondantDeja couvert par
Message[conversationId][conversationId, createdAt]
AuditLog[orgId][orgId, createdAt]
Credit[orgId][orgId, expiresAt]
DevStorePool[status][status, transferRequestedAt] et [status, lastCheckedAt]
MarketplaceListing[sellerId][sellerId, status, publishedAt]
Offer[listingId, status][listingId, status, createdAt]
DealThread[sellerId], [buyerId][…, status, createdAt]
Order[buyerId], [listingId][…, status, …]
ProductPerformanceDaily[storeId, day][storeId, day, revenueCents]

Verifie colonne par colonne : dans les douze cas la colonne de tete est en ASC des deux cotes, les sort: Desc presents portent sur une colonne suivante, ce qui ne change pas la capacite du composite a servir une egalite sur la tete. La redondance tient.

Ou ca coute vraiment : Message et AuditLog sont des tables en append quasi continu.

Contrainte a connaitre avant d'ouvrir le sujet. Supprimer un index est du DDL destructif, et le schema guard ne fait jamais que de l'additif (schema-guard.ts, invariant declare). Ce n'est donc pas un changement qu'un deploy peut porter : c'est une migration operateur deliberee. C'est ce qui rend l'item petit en code et non trivial en execution.

Repli inclus dans le meme item : quatre cles etrangeres n'ont aucun index dont elles soient la colonne de tete, AffiliateRedemption.orgId, PhaseTransition.actorId, RoadmapVote.userId, StoreNote.fromReport. Aucune requete du depot ne filtre dessus ; le cout est uniquement sur le DELETE du parent (Postgres doit scanner la table enfant pour verifier la contrainte). PhaseTransition et RoadmapVote pendent a User, donc sur le chemin de /api/auth/delete-account. Meme famille, meme migration.


Releve, sans item

Deux constats reels que je n'ouvre pas, pour ne pas gonfler le backlog avec ce qui ne merite pas une PR :

  • Deux portes pour le meme module. db et createDatabase sont accessibles par @/services/database et par @/services/database/client (un barrel de 8 lignes). Les appelants se partagent les deux ; createDatabase re-exporte par client.ts n'est pris par personne. Aucune consequence fonctionnelle : a fusionner le jour ou quelqu'un touche ce fichier pour une autre raison.
  • knip.json porte un motif d'entree redondant pour prisma/seed.ts (deja couvert par un autre motif). Une ligne de configuration.

Ce que cet audit n'a pas couvert

Dit explicitement, pour que le prochain tour sache ou reprendre :

  • le comportement runtime du cache : visitor-stats.ts et kv.ts ont ete lus, pas exerces ; aucune sonde sur les TTL reels ni sur le comportement quand Redis est absent ;
  • prisma-provider.ts (602 lignes) — la plus grosse surface non generee du pilier, parcourue seulement pour verifier l'instanciation du client ;
  • les performances reelles — aucune mesure, aucun EXPLAIN. Les constats d'index sont structurels, pas mesures.

Prochain pilier de la rotation : security-identity. Il devient plus urgent qu'avant : le chantier Studio vient d'ajouter 11 permissions studio.* et 4 roles preconfigures, et personne n'a jamais audite si ces gardes tiennent, alors que trois personnes exterieures se connectent desormais a l'app avec.