Files
planflow/tests/integration/rls.test.ts
T
Claude 11763142d5 WP-01: tenancy, identity and capability authorization
Adds the data model for accounts, locations, teams, users, memberships
and scopes, plus roles, the 70-capability catalogue, database-backed
sessions, and the audit log.

Isolation is enforced twice, independently. A Prisma extension injects
accountId into every query, and PostgreSQL row-level security filters
underneath it, keyed on a transaction-local setting. The first alone
leaves raw queries unguarded; the second alone returns empty results
without saying why.

Integration tests prove both against a real database rather than
through the application layer, which would only prove the application
layer. They create a restricted role to do it — and that exposed a trap
worth naming: **a PostgreSQL superuser bypasses row-level security even
with FORCE**. Connecting the app as one silently disables the second
layer while every application test still passes. checkTenantIsolation
now refuses to start in production on such a database, warns in
development, and reports through /api/sante. The README explains the
role to create.

The audit log is append-only by trigger, so it resists even a
superuser: a trail that can be rewritten proves nothing. Entries
carrying an adjustment or an unlock are rejected without a
justification, and known secret-bearing fields are redacted before
writing — the log is read, exported and kept for years, so it must not
become a second unencrypted copy of what is encrypted elsewhere.

Sensitive columns use AES-256-GCM with the key held outside the
database. Sign-in verifies a dummy hash for unknown accounts so timing
does not enumerate addresses.

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

176 lines
6.1 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';
const adminUrl = 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);
});
});