diff --git a/Dockerfile b/Dockerfile index b2bc490..4f83720 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/public ./public 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 # propriétaire du répertoire qu'il recouvre, et sans cela l'application — diff --git a/docker-compose.yml b/docker-compose.yml index fcba5cd..01ce1a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -105,8 +105,17 @@ services: # salarié écrites dans la couche du conteneur disparaîtraient au premier # redéploiement, et une pièce d'identité perdue ne se reconstitue pas. 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_PASSWORD: ${APP_DB_PASSWORD:-planflow-app-interne} volumes: - documents:/data # Volume distinct de la base et des documents : sauvegarder l'un ne doit diff --git a/docker/bootstrap-role.mjs b/docker/bootstrap-role.mjs new file mode 100644 index 0000000..810f6cb --- /dev/null +++ b/docker/bootstrap-role.mjs @@ -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); +} diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 24d5b4b..0e3795b 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -49,6 +49,16 @@ fi 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 # 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 cat "$migration_log" - # Deux codes, deux causes voisines, même remède : + # Deux codes, deux causes voisines : # P1010 — le rôle n'existe pas ; # 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 # antérieur avec une autre valeur. # - # Dans les deux cas c'est `db-init` qui remet les choses d'aplomb : il crée le - # rôle ou réaligne son mot de passe. Sans ce message, l'application redémarre - # en boucle sur une erreur qui n'indique rien à faire. + # Les voir **ici** signifie que l'amorçage ci-dessus n'a pas fait son travail : + # soit il n'a pas reçu d'identifiants, soit il a échoué. Le message renvoie + # donc à ce qu'il a journalisé, et non à une manœuvre à improviser. if grep -qE 'P1000|P1010' "$migration_log"; then role="${APP_DB_USER:-planflow_app}" 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." fi echo '' - echo ' Le service `db-init` crée ce rôle et réaligne son mot de passe à' - echo " chaque démarrage. Vérifiez qu'il figure bien dans la pile, et ce" - echo " qu'il a journalisé :" + echo " L'application sait poser ce rôle elle-même au démarrage. Les lignes" + echo ' « [bootstrap] » plus haut disent pourquoi elle ne l’a pas fait :' echo '' - echo ' docker compose logs db-init' - echo ' docker compose run --rm db-init' + echo " • « Aucun identifiant d’amorçage fourni » — le service applicatif" + echo ' n’a pas POSTGRES_PASSWORD. Ajoutez-le, ou lancez :' + echo ' docker compose run --rm db-init' echo '' - echo " La seconde commande le rejoue : il est fait pour être exécuté" - echo " autant de fois qu'il le faut." + echo ' • « Provisionnement impossible » — le message qui suit indique' + echo ' ce qui a échoué (base injoignable, mot de passe d’amorçage' + echo ' erroné, droits insuffisants).' echo '════════════════════════════════════════════════════════════════' echo '' fi diff --git a/src/server/tenant.ts b/src/server/tenant.ts index 07c2276..20bb1aa 100644 --- a/src/server/tenant.ts +++ b/src/server/tenant.ts @@ -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( // 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; - }); + return fn(tx as unknown as ScopedClient) as Promise; + }, + { timeout: TRANSACTION_BUDGET_MS, maxWait: CONNECTION_WAIT_MS }, + ); } /**