Fundament: atomare Mandanten-Transaktionen, iframe-Vorschau, Uploads bis 25 MB, DSGVO-Felder

- db.ts: tenantTransaction() – atomar auch bei RLS_ENFORCED=true (AsyncLocalStorage
  bindet Operationen an eine craftvia_app-Transaktion, Kontext einmal gesetzt,
  verschachtelte Aufrufe treten bei, fremder Mandant wird abgewiesen)
- services/context.ts: inTransaction(ctx, fn); imports/confirm.ts umgestellt
- next.config.ts: EMBEDDABLE_FILE_ROUTES mit frame-ancestors 'self'/SAMEORIGIN
  (PDF-Vorschau Prüfmaske), proxyClientMaxBodySize 26mb (Import bis 25 MB)
- test-rls-enforcement: RLS-URL-Default aus DATABASE_URL (Lane-DBs)
- dsgvo/pii-fields: 26 Personenreferenzen des Craftvia-Domänenmodells
- ARCHITEKTUR §4.8: Transaktions-, Header-, Upload-, Versions- und PII-Regeln
- Test test-tenant-transaction (Commit/Rollback/Fremdmandant/Verschachtelung),
  grün im Owner- und im RLS-Modus

Gate: tsc, lint, build, 31/31 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:29:57 +02:00
co-authored by Claude Opus 5
parent 3e16689b2f
commit 4a25f2cc3b
8 changed files with 243 additions and 13 deletions
+60
View File
@@ -1,3 +1,4 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
@@ -329,6 +330,11 @@ async function applyTenantGuard(
* `app.tenant_id` transaktionslokal gesetzt wird (verworfen bei Commit/Rollback
* → kein Leak über den Pool). Die Guard-Logik ist in beiden Pfaden identisch.
*/
type TxClient = Parameters<Parameters<PrismaClient["$transaction"]>[0]>[0];
/** Active tenant transaction for the current async call chain (RLS path only). */
const tenantTx = new AsyncLocalStorage<{ tenantId: string; tx: TxClient }>();
export function dbForTenant(tenantId: string) {
if (!tenantId) throw new Error("dbForTenant: tenantId is required");
@@ -353,6 +359,26 @@ export function dbForTenant(tenantId: string) {
);
}
// Inside tenantTransaction(): run on the shared transaction (atomic, context set once).
const active = tenantTx.getStore();
if (active) {
if (active.tenantId !== tenantId) {
throw new Error("Tenant isolation violation: nested transaction for another tenant");
}
const txDelegate = (active.tx as unknown as Record<
string,
Record<string, (a: unknown) => Promise<unknown>>
>)[model.charAt(0).toLowerCase() + model.slice(1)];
return applyTenantGuard(
active.tx as unknown as DelegateSource,
tenantId,
model,
operation,
args,
(a) => txDelegate[operation](a),
);
}
// RLS-Pfad: alles in EINER Transaktion des Basisclients, damit
// Kontextsetzung und Ausführung garantiert auf derselben Connection
// liegen. `tx` ist uneextendiert → keine Rekursion in die Extension.
@@ -381,3 +407,37 @@ export function dbForTenant(tenantId: string) {
}
export type TenantDb = ReturnType<typeof dbForTenant>;
/**
* Run `fn` atomically for one tenant. ALWAYS use this (or `inTransaction` in services)
* instead of `db.$transaction(...)` for multi-step writes — atomic in both modes:
* - Owner mode: interactive transaction of the guarded client (the extension applies to tx).
* - RLS mode: one craftvia_app transaction with app.tenant_id set once; every operation of the
* guarded client inside `fn` (same async call chain) runs on that transaction.
* Nested calls join the outer transaction.
*/
export async function tenantTransaction<T>(
db: TenantDb,
tenantId: string,
fn: (tx: TenantDb) => Promise<T>,
options?: { timeout?: number; maxWait?: number },
): Promise<T> {
if (!RLS_ENFORCED) {
if (isTransactionClient(db)) return fn(db);
return db.$transaction((tx) => fn(tx as unknown as TenantDb), options) as Promise<T>;
}
const active = tenantTx.getStore();
if (active) {
if (active.tenantId !== tenantId) throw new Error("Tenant isolation violation: nested transaction for another tenant");
return fn(db);
}
return appBase!.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
return tenantTx.run({ tenantId, tx }, () => fn(db));
}, options);
}
/** Interactive transaction clients have no $transaction method. */
function isTransactionClient(db: TenantDb): boolean {
return typeof (db as unknown as { $transaction?: unknown }).$transaction !== "function";
}