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:
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/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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
# 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
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user