Projekt-Fundament: Spec, Docker-Stack, Prisma-Basis, Tenant-Guard

- docs/SPEC.md (v1.2: Assets & BIA als ein Modul, Risiko-Detailansicht
  mit Maßnahmen/betroffenen Assets, Risiko-Verknüpfungen in allen
  Detailansichten) + UI-Mockup als Referenz
- Docker-Compose: app, worker, postgres (pgvector), redis, minio, mailhog
- Prisma 7: Fundament-Schema (Tenant, User, RBAC, AuditLog), prisma.config.ts,
  pg-Treiberadapter
- Tenant-Guard (src/server/db.ts): erzwungene Mandanten-Isolation für
  alle tenant-gebundenen Modelle
- AGENTS.md/README mit Projektregeln (Isolation, RBAC, Audit-Log, i18n, Lizenz)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Martin
2026-07-02 11:43:37 +02:00
co-authored by Claude Fable 5
parent 394c05aa08
commit 87e933b3c0
14 changed files with 3248 additions and 46 deletions
+99
View File
@@ -0,0 +1,99 @@
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
/**
* Central database access for the ISMS tool.
*
* Multi-tenant rule (see docs/SPEC.md §2): no query on tenant-scoped models
* without a tenant context. Application code MUST use `dbForTenant(tenantId)`
* for all tenant-scoped models — the raw client is only for global data
* (permissions, control catalogs) and platform administration.
*
* Postgres Row Level Security is added on top as a second line of defense
* (prisma/rls.sql, applied via migration).
*/
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
/** Models that carry a tenantId column and must never be queried without one. */
const TENANT_MODELS = new Set<string>(["User", "Role", "AuditLog"]);
/**
* Returns a Prisma client that transparently enforces the tenant scope:
* reads are filtered by tenantId, creates get the tenantId injected.
*/
export function dbForTenant(tenantId: string) {
if (!tenantId) throw new Error("dbForTenant: tenantId is required");
return prisma.$extends({
query: {
$allModels: {
async $allOperations({ model, operation, args, query }) {
if (!TENANT_MODELS.has(model)) return query(args);
const a = args as Record<string, unknown>;
if (
operation === "findMany" ||
operation === "findFirst" ||
operation === "count" ||
operation === "aggregate" ||
operation === "updateMany" ||
operation === "deleteMany"
) {
a.where = { AND: [{ tenantId }, (a.where as object) ?? {}] };
} else if (operation === "create") {
a.data = { ...(a.data as object), tenantId };
} else if (operation === "createMany") {
const data = a.data as Record<string, unknown>[];
a.data = data.map((d) => ({ ...d, tenantId }));
} else if (operation === "findUnique" || operation === "findUniqueOrThrow") {
// Unique lookups cannot be widened with AND — verify ownership on the result.
const result = await query(args);
if (
result &&
typeof result === "object" &&
"tenantId" in result &&
(result as { tenantId: string }).tenantId !== tenantId
) {
throw new Error(
`Tenant isolation violation: ${model} belongs to another tenant`
);
}
return result;
} else if (
operation === "update" ||
operation === "delete" ||
operation === "upsert"
) {
// Mutations on unique keys: verify ownership BEFORE mutating.
const delegate = (
prisma as unknown as Record<
string,
{ findUnique: (a: unknown) => Promise<{ tenantId?: string } | null> }
>
)[model.charAt(0).toLowerCase() + model.slice(1)];
const existing = await delegate.findUnique({ where: a.where });
if (existing && existing.tenantId !== tenantId) {
throw new Error(
`Tenant isolation violation: ${model} belongs to another tenant`
);
}
}
return query(args);
},
},
},
});
}
export type TenantDb = ReturnType<typeof dbForTenant>;