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

+89
View File
@@ -0,0 +1,89 @@
import {
createCipheriv,
createDecipheriv,
randomBytes,
timingSafeEqual,
createHash,
} from 'node:crypto';
import { env } from '@/lib/env';
/**
* Chiffrement applicatif des colonnes sensibles — PLAN.md §3.6.
*
* NIR, IBAN, BIC et secrets MFA. AES-256-GCM : le mode authentifié détecte
* qu'un chiffré a été modifié, ce qu'un CBC laisserait passer en produisant du
* clair corrompu.
*
* La clé vit hors de la base. Une sauvegarde volée ne doit pas suffire à lire
* ces colonnes — sans quoi le chiffrement ne protège que du vol de disque nu.
*/
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
function key(): Buffer {
return Buffer.from(env.ENCRYPTION_KEY, 'base64');
}
/** Format : [iv (12) | tag (16) | chiffré]. */
export function encrypt(plaintext: string): Buffer {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key(), iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
return Buffer.concat([iv, cipher.getAuthTag(), encrypted]);
}
export function decrypt(payload: Buffer): string {
if (payload.length < IV_LENGTH + TAG_LENGTH) {
throw new Error('Chiffré invalide : trop court pour contenir iv et tag');
}
const iv = payload.subarray(0, IV_LENGTH);
const tag = payload.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const encrypted = payload.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key(), iv);
decipher.setAuthTag(tag);
return Buffer.concat([
decipher.update(encrypted),
decipher.final(),
]).toString('utf8');
}
export function encryptOptional(value: string | null | undefined): Buffer | null {
return value ? encrypt(value) : null;
}
export function decryptOptional(payload: Buffer | null | undefined): string | null {
return payload ? decrypt(payload) : null;
}
/**
* Empreinte d'un jeton de session ou d'invitation.
*
* La base ne stocke que l'empreinte : une fuite de la table ne donne pas de
* sessions utilisables. SHA-256 suffit ici — le jeton est déjà 256 bits
* d'aléa, il n'y a pas de dictionnaire à ralentir, contrairement à un mot de
* passe.
*/
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export function generateToken(): string {
return randomBytes(32).toString('base64url');
}
/** Comparaison à temps constant, pour ne pas fuiter par la durée. */
export function safeEqual(a: string, b: string): boolean {
const bufferA = Buffer.from(a);
const bufferB = Buffer.from(b);
if (bufferA.length !== bufferB.length) return false;
return timingSafeEqual(bufferA, bufferB);
}