Files
planflow/src/server/db-guard.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

76 lines
2.3 KiB
TypeScript

import { prisma } from '@/server/db';
import { env } from '@/lib/env';
/**
* Vérifie que la base est configurée pour que la RLS s'applique réellement.
*
* **Un superutilisateur PostgreSQL contourne toute politique de sécurité au
* niveau ligne, y compris avec FORCE.** Connecter l'application avec un tel
* compte désactive silencieusement la seconde couche d'isolation : les
* requêtes continuent de fonctionner, les tests applicatifs passent, et rien
* n'indique que la protection a disparu.
*
* C'est exactement le genre de mauvaise configuration qu'on ne découvre qu'en
* lisant les données d'un autre client. D'où ce contrôle au démarrage.
*/
export interface TenantGuardResult {
ok: boolean;
isSuperuser: boolean;
bypassRls: boolean;
message?: string;
}
export async function checkTenantIsolation(): Promise<TenantGuardResult> {
const rows = await prisma.$queryRaw<
Array<{ usesuper: boolean; usebypassrls: boolean }>
>`SELECT usesuper, usebypassrls FROM pg_user WHERE usename = current_user`;
const row = rows[0];
if (!row) {
return {
ok: false,
isSuperuser: false,
bypassRls: false,
message: 'Impossible de déterminer les privilèges du compte de connexion.',
};
}
const ok = !row.usesuper && !row.usebypassrls;
return {
ok,
isSuperuser: row.usesuper,
bypassRls: row.usebypassrls,
...(ok
? {}
: {
message:
'Le compte de connexion contourne la row-level security ' +
`(superutilisateur : ${row.usesuper}, bypassrls : ${row.usebypassrls}). ` +
"L'isolation multi-tenant ne repose plus que sur la couche applicative. " +
'Créer un rôle dédié : CREATE ROLE planflow_app LOGIN NOSUPERUSER NOBYPASSRLS.',
}),
};
}
/**
* Refuse de démarrer en production sur une base mal configurée.
*
* En développement, un avertissement suffit : la base locale est souvent créée
* avec un compte administrateur, et bloquer rendrait la mise en route pénible
* sans rien protéger.
*/
export async function assertTenantIsolation(): Promise<void> {
const result = await checkTenantIsolation();
if (result.ok) return;
const message = `Isolation multi-tenant affaiblie — ${result.message}`;
if (env.NODE_ENV === 'production') {
throw new Error(message);
}
console.warn(`⚠ ${message}`);
}