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:
@@ -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";
|
||||
}
|
||||
|
||||
@@ -20,4 +20,33 @@ export interface PiiReference {
|
||||
export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [
|
||||
{ model: "AuditLog", field: "actorId" },
|
||||
{ model: "NotificationPreference", field: "userId" },
|
||||
// ── Craftvia domain (0002_craftvia_domain) ──
|
||||
{ model: "Customer", field: "createdById" },
|
||||
{ model: "Team", field: "leaderUserId" },
|
||||
{ model: "TeamMember", field: "userId" },
|
||||
{ model: "WorkOrder", field: "createdById" },
|
||||
{ model: "WorkOrder", field: "teamLeadUserId" },
|
||||
{ model: "WorkOrderAssignee", field: "userId" },
|
||||
{ model: "WorkOrderStatusChange", field: "actorId" },
|
||||
{ model: "ChecklistItem", field: "checkedById" },
|
||||
{ model: "MaterialUsage", field: "recordedById" },
|
||||
{ model: "WorkSession", field: "userId" },
|
||||
{ model: "TimeEntry", field: "userId" },
|
||||
{ model: "TimeEntry", field: "correctedById" },
|
||||
{ model: "ActivityNote", field: "authorId" },
|
||||
{ model: "Document", field: "uploadedById" },
|
||||
{ model: "Photo", field: "takenById" },
|
||||
{ model: "VoiceNote", field: "recordedById" },
|
||||
{ model: "Report", field: "createdById" },
|
||||
{ model: "Report", field: "teamApprovedById" },
|
||||
{ model: "Report", field: "approvedById" },
|
||||
{ model: "Signature", field: "capturedById" },
|
||||
{ model: "ImportJob", field: "importedById" },
|
||||
{ model: "ImportJob", field: "confirmedById" },
|
||||
{ model: "Notification", field: "userId" },
|
||||
{ model: "SyncOperation", field: "userId" },
|
||||
{ model: "SyncOperation", field: "resolvedById" },
|
||||
{ model: "AiGeneration", field: "createdById" },
|
||||
// Free-text person data of END CUSTOMERS (Customer/Contact/Site/Signature.signerName) is
|
||||
// tenant business data under data processing — not part of the employee subject export.
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Session } from "next-auth";
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import { tenantTransaction, type TenantDb } from "@/server/db";
|
||||
|
||||
/**
|
||||
* Context passed to every domain service. Created by server actions (from moduleGuard)
|
||||
@@ -23,6 +23,15 @@ export function ctxFromGuard(g: { session: Session; db: TenantDb; permissions: R
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a multi-step write atomically (ARCHITEKTUR §4.8). `fn` receives a ctx whose `db`
|
||||
* is bound to the transaction; nested calls join the outer transaction.
|
||||
* Never use `ctx.db.$transaction(...)` directly — it is not atomic with RLS_ENFORCED=true.
|
||||
*/
|
||||
export function inTransaction<T>(ctx: ServiceCtx, fn: (ctx: ServiceCtx) => Promise<T>): Promise<T> {
|
||||
return tenantTransaction(ctx.db, ctx.tenantId, (tx) => fn({ ...ctx, db: tx }));
|
||||
}
|
||||
|
||||
export function can(ctx: ServiceCtx, permission: string): boolean {
|
||||
return ctx.permissions.has(permission);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { nextNumber } from "@/server/services/numbering";
|
||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { readStoredExtraction } from "@/lib/imports/extraction";
|
||||
import { computeCorrections, reviewFormSchema, type ReviewForm } from "@/lib/imports/review";
|
||||
// TODO(L3→L2): replace with the L2 work order service after merge (same input type).
|
||||
@@ -65,9 +64,8 @@ export async function confirmImport(ctx: ServiceCtx, importId: string, rawForm:
|
||||
const corrections = computeCorrections(stored.fields, form);
|
||||
const now = new Date();
|
||||
|
||||
const result = await ctx.db.$transaction(async (tx) => {
|
||||
const db = tx as unknown as TenantDb;
|
||||
const txCtx: ServiceCtx = { ...ctx, db };
|
||||
const result = await inTransaction(ctx, async (txCtx) => {
|
||||
const db = txCtx.db;
|
||||
|
||||
const switched = await db.importJob.updateMany({
|
||||
where: { id: job.id, status: "review_required" },
|
||||
|
||||
Reference in New Issue
Block a user