feat(hub): add global nestable member group hierarchy

Global, unlimited-depth member groups managed by the platform super
admin (requirement 3.1-3.3). Stores membership + nesting only, never
permission data; exposes user -> ancestor-closed group set.

- MemberGroup: soft delete via archivedAt; parentId FK RESTRICT.
  Deleting a group cascade-soft-deletes its whole subtree as an
  application operation, not a DB cascade.
- MemberGroupMembership: user<->group many-to-many, revokedAt soft
  delete, user/group indexed for resolution hot path.
- MemberGroupClosure: transitive closure (depth-0 self rows) for
  one-join ancestor/descendant resolution; maintained on
  create/reparent with a cycle guard.

Permission side (GROUP principal, FOLDER resource, grant inheritance)
is deferred. This principal is deliberately not org-scoped and will
need ADR-0028 to supersede the ADR-0020 cross-org invariant before the
GROUP principal ships.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 22:31:26 +08:00
parent 12628c9233
commit 192cd43245
2 changed files with 132 additions and 0 deletions
@@ -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;
+62
View File
@@ -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())