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

+85
View File
@@ -0,0 +1,85 @@
import type { ScopedClient } from '@/server/tenant';
/**
* Journal d'audit — PLAN.md §3.5 et matrice n° 22.
*
* Append-only, imposé par un trigger PostgreSQL. La preuve exige de pouvoir
* reconstruire une décision telle qu'elle a été prise ; une piste que l'on peut
* réécrire ne prouve rien.
*/
export interface AuditEntry {
actorMembershipId?: string | null;
action: string;
entityType: string;
entityId: string;
before?: unknown;
after?: unknown;
/** Justification, obligatoire pour les opérations qui l'exigent. */
reason?: string | null;
ip?: string | null;
userAgent?: string | null;
}
/** Actions dont la matrice exige une justification explicite. */
const REASON_REQUIRED = new Set([
'counter.adjust',
'payroll.period.unlock',
'payroll.period.delete',
'planning.alert.acknowledge',
'membership.delete',
]);
export async function recordAudit(
db: ScopedClient,
entry: AuditEntry,
): Promise<void> {
if (REASON_REQUIRED.has(entry.action) && !entry.reason?.trim()) {
// Refuser ici plutôt qu'accepter une entrée creuse : une justification
// vide sur un ajustement de compteur ne vaut pas mieux que pas d'entrée,
// et laisse croire que la traçabilité est en place.
throw new Error(
`L'action « ${entry.action} » exige une justification (matrice n° 21 et 22).`,
);
}
await db.auditLog.create({
data: {
actorMembershipId: entry.actorMembershipId ?? null,
action: entry.action,
entityType: entry.entityType,
entityId: entry.entityId,
before: (entry.before ?? null) as never,
after: (entry.after ?? null) as never,
reason: entry.reason ?? null,
ip: entry.ip ?? null,
userAgent: entry.userAgent ?? null,
} as never,
});
}
/**
* Retire des valeurs à ne jamais écrire au journal.
*
* Le journal se relit, s'exporte et se conserve longtemps : y déposer un IBAN
* ou un mot de passe reviendrait à créer une seconde copie non chiffrée de ce
* que l'on a pris soin de chiffrer ailleurs.
*/
const REDACTED = new Set([
'passwordHash',
'socialSecurityNumberEnc',
'ibanEnc',
'bicEnc',
'mfaSecretEnc',
'tokenHash',
]);
export function redact<T extends Record<string, unknown>>(
value: T,
): Record<string, unknown> {
const output: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
output[key] = REDACTED.has(key) ? '[masqué]' : entry;
}
return output;
}
+217
View File
@@ -0,0 +1,217 @@
import { hash, verify } from '@node-rs/argon2';
import { cookies } from 'next/headers';
import type { Actor, Scope } from '@/domain/access/authorize';
import { generateToken, hashToken } from '@/server/crypto';
import { unscoped } from '@/server/tenant';
/**
* Sessions serveur — PLAN.md §2 (écart assumé) et matrice n° 23.
*
* En base plutôt qu'en jeton signé : la matrice impose de pouvoir **révoquer**
* une session lors d'un incident. Un JWT reste valable jusqu'à expiration quoi
* qu'on fasse, sauf à tenir une liste de révocation — c'est-à-dire à
* reconstruire une table de sessions, en moins fiable.
*/
export const SESSION_COOKIE = 'planflow_session';
const SESSION_DURATION_MS = 12 * 60 * 60 * 1000; // 12 h : une journée de travail.
const MAX_FAILED_ATTEMPTS = 8;
const LOCK_DURATION_MS = 15 * 60 * 1000;
/**
* Paramètres argon2id. Les valeurs par défaut de la bibliothèque sont
* délibérément basses ; celles-ci suivent les recommandations OWASP.
*/
const ARGON2_OPTIONS = {
memoryCost: 19_456, // 19 Mio
timeCost: 2,
parallelism: 1,
} as const;
export function hashPassword(password: string): Promise<string> {
return hash(password, ARGON2_OPTIONS);
}
export async function verifyPassword(
passwordHash: string,
password: string,
): Promise<boolean> {
try {
return await verify(passwordHash, password);
} catch {
// Une empreinte illisible est un échec d'authentification, pas une panne :
// la remonter en erreur 500 renseignerait sur l'état du compte.
return false;
}
}
export interface SignInInput {
email: string;
password: string;
ip?: string | null;
userAgent?: string | null;
}
export type SignInResult =
| { ok: true; token: string; expiresAt: Date; userId: string }
| { ok: false; reason: 'invalid' | 'locked' };
export async function signIn(input: SignInInput): Promise<SignInResult> {
const db = unscoped();
const user = await db.user.findUnique({
where: { email: input.email.toLowerCase().trim() },
});
if (user?.lockedUntil && user.lockedUntil > new Date()) {
return { ok: false, reason: 'locked' };
}
// Vérifier une empreinte factice quand l'utilisateur n'existe pas : sans
// cela, un compte inconnu répond nettement plus vite qu'un mot de passe
// faux, ce qui laisse énumérer les adresses.
const passwordHash =
user?.passwordHash ??
'$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHR2YWx1ZQ$0000000000000000000000000000000000000000000';
const valid = await verifyPassword(passwordHash, input.password);
if (!user || !user.passwordHash || !valid) {
if (user) {
const failed = user.failedAttempts + 1;
await db.user.update({
where: { id: user.id },
data: {
failedAttempts: failed,
lockedUntil:
failed >= MAX_FAILED_ATTEMPTS
? new Date(Date.now() + LOCK_DURATION_MS)
: null,
},
});
}
return { ok: false, reason: 'invalid' };
}
const token = generateToken();
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
await db.$transaction([
db.session.create({
data: {
userId: user.id,
tokenHash: hashToken(token),
expiresAt,
ip: input.ip ?? null,
userAgent: input.userAgent ?? null,
},
}),
db.user.update({
where: { id: user.id },
data: {
failedAttempts: 0,
lockedUntil: null,
lastSignInAt: new Date(),
},
}),
]);
return { ok: true, token, expiresAt, userId: user.id };
}
export async function signOut(token: string): Promise<void> {
await unscoped().session.updateMany({
where: { tokenHash: hashToken(token), revokedAt: null },
data: { revokedAt: new Date() },
});
}
/** Révoque toutes les sessions d'un utilisateur — réponse à incident. */
export async function revokeAllSessions(
userId: string,
revokedBy: string,
): Promise<number> {
const result = await unscoped().session.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date(), revokedBy },
});
return result.count;
}
export interface SessionContext {
actor: Actor;
sessionId: string;
}
/**
* Résout la session courante en acteur autorisable.
*
* Le périmètre est reconstruit ici, depuis la base, à chaque requête. Le
* transporter dans le cookie permettrait à un client de se l'élargir.
*/
export async function resolveSession(
token: string,
): Promise<SessionContext | null> {
const db = unscoped();
const session = await db.session.findUnique({
where: { tokenHash: hashToken(token) },
include: {
user: {
include: {
memberships: {
where: { status: 'ACTIVE', archivedAt: null },
include: {
role: { include: { permissions: { include: { permission: true } } } },
scopes: true,
},
},
},
},
},
});
if (!session || session.revokedAt || session.expiresAt < new Date()) {
return null;
}
const membership = session.user.memberships[0];
if (!membership) return null;
const scope: Scope = {
allLocations: membership.scopes.some((entry) => entry.allLocations),
locationIds: membership.scopes
.map((entry) => entry.locationId)
.filter((id): id is string => id !== null),
teamIds: membership.scopes
.map((entry) => entry.teamId)
.filter((id): id is string => id !== null),
};
const actor: Actor = {
membershipId: membership.id,
accountId: membership.accountId,
userId: session.user.id,
roleKey: membership.role.key,
permissions: new Set(
membership.role.permissions.map((entry) => entry.permission.code),
),
scope,
};
return { actor, sessionId: session.id };
}
/** Session courante depuis le cookie, ou `null`. */
export async function currentSession(): Promise<SessionContext | null> {
const token = (await cookies()).get(SESSION_COOKIE)?.value;
if (!token) return null;
return resolveSession(token);
}
export const SESSION_COOKIE_OPTIONS = {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NODE_ENV === 'production',
} as const;
+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);
}
+75
View File
@@ -0,0 +1,75 @@
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}`);
}
+138
View File
@@ -0,0 +1,138 @@
import type { Prisma, PrismaClient } from '@prisma/client';
import { prisma } from '@/server/db';
/**
* Isolation multi-tenant — PLAN.md §3.1.
*
* Deux couches indépendantes :
*
* 1. `SET LOCAL app.account_id` active les politiques RLS de PostgreSQL, qui
* filtrent en base quoi qu'il arrive côté application.
* 2. L'extension Prisma injecte `accountId` dans chaque `where` et chaque
* `create`, pour que le filtre soit aussi visible dans le plan de requête
* et que l'erreur, quand il y en a une, soit lisible.
*
* La première seule laisserait passer des requêtes qui ramènent zéro ligne sans
* dire pourquoi ; la seconde seule tomberait avec le premier `$queryRaw`.
*/
/** Tables portant une colonne `accountId`. */
const SCOPED_MODELS = new Set([
'Location',
'Team',
'Membership',
'MembershipScope',
'Invitation',
'Role',
'AuditLog',
'RetentionPolicy',
'FeatureFlag',
]);
const READ_OPERATIONS = new Set([
'findFirst',
'findFirstOrThrow',
'findMany',
'findUnique',
'findUniqueOrThrow',
'count',
'aggregate',
'groupBy',
'updateMany',
'deleteMany',
]);
const CREATE_OPERATIONS = new Set(['create', 'createMany', 'upsert']);
export type ScopedClient = Omit<
PrismaClient,
'$connect' | '$disconnect' | '$transaction' | '$extends'
>;
/**
* Un client étendu par compte. Mémorisé : `$extends` construit un proxy à
* chaque appel, et on en ferait un par requête HTTP sinon.
*/
const scopedClients = new Map<string, ReturnType<typeof buildScopedClient>>();
function scopedClientFor(accountId: string) {
const existing = scopedClients.get(accountId);
if (existing) return existing;
const client = buildScopedClient(accountId);
scopedClients.set(accountId, client);
return client;
}
function buildScopedClient(accountId: string) {
return prisma.$extends({
query: {
$allModels: {
async $allOperations({ model, operation, args, query }) {
if (!model || !SCOPED_MODELS.has(model)) {
return query(args);
}
const input = args as Record<string, unknown>;
if (READ_OPERATIONS.has(operation)) {
input.where = { ...(input.where as object), accountId };
}
if (CREATE_OPERATIONS.has(operation)) {
if (operation === 'createMany') {
const data = input.data;
input.data = Array.isArray(data)
? data.map((row) => ({ ...(row as object), accountId }))
: { ...(data as object), accountId };
} else if (operation === 'upsert') {
input.where = { ...(input.where as object), accountId };
input.create = { ...(input.create as object), accountId };
} else {
input.data = { ...(input.data as object), accountId };
}
}
return query(input);
},
},
},
});
}
/**
* Exécute `fn` dans une transaction portant le compte courant.
*
* Tout accès aux données d'un compte passe par ici. Le périmètre vient de la
* session serveur, jamais d'un paramètre client — c'est la règle qui rend
* l'isolation vérifiable plutôt que confiante.
*/
export async function withTenant<T>(
accountId: string,
fn: (db: ScopedClient) => Promise<T>,
): Promise<T> {
// La transaction est ouverte **depuis le client étendu**, pour que le `tx`
// qu'elle fournit porte l'extension et partage la connexion sur laquelle
// `set_config` est posé. L'inverse — étendre le `tx` — n'est pas possible :
// Prisma retire `$extends` du client de transaction.
return scopedClientFor(accountId).$transaction(async (tx) => {
// `set_config(..., true)` est local à la transaction, donc remis à zéro
// automatiquement. Une connexion rendue au pool ne garde pas le compte
// précédent — ce serait la pire fuite possible.
await tx.$executeRaw`SELECT set_config('app.account_id', ${accountId}, true)`;
return fn(tx as unknown as ScopedClient) as Promise<T>;
});
}
/**
* Accès sans portée de compte, pour les opérations qui précèdent l'identité :
* authentification par e-mail, acceptation d'invitation, migrations.
*
* Volontairement nommé pour se voir en revue de code.
*/
export function unscoped(): PrismaClient {
return prisma;
}
export type { Prisma };