diff --git a/hub/prisma/migrations/20260723120000_member_group_hierarchy/migration.sql b/hub/prisma/migrations/20260723120000_member_group_hierarchy/migration.sql new file mode 100644 index 0000000..b17f6ec --- /dev/null +++ b/hub/prisma/migrations/20260723120000_member_group_hierarchy/migration.sql @@ -0,0 +1,70 @@ +-- Global, unlimited-depth member group hierarchy (requirement 3.1-3.3). +-- Managed only by the platform super administrator; deliberately NOT +-- org-scoped. Stores membership + nesting only, never permission data. +-- Deletion is soft (archivedAt / revokedAt markers); a group delete +-- cascade-soft-deletes its whole subtree as an application operation. +-- The permission side (GROUP principal, FOLDER resource, grant inheritance) +-- is intentionally deferred to a later migration. + +-- CreateTable +CREATE TABLE "MemberGroup" ( + "id" TEXT NOT NULL, + "parentId" TEXT, + "name" TEXT NOT NULL, + "description" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "archivedAt" TIMESTAMP(3), + + CONSTRAINT "MemberGroup_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MemberGroupMembership" ( + "id" TEXT NOT NULL, + "groupId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revokedAt" TIMESTAMP(3), + + CONSTRAINT "MemberGroupMembership_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MemberGroupClosure" ( + "ancestorId" TEXT NOT NULL, + "descendantId" TEXT NOT NULL, + "depth" INTEGER NOT NULL, + + CONSTRAINT "MemberGroupClosure_pkey" PRIMARY KEY ("ancestorId", "descendantId") +); + +-- CreateIndex +CREATE INDEX "MemberGroup_parentId_archivedAt_idx" ON "MemberGroup"("parentId", "archivedAt"); + +-- CreateIndex +CREATE INDEX "MemberGroupMembership_userId_revokedAt_idx" ON "MemberGroupMembership"("userId", "revokedAt"); + +-- CreateIndex +CREATE INDEX "MemberGroupMembership_groupId_revokedAt_idx" ON "MemberGroupMembership"("groupId", "revokedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "MemberGroupMembership_groupId_userId_revokedAt_key" ON "MemberGroupMembership"("groupId", "userId", "revokedAt"); + +-- CreateIndex +CREATE INDEX "MemberGroupClosure_descendantId_idx" ON "MemberGroupClosure"("descendantId"); + +-- AddForeignKey +ALTER TABLE "MemberGroup" ADD CONSTRAINT "MemberGroup_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "MemberGroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MemberGroupMembership" ADD CONSTRAINT "MemberGroupMembership_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MemberGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MemberGroupMembership" ADD CONSTRAINT "MemberGroupMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MemberGroupClosure" ADD CONSTRAINT "MemberGroupClosure_ancestorId_fkey" FOREIGN KEY ("ancestorId") REFERENCES "MemberGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MemberGroupClosure" ADD CONSTRAINT "MemberGroupClosure_descendantId_fkey" FOREIGN KEY ("descendantId") REFERENCES "MemberGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index 9e619ea..cf7c2bf 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -193,6 +193,7 @@ model User { heldLocks ProjectAgentLock[] @relation("lockHolder") feishuBindings ProjectGroupBinding[] @relation("bindingCreator") teamMemberships TeamMembership[] + memberGroupMemberships MemberGroupMembership[] externalPrincipalMemberships ExternalPrincipalMembership[] permissionGrants PermissionGrant[] @relation("grantCreator") roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator") @@ -393,6 +394,67 @@ model TeamExternalBinding { @@index([teamId, revokedAt]) } +// --- Member groups (global, nestable authorization principal) ------------ + +/// Global, unlimited-depth member group. Managed only by the platform super administrator (ADR-0023); +/// Deletion is soft: `archivedAt` is a marker. +/// Deleting a group cascade-soft-deletes its whole subtree — an application +/// operation (walk the subtree, stamp archivedAt), not a DB constraint. +model MemberGroup { + id String @id @default(cuid()) + parentId String? + name String + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + archivedAt DateTime? + + parent MemberGroup? @relation("groupTree", fields: [parentId], references: [id], onDelete: Restrict) + children MemberGroup[] @relation("groupTree") + memberships MemberGroupMembership[] + asAncestor MemberGroupClosure[] @relation("ancestor") + asDescendant MemberGroupClosure[] @relation("descendant") + + @@index([parentId, archivedAt]) +} + +/// User↔group many-to-many; a user may belong to multiple groups. Soft delete +/// via `revokedAt` (same pattern as TeamMembership) allows re-adding a removed +/// member. `userId, revokedAt` index is the resolution hot path: fetch a user's direct groups. +model MemberGroupMembership { + id String @id @default(cuid()) + groupId String + userId String + createdAt DateTime @default(now()) + revokedAt DateTime? + + group MemberGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([groupId, userId, revokedAt]) + @@index([userId, revokedAt]) + @@index([groupId, revokedAt]) +} + +/// Transitive closure of the MemberGroup tree. Every group has a depth=0 +/// self row. Turns ancestor/descendant resolution into one indexed join +/// instead of a recursive CTE; maintained only on create / reparent (rare +/// super-admin ops, so the write cost is amortized against hot reads). On soft +/// delete the closure rows are retained; resolution filters by +/// MemberGroup.archivedAt. Reparent must reject a new parent inside the moved +/// subtree (cycle guard). +model MemberGroupClosure { + ancestorId String + descendantId String + depth Int + + ancestor MemberGroup @relation("ancestor", fields: [ancestorId], references: [id], onDelete: Cascade) + descendant MemberGroup @relation("descendant", fields: [descendantId], references: [id], onDelete: Cascade) + + @@id([ancestorId, descendantId]) + @@index([descendantId]) +} + /// Locally synchronized Feishu external principal membership. model ExternalDirectoryConnection { id String @id @default(cuid())