Faire poser le rôle applicatif par l'application elle-même

`db-init` suppose un orchestrateur qui honore
`depends_on: service_completed_successfully`. Swarm l'ignore, et une pile
déployée avant l'ajout du service ne le contient même pas. L'application
redémarrait alors en boucle sur un refus d'authentification que le
diagnostic ajouté précédemment décrivait sans que personne puisse le
corriger.

Elle le corrige donc elle-même au démarrage, si on lui confie les
identifiants d'amorçage — et se tait sinon, pour ne pas contrarier un
déploiement qui préfère les garder hors du conteneur applicatif. Le point
d'entrée retire ces variables avant de lancer le serveur : le processus qui
sert les requêtes ne les voit jamais.

Le branchement create/alter se fait côté client et non dans un bloc `DO` :
le corps d'un `DO` est une chaîne littérale, où `$1` n'est pas un paramètre
de requête. L'échappement du mot de passe est confié à `quote_literal`, et
le nom de rôle est refusé s'il n'a pas la forme d'un identifiant.

Éprouvé sous authentification scram réelle : après réalignement, l'ancien
mot de passe est refusé et le nouveau accepté, le rôle reste NOSUPERUSER
NOBYPASSRLS et devient propriétaire de la base.

Au passage, `withTenant` pose un budget de transaction explicite. Tout accès
aux données passe par lui, si bien que le défaut Prisma de 5 s plafonnait en
réalité chaque requête de l'application, et l'échec se présentait en `P2028`
qui ne désigne ni la requête ni la cause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-09 18:32:38 +00:00
1 parent 8c6e2ad52a
commit 49618c57bf
5 files changed
+178 -19

No files matched your search

+28 -7
View File
@@ -106,6 +106,24 @@ function buildScopedClient(accountId: string) {
});
}
/**
* Budget d'une transaction de compte.
*
* Prisma applique 5 s par défaut, sans le dire. Comme **tout** accès aux
* données passe par `withTenant`, ce défaut plafonne en réalité chaque requête
* de l'application : une semaine de planning chargée sur un serveur occupé
* échoue alors sur un `P2028` qui ne désigne ni la requête ni la cause.
*
* La valeur est donc posée ici, visible et discutable, plutôt que subie. Elle
* reste un plafond : une transaction qui l'atteint est un défaut à corriger,
* pas une lenteur à tolérer — mais elle doit échouer parce qu'elle est trop
* lente, pas parce que la machine était chargée pendant deux secondes.
*/
const TRANSACTION_BUDGET_MS = 20_000;
/** Attente maximale d'une connexion libre avant de renoncer. */
const CONNECTION_WAIT_MS = 10_000;
/**
* Exécute `fn` dans une transaction portant le compte courant.
*
@@ -121,14 +139,17 @@ export async function withTenant<T>(
// qu'elle fournit porte l'extension et partage la connexion sur laquelle
// `set_config` est posé. L'inverse — étendre le `tx` — n'est pas possible :
// Prisma retire `$extends` du client de transaction.
return scopedClientFor(accountId).$transaction(async (tx) => {
// `set_config(..., true)` est local à la transaction, donc remis à zéro
// automatiquement. Une connexion rendue au pool ne garde pas le compte
// précédent — ce serait la pire fuite possible.
await tx.$executeRaw`SELECT set_config('app.account_id', ${accountId}, true)`;
return scopedClientFor(accountId).$transaction(
async (tx) => {
// `set_config(..., true)` est local à la transaction, donc remis à zéro
// automatiquement. Une connexion rendue au pool ne garde pas le compte
// précédent — ce serait la pire fuite possible.
await tx.$executeRaw`SELECT set_config('app.account_id', ${accountId}, true)`;
return fn(tx as unknown as ScopedClient) as Promise<T>;
});
return fn(tx as unknown as ScopedClient) as Promise<T>;
},
{ timeout: TRANSACTION_BUDGET_MS, maxWait: CONNECTION_WAIT_MS },
);
}
/**