Merge pull request #30 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre

Faire poser le rôle applicatif par l'application elle-même
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-09 20:33:10 +02:00
commit 759dd97c8e
5 files changed
+178 -19

No files matched your search

+1
View File
@@ -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 —
+10 -1
View File
@@ -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
+117
View File
@@ -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
View File
@@ -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
+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 },
);
}
/**