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:
@@ -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
|
||||||
|
);
|
||||||
@@ -16,6 +16,8 @@ model Tenant {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
users User[]
|
users User[]
|
||||||
ldapConfig LdapConfig?
|
ldapConfig LdapConfig?
|
||||||
|
groups Group[]
|
||||||
|
moduleGrants ModuleGrant[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Role {
|
enum Role {
|
||||||
@@ -42,6 +44,8 @@ model User {
|
|||||||
avatarPath String?
|
avatarPath String?
|
||||||
accentColor String?
|
accentColor String?
|
||||||
passwordResetTokens PasswordResetToken[]
|
passwordResetTokens PasswordResetToken[]
|
||||||
|
groupMemberships GroupMembership[]
|
||||||
|
moduleGrants ModuleGrant[]
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@index([username])
|
@@index([username])
|
||||||
@@ -102,6 +106,7 @@ model Module {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
activations TenantModuleActivation[]
|
activations TenantModuleActivation[]
|
||||||
|
grants ModuleGrant[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model TenantModuleActivation {
|
model TenantModuleActivation {
|
||||||
@@ -116,6 +121,69 @@ model TenantModuleActivation {
|
|||||||
@@index([tenantId])
|
@@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 {
|
model DashboardLayout {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @unique
|
userId String @unique
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user