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:
5 files changed
+178
-19
No files matched your search
@@ -52,6 +52,7 @@ COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
|
|||||||
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
COPY --from=build --chown=nextjs:nodejs /app/public ./public
|
COPY --from=build --chown=nextjs:nodejs /app/public ./public
|
||||||
COPY --chown=nextjs:nodejs docker/entrypoint.sh ./docker/entrypoint.sh
|
COPY --chown=nextjs:nodejs docker/entrypoint.sh ./docker/entrypoint.sh
|
||||||
|
COPY --chown=nextjs:nodejs docker/bootstrap-role.mjs ./docker/bootstrap-role.mjs
|
||||||
|
|
||||||
# Créé dans l'image, et non laissé au montage : un volume nommé hérite du
|
# Créé dans l'image, et non laissé au montage : un volume nommé hérite du
|
||||||
# propriétaire du répertoire qu'il recouvre, et sans cela l'application —
|
# propriétaire du répertoire qu'il recouvre, et sans cela l'application —
|
||||||
|
|||||||
+10
-1
@@ -105,8 +105,17 @@ services:
|
|||||||
# salarié écrites dans la couche du conteneur disparaîtraient au premier
|
# salarié écrites dans la couche du conteneur disparaîtraient au premier
|
||||||
# redéploiement, et une pièce d'identité perdue ne se reconstitue pas.
|
# redéploiement, et une pièce d'identité perdue ne se reconstitue pas.
|
||||||
DOCUMENT_STORE: /data/documents
|
DOCUMENT_STORE: /data/documents
|
||||||
# Sert uniquement au diagnostic affiché quand la connexion est refusée.
|
# Amorçage du rôle applicatif depuis le conteneur, pour les
|
||||||
|
# orchestrateurs qui n'honorent pas `depends_on` — Swarm, notamment — et
|
||||||
|
# pour les piles déployées avant l'ajout de `db-init`. Le point d'entrée
|
||||||
|
# retire ces variables avant de lancer le serveur : le processus qui sert
|
||||||
|
# les requêtes ne les voit jamais.
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-planflow}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-planflow-interne}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-planflow}
|
||||||
|
POSTGRES_HOST: db
|
||||||
APP_DB_USER: ${APP_DB_USER:-planflow_app}
|
APP_DB_USER: ${APP_DB_USER:-planflow_app}
|
||||||
|
APP_DB_PASSWORD: ${APP_DB_PASSWORD:-planflow-app-interne}
|
||||||
volumes:
|
volumes:
|
||||||
- documents:/data
|
- documents:/data
|
||||||
# Volume distinct de la base et des documents : sauvegarder l'un ne doit
|
# Volume distinct de la base et des documents : sauvegarder l'un ne doit
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Pose le rôle applicatif depuis le conteneur de l'application.
|
||||||
|
*
|
||||||
|
* Le service `db-init` fait déjà ce travail, mais il suppose un orchestrateur
|
||||||
|
* qui honore `depends_on: service_completed_successfully` — ce que Swarm ignore,
|
||||||
|
* et ce qu'une pile déployée avant l'ajout du service ne contient même pas.
|
||||||
|
* L'application se retrouve alors à redémarrer en boucle sur un refus
|
||||||
|
* d'authentification que personne ne peut corriger sans intervenir à la main.
|
||||||
|
*
|
||||||
|
* Elle le corrige donc elle-même, **si** on lui confie de quoi le faire. Sans
|
||||||
|
* `POSTGRES_PASSWORD`, ce script ne fait rien et se tait : un déploiement qui
|
||||||
|
* préfère garder les identifiants d'amorçage hors du conteneur applicatif reste
|
||||||
|
* libre de le faire.
|
||||||
|
*
|
||||||
|
* Le point d'entrée retire ces variables de l'environnement avant de lancer le
|
||||||
|
* serveur : le processus qui sert les requêtes ne les voit jamais, et une
|
||||||
|
* exécution de code arbitraire dans l'application n'y donne pas accès.
|
||||||
|
*/
|
||||||
|
import { Client } from 'pg';
|
||||||
|
|
||||||
|
const superUser = process.env.POSTGRES_USER ?? 'planflow';
|
||||||
|
const superPassword = process.env.POSTGRES_PASSWORD ?? '';
|
||||||
|
const database = process.env.POSTGRES_DB ?? 'planflow';
|
||||||
|
const host = process.env.POSTGRES_HOST ?? 'db';
|
||||||
|
const port = Number(process.env.POSTGRES_PORT_INTERNAL ?? 5432);
|
||||||
|
|
||||||
|
const appRole = process.env.APP_DB_USER ?? 'planflow_app';
|
||||||
|
const appPassword = process.env.APP_DB_PASSWORD ?? 'planflow-app-interne';
|
||||||
|
|
||||||
|
if (!superPassword) {
|
||||||
|
console.log(
|
||||||
|
'[bootstrap] Aucun identifiant d’amorçage fourni : le rôle applicatif est supposé déjà en place.',
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un identifiant ne se met pas entre guillemets simples comme une valeur.
|
||||||
|
* `quote_ident` n'étant pas disponible côté client, on refuse ce qui n'a pas la
|
||||||
|
* forme d'un identifiant plutôt que de fabriquer une injection.
|
||||||
|
*/
|
||||||
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(appRole)) {
|
||||||
|
console.error(`[bootstrap] Nom de rôle invalide : ${appRole}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
user: superUser,
|
||||||
|
password: superPassword,
|
||||||
|
database,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Le branchement se fait ici, pas dans un bloc `DO` : à l'intérieur d'un
|
||||||
|
// `DO`, le corps est une **chaîne littérale**, et `$1` n'y est pas un
|
||||||
|
// paramètre de requête — le serveur répond « bind message supplies 1
|
||||||
|
// parameters, but prepared statement requires 0 ».
|
||||||
|
const existing = await client.query(
|
||||||
|
'SELECT 1 FROM pg_roles WHERE rolname = $1',
|
||||||
|
[appRole],
|
||||||
|
);
|
||||||
|
|
||||||
|
// L'échappement est confié à PostgreSQL : un mot de passe peut contenir une
|
||||||
|
// apostrophe, et la concaténer à la main serait une injection.
|
||||||
|
const quoted = await client.query('SELECT quote_literal($1) AS literal', [
|
||||||
|
appPassword,
|
||||||
|
]);
|
||||||
|
const literal = quoted.rows[0].literal;
|
||||||
|
|
||||||
|
const attributes = 'NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS';
|
||||||
|
await client.query(
|
||||||
|
existing.rowCount === 0
|
||||||
|
? `CREATE ROLE ${appRole} LOGIN PASSWORD ${literal} ${attributes}`
|
||||||
|
: `ALTER ROLE ${appRole} WITH LOGIN PASSWORD ${literal} ${attributes}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(`ALTER DATABASE "${database}" OWNER TO ${appRole}`);
|
||||||
|
await client.query(`ALTER SCHEMA public OWNER TO ${appRole}`);
|
||||||
|
await client.query(`GRANT ALL ON SCHEMA public TO ${appRole}`);
|
||||||
|
|
||||||
|
// Objets créés par le compte d'amorçage lors d'un déploiement antérieur :
|
||||||
|
// sans ce transfert, le rôle applicatif ne pourrait ni migrer ni lire.
|
||||||
|
await client.query(`
|
||||||
|
DO $own$
|
||||||
|
DECLARE statement text;
|
||||||
|
BEGIN
|
||||||
|
FOR statement IN
|
||||||
|
SELECT format('ALTER TABLE %I.%I OWNER TO ${appRole}', schemaname, tablename)
|
||||||
|
FROM pg_tables WHERE schemaname = 'public'
|
||||||
|
UNION ALL
|
||||||
|
SELECT format('ALTER SEQUENCE %I.%I OWNER TO ${appRole}', sequence_schema, sequence_name)
|
||||||
|
FROM information_schema.sequences WHERE sequence_schema = 'public'
|
||||||
|
LOOP
|
||||||
|
EXECUTE statement;
|
||||||
|
END LOOP;
|
||||||
|
END
|
||||||
|
$own$;`);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[bootstrap] Rôle ${appRole} prêt — NOSUPERUSER NOBYPASSRLS, propriétaire de ${database}.`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Non bloquant : le rôle est peut-être déjà correct et posé par `db-init`.
|
||||||
|
// Faire échouer le démarrage ici priverait d'une installation qui marche.
|
||||||
|
console.error(
|
||||||
|
`[bootstrap] Provisionnement impossible (${error instanceof Error ? error.message : String(error)}).`,
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
'[bootstrap] La suite dira si le rôle applicatif est utilisable en l’état.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
+22
-11
@@ -49,6 +49,16 @@ fi
|
|||||||
|
|
||||||
export ENCRYPTION_KEY
|
export ENCRYPTION_KEY
|
||||||
|
|
||||||
|
# Pose ou réaligne le rôle applicatif, si les identifiants d'amorçage sont
|
||||||
|
# fournis. Sans eux, ce script se tait : c'est alors `db-init` qui s'en charge.
|
||||||
|
node "${BOOTSTRAP_SCRIPT:-./docker/bootstrap-role.mjs}" || true
|
||||||
|
|
||||||
|
# Les identifiants d'amorçage ne vont pas plus loin : le serveur qui traite les
|
||||||
|
# requêtes ne doit pas les avoir sous la main. Une exécution de code arbitraire
|
||||||
|
# dans l'application n'y donnera pas accès, et l'isolation par la row-level
|
||||||
|
# security garde son sens.
|
||||||
|
unset POSTGRES_PASSWORD PGPASSWORD
|
||||||
|
|
||||||
# Les migrations s'appliquent au démarrage : l'image se déploie sans étape
|
# Les migrations s'appliquent au démarrage : l'image se déploie sans étape
|
||||||
# séparée.
|
# séparée.
|
||||||
#
|
#
|
||||||
@@ -62,15 +72,15 @@ if ! ( cd "$MIGRATOR_DIR" && node node_modules/prisma/build/index.js migrate dep
|
|||||||
>"$migration_log" 2>&1; then
|
>"$migration_log" 2>&1; then
|
||||||
cat "$migration_log"
|
cat "$migration_log"
|
||||||
|
|
||||||
# Deux codes, deux causes voisines, même remède :
|
# Deux codes, deux causes voisines :
|
||||||
# P1010 — le rôle n'existe pas ;
|
# P1010 — le rôle n'existe pas ;
|
||||||
# P1000 — il existe, mais son mot de passe ne correspond pas à celui de la
|
# P1000 — il existe, mais son mot de passe ne correspond pas à celui de la
|
||||||
# pile, typiquement parce qu'il a été posé lors d'un déploiement
|
# pile, typiquement parce qu'il a été posé lors d'un déploiement
|
||||||
# antérieur avec une autre valeur.
|
# antérieur avec une autre valeur.
|
||||||
#
|
#
|
||||||
# Dans les deux cas c'est `db-init` qui remet les choses d'aplomb : il crée le
|
# Les voir **ici** signifie que l'amorçage ci-dessus n'a pas fait son travail :
|
||||||
# rôle ou réaligne son mot de passe. Sans ce message, l'application redémarre
|
# soit il n'a pas reçu d'identifiants, soit il a échoué. Le message renvoie
|
||||||
# en boucle sur une erreur qui n'indique rien à faire.
|
# donc à ce qu'il a journalisé, et non à une manœuvre à improviser.
|
||||||
if grep -qE 'P1000|P1010' "$migration_log"; then
|
if grep -qE 'P1000|P1010' "$migration_log"; then
|
||||||
role="${APP_DB_USER:-planflow_app}"
|
role="${APP_DB_USER:-planflow_app}"
|
||||||
echo ''
|
echo ''
|
||||||
@@ -83,15 +93,16 @@ if ! ( cd "$MIGRATOR_DIR" && node node_modules/prisma/build/index.js migrate dep
|
|||||||
echo " d'un déploiement antérieur, avec une autre valeur."
|
echo " d'un déploiement antérieur, avec une autre valeur."
|
||||||
fi
|
fi
|
||||||
echo ''
|
echo ''
|
||||||
echo ' Le service `db-init` crée ce rôle et réaligne son mot de passe à'
|
echo " L'application sait poser ce rôle elle-même au démarrage. Les lignes"
|
||||||
echo " chaque démarrage. Vérifiez qu'il figure bien dans la pile, et ce"
|
echo ' « [bootstrap] » plus haut disent pourquoi elle ne l’a pas fait :'
|
||||||
echo " qu'il a journalisé :"
|
|
||||||
echo ''
|
echo ''
|
||||||
echo ' docker compose logs db-init'
|
echo " • « Aucun identifiant d’amorçage fourni » — le service applicatif"
|
||||||
echo ' docker compose run --rm db-init'
|
echo ' n’a pas POSTGRES_PASSWORD. Ajoutez-le, ou lancez :'
|
||||||
|
echo ' docker compose run --rm db-init'
|
||||||
echo ''
|
echo ''
|
||||||
echo " La seconde commande le rejoue : il est fait pour être exécuté"
|
echo ' • « Provisionnement impossible » — le message qui suit indique'
|
||||||
echo " autant de fois qu'il le faut."
|
echo ' ce qui a échoué (base injoignable, mot de passe d’amorçage'
|
||||||
|
echo ' erroné, droits insuffisants).'
|
||||||
echo '════════════════════════════════════════════════════════════════'
|
echo '════════════════════════════════════════════════════════════════'
|
||||||
echo ''
|
echo ''
|
||||||
fi
|
fi
|
||||||
|
|||||||
+28
-7
@@ -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.
|
* 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
|
// 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 :
|
// `set_config` est posé. L'inverse — étendre le `tx` — n'est pas possible :
|
||||||
// Prisma retire `$extends` du client de transaction.
|
// Prisma retire `$extends` du client de transaction.
|
||||||
return scopedClientFor(accountId).$transaction(async (tx) => {
|
return scopedClientFor(accountId).$transaction(
|
||||||
// `set_config(..., true)` est local à la transaction, donc remis à zéro
|
async (tx) => {
|
||||||
// automatiquement. Une connexion rendue au pool ne garde pas le compte
|
// `set_config(..., true)` est local à la transaction, donc remis à zéro
|
||||||
// précédent — ce serait la pire fuite possible.
|
// automatiquement. Une connexion rendue au pool ne garde pas le compte
|
||||||
await tx.$executeRaw`SELECT set_config('app.account_id', ${accountId}, true)`;
|
// 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 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user