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
This commit is contained in:
Claude committed 2026-08-07 22:39:33 +00:00
1 parent 93852c2602
commit 11763142d5
16 files changed
+2037 -9

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
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);
});
});