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:
16 files changed
+2037
-9
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user