Files
planflow/tests/integration/rls.test.ts
T
Claude ef2868b980 Rendre le déploiement Docker correct, et la RLS réellement active
Trois problèmes, dont un grave, trouvés en corrigeant un échec de déploiement.

**Le compose connectait l'application en superutilisateur PostgreSQL.** Un
superutilisateur contourne toute politique de sécurité au niveau ligne, y
compris déclarée en FORCE : la seconde couche d'isolation était présente en
base et absente des faits. Le README l'interdisait déjà noir sur blanc ; le
chemin de déploiement que nous livrons faisait exactement l'inverse. Un script
d'initialisation crée désormais un rôle `planflow_app` NOSUPERUSER NOBYPASSRLS,
propriétaire de la base — il lui faut ce droit pour migrer, et les politiques
sont en FORCE précisément pour s'appliquer aussi au propriétaire. Mesuré : en
superutilisateur, deux lignes visibles sans compte courant ; avec le rôle
dédié, zéro.

Basculer la base de développement sur ce même rôle a révélé le défaut que le
superutilisateur masquait : `resolveSession` lisait `Membership`, table filtrée
par compte, sans périmètre. Avec la RLS active, plus personne ne pouvait se
connecter. La résolution passe maintenant par une porte étroite — une politique
qui n'ouvre que les lignes dont l'utilisateur est titulaire, sous `app.user_id`
— le temps de trouver le compte, puis repasse par le périmètre ordinaire. La
suite de tests traverse enfin la RLS au lieu de la contourner.

**Les pièces du dossier salarié n'avaient aucun volume.** Elles étaient écrites
dans la couche du conteneur et disparaissaient au premier redéploiement. Une
pièce d'identité perdue ne se reconstitue pas.

Le reste répond à la demande : Postgres préconfiguré — la base n'étant ni
publiée ni attachée au réseau du proxy, ce mot de passe protège d'un conteneur
voisin, pas d'Internet — réseau `nginx_default` déclaré externe avec
l'application seule dessus, et port publié peu courant.

ENCRYPTION_KEY reste la seule variable sans valeur par défaut, et n'en aura
pas : elle chiffre le NIR, l'IBAN et les arrêts de travail. Une clé livrée avec
l'image serait connue de quiconque lit ce dépôt.

Au démarrage, l'application contrôle ses propres privilèges : elle refuse de se
lancer si la base porte plus d'un compte, et se contente d'un avertissement
s'il n'y en a qu'un — bloquer une installation mono-compte fermerait l'accès de
l'entreprise à ses données pour une fuite entre clients qui ne peut pas se
produire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-09 08:39:54 +00:00

233 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Client } from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
/**
* Isolation multi-tenant au niveau base — PLAN.md §3.1.
*
* Ces tests parlent directement à PostgreSQL, sans passer par Prisma : ils
* vérifient que l'isolation tient **même si la couche applicative est
* contournée**. Un test qui passerait par l'extension Prisma ne prouverait que
* l'extension.
*
* Point d'attention : un **superutilisateur contourne la RLS**, y compris avec
* FORCE. Ces tests créent donc un rôle restreint, ce qui est aussi la
* configuration attendue en production (voir README).
*/
const APP_ROLE = 'planflow_rls_test';
const APP_PASSWORD = 'rls-test-only';
// Connexion d'amorçage : créer un rôle et deux comptes de toutes pièces est
// hors de portée du compte applicatif — c'est précisément ce que ces tests
// vérifient par ailleurs.
const adminUrl =
process.env.ADMIN_DATABASE_URL ?? process.env.DATABASE_URL ?? '';
const enabled = adminUrl.length > 0;
let admin: Client;
let app: Client;
let accountA: string;
let accountB: string;
const describeIfDb = enabled ? describe : describe.skip;
describeIfDb('row-level security', () => {
beforeAll(async () => {
admin = new Client({ connectionString: adminUrl });
await admin.connect();
// Rôle applicatif sans privilège particulier : c'est la seule façon de
// voir les politiques s'appliquer.
await admin.query(`DROP OWNED BY ${APP_ROLE}`).catch(() => undefined);
await admin.query(`DROP ROLE IF EXISTS ${APP_ROLE}`).catch(() => undefined);
await admin.query(
`CREATE ROLE ${APP_ROLE} LOGIN PASSWORD '${APP_PASSWORD}' NOSUPERUSER NOBYPASSRLS`,
);
await admin.query(
`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${APP_ROLE}`,
);
await admin.query(
`GRANT USAGE ON SCHEMA public TO ${APP_ROLE}`,
);
accountA = `rls-a-${Date.now()}`;
accountB = `rls-b-${Date.now()}`;
for (const id of [accountA, accountB]) {
await admin.query(
'INSERT INTO "Account" (id, name, "createdAt") VALUES ($1, $2, now())',
[id, `Compte ${id}`],
);
await admin.query(
'INSERT INTO "Location" (id, "accountId", name, timezone, "employerContributionRate") VALUES ($1, $2, $3, $4, $5)',
[`${id}-loc`, id, `Établissement ${id}`, 'Europe/Paris', 0],
);
}
const url = new URL(adminUrl);
url.username = APP_ROLE;
url.password = APP_PASSWORD;
app = new Client({ connectionString: url.toString() });
await app.connect();
}, 30_000);
afterAll(async () => {
await app?.end().catch(() => undefined);
for (const id of [accountA, accountB]) {
await admin
?.query('DELETE FROM "Account" WHERE id = $1', [id])
.catch(() => undefined);
}
await admin?.query(`DROP OWNED BY ${APP_ROLE}`).catch(() => undefined);
await admin?.query(`DROP ROLE IF EXISTS ${APP_ROLE}`).catch(() => undefined);
await admin?.end().catch(() => undefined);
}, 30_000);
it('le rôle applicatif n’est pas superutilisateur', async () => {
// Sinon tous les tests suivants passeraient sans rien prouver.
const { rows } = await app.query(
'SELECT usesuper FROM pg_user WHERE usename = current_user',
);
expect(rows[0]?.usesuper).toBe(false);
});
it('ne renvoie rien tant que le compte courant n’est pas posé', async () => {
const { rows } = await app.query('SELECT count(*)::int AS n FROM "Location"');
expect(rows[0]?.n).toBe(0);
});
it('ne renvoie que les lignes du compte courant', async () => {
await app.query('BEGIN');
await app.query("SELECT set_config('app.account_id', $1, true)", [accountA]);
const { rows } = await app.query(
'SELECT "accountId" FROM "Location" ORDER BY id',
);
await app.query('COMMIT');
expect(rows).toHaveLength(1);
expect(rows[0]?.accountId).toBe(accountA);
});
it('refuse d’écrire pour un autre compte', async () => {
await app.query('BEGIN');
await app.query("SELECT set_config('app.account_id', $1, true)", [accountA]);
await expect(
app.query(
'INSERT INTO "Location" (id, "accountId", name, timezone, "employerContributionRate") VALUES ($1, $2, $3, $4, $5)',
[`intrus-${Date.now()}`, accountB, 'Intrusion', 'Europe/Paris', 0],
),
).rejects.toThrow(/row-level security/i);
await app.query('ROLLBACK');
});
it('remet le compte à zéro à la fin de la transaction', async () => {
// `set_config(..., true)` est local. Sans cela, une connexion rendue au
// pool garderait le compte du client précédent — la pire fuite possible.
await app.query('BEGIN');
await app.query("SELECT set_config('app.account_id', $1, true)", [accountA]);
await app.query('COMMIT');
const { rows } = await app.query('SELECT count(*)::int AS n FROM "Location"');
expect(rows[0]?.n).toBe(0);
});
});
describeIfDb('immutabilité du journal d’audit', () => {
let client: Client;
let accountId: string;
let entryId: string;
beforeAll(async () => {
client = new Client({ connectionString: adminUrl });
await client.connect();
accountId = `audit-${Date.now()}`;
entryId = `entry-${Date.now()}`;
await client.query(
'INSERT INTO "Account" (id, name, "createdAt") VALUES ($1, $2, now())',
[accountId, 'Compte audit'],
);
await client.query(
'INSERT INTO "AuditLog" (id, "accountId", action, "entityType", "entityId", "occurredAt") VALUES ($1, $2, $3, $4, $5, now())',
[entryId, accountId, 'test.action', 'Test', 'x'],
);
}, 30_000);
afterAll(async () => {
await client
?.query('DELETE FROM "Account" WHERE id = $1', [accountId])
.catch(() => undefined);
await client?.end().catch(() => undefined);
}, 30_000);
it('refuse la modification d’une entrée', async () => {
await expect(
client.query('UPDATE "AuditLog" SET action = $1 WHERE id = $2', [
'falsifie',
entryId,
]),
).rejects.toThrow(/append-only/i);
});
it('refuse la suppression d’une entrée', async () => {
// Même un superutilisateur est arrêté : le trigger ne dépend pas des
// privilèges, contrairement à la RLS.
await expect(
client.query('DELETE FROM "AuditLog" WHERE id = $1', [entryId]),
).rejects.toThrow(/append-only/i);
});
});
describeIfDb('couverture des politiques', () => {
let client: Client;
beforeAll(async () => {
client = new Client({ connectionString: adminUrl });
await client.connect();
}, 30_000);
afterAll(async () => {
await client?.end().catch(() => undefined);
}, 30_000);
it('toute table portant accountId est protégée', async () => {
// Le mode de défaillance de la RLS n'est pas d'écrire une mauvaise règle,
// c'est d'oublier d'en écrire une : la table répond alors à tout le monde,
// en silence. Ce test échoue quand une table est ajoutée sans politique.
const { rows } = await client.query<{
table_name: string;
relrowsecurity: boolean;
relforcerowsecurity: boolean;
policies: number;
}>(`
SELECT c.relname AS table_name,
c.relrowsecurity,
c.relforcerowsecurity,
(SELECT count(*)::int FROM pg_policy p WHERE p.polrelid = c.oid) AS policies
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND c.relkind = 'r'
AND EXISTS (
SELECT 1 FROM information_schema.columns col
WHERE col.table_schema = 'public'
AND col.table_name = c.relname
AND col.column_name = 'accountId'
)
ORDER BY c.relname
`);
expect(rows.length).toBeGreaterThan(0);
const unprotected = rows.filter(
(row) =>
!row.relrowsecurity || !row.relforcerowsecurity || row.policies < 2,
);
expect(
unprotected.map((row) => row.table_name),
'tables sans RLS forcée ou sans politique de lecture et d’écriture',
).toEqual([]);
});
});