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,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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user