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
+68
View File
@@ -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