From c5c704bae9feb79cfa7742bb3e442b42ac4497c6 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 4 Aug 2026 15:03:48 +0200 Subject: [PATCH] feat(15-01): Group/GroupMembership/ModuleGrant schema + D-06 backfill migration - Group/GroupMembership/ModuleGrant models plus MembershipSource enum (D-05), placed under TenantModuleActivation with German block comment - Hand-SQL appended to the generated migration: partial unique index for one default group per tenant (D-13), CHECK num_nonnulls xor-constraint plus two partial unique indexes for ModuleGrant (D-04), and the D-06 backfill (Group -> GroupMembership -> ModuleGrant, each INSERT guarded by WHERE NOT EXISTS for idempotent re-runs on `prisma migrate deploy`) - apps/api/src/groups/migration-sql.spec.ts verifies the hand-SQL by reading migration.sql directly, no DB required - Verified against the local DB: default-group count matches tenant count, membership/grant counts match existing users/active activations, and the XOR constraint rejects a group+user-less insert --- .../migration.sql | 153 ++++++++++++++++++ apps/api/prisma/schema.prisma | 68 ++++++++ apps/api/src/groups/migration-sql.spec.ts | 68 ++++++++ 3 files changed, 289 insertions(+) create mode 100644 apps/api/prisma/migrations/20260804130130_add_groups_and_module_grants/migration.sql create mode 100644 apps/api/src/groups/migration-sql.spec.ts diff --git a/apps/api/prisma/migrations/20260804130130_add_groups_and_module_grants/migration.sql b/apps/api/prisma/migrations/20260804130130_add_groups_and_module_grants/migration.sql new file mode 100644 index 0000000..229dae5 --- /dev/null +++ b/apps/api/prisma/migrations/20260804130130_add_groups_and_module_grants/migration.sql @@ -0,0 +1,153 @@ +-- CreateEnum +CREATE TYPE "MembershipSource" AS ENUM ('MANUAL', 'LDAP'); + +-- CreateTable +CREATE TABLE "Group" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "ldapDn" TEXT, + "isDefault" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Group_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GroupMembership" ( + "id" TEXT NOT NULL, + "groupId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "source" "MembershipSource" NOT NULL DEFAULT 'MANUAL', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "GroupMembership_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ModuleGrant" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "moduleId" TEXT NOT NULL, + "groupId" TEXT, + "userId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ModuleGrant_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Group_tenantId_idx" ON "Group"("tenantId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Group_tenantId_name_key" ON "Group"("tenantId", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Group_tenantId_ldapDn_key" ON "Group"("tenantId", "ldapDn"); + +-- CreateIndex +CREATE INDEX "GroupMembership_userId_idx" ON "GroupMembership"("userId"); + +-- CreateIndex +CREATE INDEX "GroupMembership_groupId_idx" ON "GroupMembership"("groupId"); + +-- CreateIndex +CREATE UNIQUE INDEX "GroupMembership_groupId_userId_key" ON "GroupMembership"("groupId", "userId"); + +-- CreateIndex +CREATE INDEX "ModuleGrant_tenantId_idx" ON "ModuleGrant"("tenantId"); + +-- CreateIndex +CREATE INDEX "ModuleGrant_moduleId_idx" ON "ModuleGrant"("moduleId"); + +-- AddForeignKey +ALTER TABLE "Group" ADD CONSTRAINT "Group_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GroupMembership" ADD CONSTRAINT "GroupMembership_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GroupMembership" ADD CONSTRAINT "GroupMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ModuleGrant" ADD CONSTRAINT "ModuleGrant_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ModuleGrant" ADD CONSTRAINT "ModuleGrant_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "Module"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ModuleGrant" ADD CONSTRAINT "ModuleGrant_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ModuleGrant" ADD CONSTRAINT "ModuleGrant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Hand-SQL ab hier: Prisma 6.19 hat kein stabiles partial-index-Feature ohne +-- previewFeatures-Flag — dieselbe Technik wie in +-- 20260618112133_rls_policies und 20260721150000_tender_cpv_divisions_backfill. + +-- D-13: genau eine Standardgruppe pro Mandant, DB-erzwungen. Ein normaler +-- Unique-Index auf ("tenantId") würde JEDE zweite Gruppe eines Mandanten +-- verbieten — der partielle Index gilt nur für Zeilen mit isDefault = true. +CREATE UNIQUE INDEX "Group_one_default_per_tenant" + ON "Group"("tenantId") WHERE "isDefault" = true; + +-- D-04: ModuleGrant zeigt auf genau eine Gruppe ODER genau einen Benutzer, +-- nie beides und nie keines. Prisma modelliert zwei unabhängige nullable +-- FK-Spalten — die Exklusivität ist nur per CHECK-Constraint erzwingbar. +ALTER TABLE "ModuleGrant" + ADD CONSTRAINT "ModuleGrant_group_xor_user" + CHECK (num_nonnulls("groupId", "userId") = 1); + +-- Duplikat-Schutz je Variante: nullable Spalten in einem normalen +-- @@unique wären wirkungslos, da Postgres NULL <> NULL vergleicht — daher +-- zwei partielle Unique-Indizes statt eines einzigen @@unique. +CREATE UNIQUE INDEX "ModuleGrant_tenant_module_group_unique" + ON "ModuleGrant"("tenantId", "moduleId", "groupId") WHERE "groupId" IS NOT NULL; +CREATE UNIQUE INDEX "ModuleGrant_tenant_module_user_unique" + ON "ModuleGrant"("tenantId", "moduleId", "userId") WHERE "userId" IS NOT NULL; + +-- D-06-Backfill: läuft automatisch bei jedem `prisma migrate deploy` +-- (apps/api/Dockerfile:38, unbeaufsichtigter Container-Start). Pro Mandant +-- entsteht eine Standardgruppe "Alle Benutzer", die alle Bestandsbenutzer +-- als Mitglieder und Grants für alle zum Migrationszeitpunkt aktiven +-- Module erhält — ohne diesen Schritt verliert jeder Bestandsbenutzer +-- beim nächsten Deploy den Modulzugriff (D-02 kehrt den Default auf +-- geschlossen um). Reihenfolge Group -> GroupMembership -> ModuleGrant ist +-- zwingend, jedes spätere Statement liest die im selben Lauf erzeugten +-- Group-Zeilen über isDefault = true. Jedes Statement trägt einen +-- WHERE-NOT-EXISTS-Wächter, damit ein Wiederholungslauf (z. B. nach einem +-- fehlgeschlagenen Deploy-Versuch) folgenlos bleibt und die partiellen +-- Unique-Indizes oben die Migration nicht abbrechen können. + +-- 1) Standardgruppe je Mandant. +INSERT INTO "Group" (id, "tenantId", name, "isDefault", "createdAt", "updatedAt") +SELECT gen_random_uuid(), t.id, 'Alle Benutzer', true, now(), now() +FROM "Tenant" t +WHERE NOT EXISTS ( + SELECT 1 FROM "Group" g WHERE g."tenantId" = t.id AND g."isDefault" = true +); + +-- 2) alle Bestandsbenutzer als Mitglieder der Standardgruppe ihres Mandanten. +INSERT INTO "GroupMembership" (id, "groupId", "userId", source, "createdAt") +SELECT gen_random_uuid(), g.id, u.id, 'MANUAL', now() +FROM "Group" g +JOIN "User" u ON u."tenantId" = g."tenantId" +WHERE g."isDefault" = true + AND NOT EXISTS ( + SELECT 1 FROM "GroupMembership" gm + WHERE gm."groupId" = g.id AND gm."userId" = u.id + ); + +-- 3) Grants für alle zum Migrationszeitpunkt aktiven Module des Mandanten. +INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "createdAt") +SELECT gen_random_uuid(), tma."tenantId", tma."moduleId", g.id, now() +FROM "TenantModuleActivation" tma +JOIN "Group" g ON g."tenantId" = tma."tenantId" AND g."isDefault" = true +WHERE tma."isActive" = true + AND NOT EXISTS ( + SELECT 1 FROM "ModuleGrant" mg + WHERE mg."tenantId" = tma."tenantId" + AND mg."moduleId" = tma."moduleId" + AND mg."groupId" = g.id + ); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 3648872..42e2d3b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -16,6 +16,8 @@ model Tenant { updatedAt DateTime @updatedAt users User[] ldapConfig LdapConfig? + groups Group[] + moduleGrants ModuleGrant[] } enum Role { @@ -42,6 +44,8 @@ model User { avatarPath String? accentColor String? passwordResetTokens PasswordResetToken[] + groupMemberships GroupMembership[] + moduleGrants ModuleGrant[] @@index([tenantId]) @@index([username]) @@ -102,6 +106,7 @@ model Module { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt activations TenantModuleActivation[] + grants ModuleGrant[] } model TenantModuleActivation { @@ -116,6 +121,69 @@ model TenantModuleActivation { @@index([tenantId]) } +// Phase 15 (PERM-04/05/06) — zweites Standbein der Zugriffskontrolle neben +// TenantModuleActivation. D-05: Gruppen sind Tessera-eigene Objekte pro +// Mandant mit optionaler AD-Bindung (ldapDn) — ein Umbau nach Vergabe +// echter Freigaben ist eine Datenmigration (one-way). D-13: pro Mandant +// darf höchstens eine Gruppe die Standard-Markierung tragen, DB-erzwungen +// über einen partiellen Unique-Index in der Hand-SQL-Ergänzung dieser +// Migration (Prisma 6.19 kennt keine partiellen Indizes ohne Preview-Flag). +// D-04: ModuleGrant trägt bewusst KEIN Rechtestufen-Feld — nur Zugriff an/aus. +enum MembershipSource { + MANUAL + LDAP +} + +model Group { + id String @id @default(uuid()) + tenantId String + tenant Tenant @relation(fields: [tenantId], references: [id]) + name String + ldapDn String? // optionale AD-Bindung (D-05) + isDefault Boolean @default(false) // D-13 — genau eine pro Mandant, DB-erzwungen (Hand-SQL) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + memberships GroupMembership[] + grants ModuleGrant[] + + @@unique([tenantId, name]) // Gruppennamen sind pro Mandant eindeutig + @@unique([tenantId, ldapDn]) // NULL ist in Postgres je Zeile distinct — mehrere ungebundene Gruppen sind erlaubt + @@index([tenantId]) +} + +model GroupMembership { + id String @id @default(uuid()) + groupId String + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + source MembershipSource @default(MANUAL) + createdAt DateTime @default(now()) + + @@unique([groupId, userId]) // Upsert-Ziel + @@index([userId]) + @@index([groupId]) +} + +model ModuleGrant { + id String @id @default(uuid()) + tenantId String + tenant Tenant @relation(fields: [tenantId], references: [id]) + moduleId String + module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade) + groupId String? + group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + // Entweder-oder (Gruppe XOR Benutzer, D-04) + Duplikat-Schutz je Variante + // werden per hand-editierter migration.sql ergänzt — Prisma 6.19 hat kein + // stabiles partial-index-Feature ohne previewFeatures-Flag. + @@index([tenantId]) + @@index([moduleId]) +} + model DashboardLayout { id String @id @default(uuid()) userId String @unique diff --git a/apps/api/src/groups/migration-sql.spec.ts b/apps/api/src/groups/migration-sql.spec.ts new file mode 100644 index 0000000..9e8d2a2 --- /dev/null +++ b/apps/api/src/groups/migration-sql.spec.ts @@ -0,0 +1,68 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Prüft die hand-editierten SQL-Ergänzungen in den beiden Phase-15- + * Migrationen (Plan 15-01, Task 1 + Task 3), ohne eine Datenbank zu + * brauchen — reiner Textabgleich der generierten migration.sql-Dateien. + * Stil folgt dem Repo-Muster hand-editierter Migrationen + * (20260618112133_rls_policies, 20260721150000_tender_cpv_divisions_backfill). + */ + +const MIGRATIONS_DIR = join(__dirname, '../../prisma/migrations'); + +function readMigrationSql(suffix: string): string { + const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix)) + .map((entry) => entry.name); + + if (dirs.length !== 1) { + throw new Error( + `Expected exactly one migration directory ending in "${suffix}", found ${dirs.length}: ${dirs.join(', ')}`, + ); + } + + return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8'); +} + +describe('add_groups_and_module_grants migration.sql (D-04, D-06, D-13)', () => { + const sql = readMigrationSql('_add_groups_and_module_grants'); + + it('erzwingt genau eine Standardgruppe pro Mandant (D-13, partieller Unique-Index)', () => { + expect(sql).toContain('Group_one_default_per_tenant'); + expect(sql).toContain('WHERE "isDefault" = true'); + }); + + it('erzwingt die Entweder-oder-Beziehung Gruppe XOR Benutzer per CHECK-Constraint (D-04)', () => { + expect(sql).toContain('ModuleGrant_group_xor_user'); + expect(sql).toContain('num_nonnulls("groupId", "userId") = 1'); + }); + + it('schützt beide ModuleGrant-Varianten (Gruppe/Benutzer) je Modul über partielle Unique-Indizes', () => { + expect(sql).toContain('ModuleGrant_tenant_module_group_unique'); + expect(sql).toContain('ModuleGrant_tenant_module_user_unique'); + }); + + it('D-06-Backfill: alle drei INSERT-Statements verwenden gen_random_uuid() für neue IDs', () => { + const occurrences = sql.match(/gen_random_uuid\(\)/g) ?? []; + expect(occurrences.length).toBe(3); + }); + + it('D-06-Backfill: alle drei INSERT-Statements tragen einen NOT-EXISTS-Wächter (idempotent bei Wiederholungslauf)', () => { + const occurrences = sql.match(/NOT EXISTS/g) ?? []; + expect(occurrences.length).toBe(3); + }); + + it('D-06-Backfill läuft in der Reihenfolge Group vor GroupMembership vor ModuleGrant', () => { + const groupIdx = sql.indexOf('INSERT INTO "Group"'); + const membershipIdx = sql.indexOf('INSERT INTO "GroupMembership"'); + const grantIdx = sql.indexOf('INSERT INTO "ModuleGrant"'); + + expect(groupIdx).toBeGreaterThan(-1); + expect(membershipIdx).toBeGreaterThan(-1); + expect(grantIdx).toBeGreaterThan(-1); + expect(groupIdx).toBeLessThan(membershipIdx); + expect(membershipIdx).toBeLessThan(grantIdx); + }); +});