forked from bai/curriculum-project-hub
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:
@@ -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;
|
||||||
@@ -193,6 +193,7 @@ model User {
|
|||||||
heldLocks ProjectAgentLock[] @relation("lockHolder")
|
heldLocks ProjectAgentLock[] @relation("lockHolder")
|
||||||
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
|
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
|
||||||
teamMemberships TeamMembership[]
|
teamMemberships TeamMembership[]
|
||||||
|
memberGroupMemberships MemberGroupMembership[]
|
||||||
externalPrincipalMemberships ExternalPrincipalMembership[]
|
externalPrincipalMemberships ExternalPrincipalMembership[]
|
||||||
permissionGrants PermissionGrant[] @relation("grantCreator")
|
permissionGrants PermissionGrant[] @relation("grantCreator")
|
||||||
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
|
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
|
||||||
@@ -393,6 +394,67 @@ model TeamExternalBinding {
|
|||||||
@@index([teamId, revokedAt])
|
@@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.
|
/// Locally synchronized Feishu external principal membership.
|
||||||
model ExternalDirectoryConnection {
|
model ExternalDirectoryConnection {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|||||||
Reference in New Issue
Block a user