Créer le rôle applicatif à chaque démarrage, pas seulement au premier

Les migrations échouaient sur « Authentication failed for planflow_app ». Le
script qui crée ce rôle était monté dans /docker-entrypoint-initdb.d, lequel ne
s'exécute qu'à la **toute première** initialisation du volume : une pile dont le
volume existait déjà — créé par une tentative antérieure — n'a jamais vu passer
le rôle. Je l'avais noté dans le README au lieu de le traiter.

Un service `db-init` rejoue désormais le script à chaque démarrage, et le script
est rendu rejouable : il crée le rôle ou aligne son mot de passe, transfère la
propriété de la base, du schéma et des objets déjà présents. Les identifiants du
superutilisateur restent dans ce service ; les confier au conteneur applicatif
lui donnerait de quoi contourner la row-level security, ce que tout ce montage
cherche à empêcher.

Éprouvé sur une base créée par le superutilisateur, comme celle du client :
rôle posé en NOSUPERUSER NOBYPASSRLS, propriétaire de la base et des tables
préexistantes, capable de se connecter et d'exécuter du DDL ; les 22 migrations
s'appliquent ; la RLS le filtre — deux comptes visibles par le superutilisateur,
zéro par lui hors périmètre. Rejoué une seconde fois sans effet de bord.

Non vérifiable ici : l'authentification par mot de passe, mon cluster de
développement étant en mode `trust`.

Corrigé au passage une interférence entre tests : celui qui retire « Gérer les
rôles » aux rôles semés, pour atteindre le refus de verrouillage, s'exécutait en
parallèle de ceux qui s'appuient sur cette capacité et les faisait échouer par
intermittence. Le fichier passe en série.

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 15:50:23 +00:00
1 parent bce8e7ca32
commit 72809cefb6
3 files changed
+75 -5

No files matched your search

+32
View File
@@ -39,6 +39,36 @@ services:
expose: expose:
- '5432' - '5432'
# Pose le rôle applicatif à **chaque** démarrage de la pile.
#
# `/docker-entrypoint-initdb.d` ne s'exécute qu'à la toute première
# initialisation du volume : un volume créé par une tentative antérieure
# n'aurait jamais vu passer ce rôle, et l'application échouerait à
# s'authentifier sans que rien n'explique pourquoi. Le script est rejouable,
# ce service le rejoue.
#
# Les identifiants du superutilisateur restent ici : les confier au conteneur
# applicatif lui donnerait de quoi contourner la row-level security, ce que
# tout ce montage cherche précisément à empêcher.
db-init:
image: postgres:16-alpine
restart: 'no'
depends_on:
db:
condition: service_healthy
environment:
PGHOST: db
PGPASSWORD: ${POSTGRES_PASSWORD:-planflow-interne}
POSTGRES_USER: ${POSTGRES_USER:-planflow}
POSTGRES_DB: ${POSTGRES_DB:-planflow}
APP_DB_USER: ${APP_DB_USER:-planflow_app}
APP_DB_PASSWORD: ${APP_DB_PASSWORD:-planflow-app-interne}
volumes:
- ./docker/init-app-role.sh:/init-app-role.sh:ro
entrypoint: ['sh', '/init-app-role.sh']
networks:
- interne
app: app:
build: build:
context: . context: .
@@ -46,6 +76,8 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
db-init:
condition: service_completed_successfully
environment: environment:
NODE_ENV: production NODE_ENV: production
# Le compte applicatif, **pas** le superutilisateur d'amorçage : un # Le compte applicatif, **pas** le superutilisateur d'amorçage : un
+37 -5
View File
@@ -14,25 +14,57 @@ set -eu
# ni superutilisateur ni BYPASSRLS. C'est précisément pourquoi les politiques # ni superutilisateur ni BYPASSRLS. C'est précisément pourquoi les politiques
# sont déclarées en FORCE : elles s'appliquent aussi au propriétaire. # sont déclarées en FORCE : elles s'appliquent aussi au propriétaire.
# #
# Ce script ne s'exécute qu'à la **première** initialisation du volume. Pour une # Ce script est **rejouable**, et il le doit : `/docker-entrypoint-initdb.d` ne
# installation déjà en place, jouer le même SQL à la main (voir README). # s'exécute qu'à la toute première initialisation du volume. Une installation
# déjà en place — un volume créé par une tentative antérieure, par exemple —
# n'aurait jamais vu passer ce rôle, et l'application échouerait à se connecter
# sans que rien n'explique pourquoi. Il est donc aussi joué à chaque démarrage
# de la pile, par le service `db-init`.
APP_ROLE="${APP_DB_USER:-planflow_app}" APP_ROLE="${APP_DB_USER:-planflow_app}"
APP_PASSWORD="${APP_DB_PASSWORD:-planflow-app-interne}" APP_PASSWORD="${APP_DB_PASSWORD:-planflow-app-interne}"
DB_NAME="${POSTGRES_DB:-planflow}"
DB_USER="${POSTGRES_USER:-planflow}"
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<SQL # Sans PGHOST, psql passe par la socket locale — le cas quand le script est
# joué par l'image postgres à l'initialisation. Avec, il passe par le réseau —
# le cas du service qui le rejoue à chaque démarrage.
psql -v ON_ERROR_STOP=1 --username "$DB_USER" --dbname "$DB_NAME" <<SQL
DO \$\$ DO \$\$
BEGIN BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${APP_ROLE}') THEN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${APP_ROLE}') THEN
CREATE ROLE ${APP_ROLE} LOGIN PASSWORD '${APP_PASSWORD}' CREATE ROLE ${APP_ROLE} LOGIN PASSWORD '${APP_PASSWORD}'
NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS; NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
ELSE
-- Le mot de passe peut avoir changé dans la configuration de la pile ;
-- laisser l'ancien produirait un refus d'authentification illisible.
ALTER ROLE ${APP_ROLE} WITH LOGIN PASSWORD '${APP_PASSWORD}'
NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
END IF; END IF;
END END
\$\$; \$\$;
ALTER DATABASE ${POSTGRES_DB} OWNER TO ${APP_ROLE}; ALTER DATABASE ${DB_NAME} OWNER TO ${APP_ROLE};
ALTER SCHEMA public OWNER TO ${APP_ROLE}; ALTER SCHEMA public OWNER TO ${APP_ROLE};
GRANT ALL ON SCHEMA public TO ${APP_ROLE}; GRANT ALL ON SCHEMA public TO ${APP_ROLE};
-- Objets déjà créés par un compte d'amorçage : sans ce transfert, le rôle
-- applicatif ne pourrait ni migrer ni lire ce qui existe déjà.
DO \$\$
DECLARE
statement text;
BEGIN
FOR statement IN
SELECT format('ALTER TABLE %I.%I OWNER TO ${APP_ROLE}', schemaname, tablename)
FROM pg_tables WHERE schemaname = 'public'
UNION ALL
SELECT format('ALTER SEQUENCE %I.%I OWNER TO ${APP_ROLE}', sequence_schema, sequence_name)
FROM information_schema.sequences WHERE sequence_schema = 'public'
LOOP
EXECUTE statement;
END LOOP;
END
\$\$;
SQL SQL
echo "Rôle applicatif ${APP_ROLE} prêt — NOSUPERUSER NOBYPASSRLS, propriétaire de ${POSTGRES_DB}." echo "Rôle applicatif ${APP_ROLE} prêt — NOSUPERUSER NOBYPASSRLS, propriétaire de ${DB_NAME}."
+6
View File
@@ -9,7 +9,13 @@ import { slugifyRoleKey } from '../../src/domain/access/role-editing';
* changement de code. » Le vérifier suppose d'aller jusqu'au bout : créer le * changement de code. » Le vérifier suppose d'aller jusqu'au bout : créer le
* rôle, l'attribuer, et constater qu'un écran s'ouvre ou se ferme en * rôle, l'attribuer, et constater qu'un écran s'ouvre ou se ferme en
* conséquence. Un test qui s'arrêterait à « la case est cochée » ne dirait rien. * conséquence. Un test qui s'arrêterait à « la case est cochée » ne dirait rien.
*
* En série, et il le faut : l'un de ces tests retire momentanément « Gérer les
* rôles » aux rôles semés pour atteindre le refus de verrouillage. Joué en
* parallèle des autres, qui s'appuient sur cette même capacité, il les fait
* échouer par intermittence — et la cause n'est visible nulle part.
*/ */
test.describe.configure({ mode: 'serial' });
const OWNER_PASSWORD = 'planflow-demo-2026'; const OWNER_PASSWORD = 'planflow-demo-2026';