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
This commit is contained in:
2026-08-04 15:03:48 +02:00
parent ac65149964
commit c5c704bae9
3 changed files with 289 additions and 0 deletions
@@ -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
);