feat(hub): 支持教师团队权限

This commit is contained in:
2026-07-08 15:42:53 +08:00
parent 3f486232a8
commit dfcc2d70f8
12 changed files with 1303 additions and 118 deletions
@@ -0,0 +1,143 @@
-- ADR-0019: typed principals, Hub teams, and external Feishu principal sync.
CREATE TYPE "PrincipalType" AS ENUM (
'USER',
'TEAM',
'FEISHU_CHAT',
'FEISHU_DEPARTMENT',
'FEISHU_USER_GROUP',
'APP'
);
CREATE TABLE "Team" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Team_slug_key" ON "Team"("slug");
CREATE INDEX "Team_archivedAt_idx" ON "Team"("archivedAt");
CREATE TABLE "TeamMembership" (
"id" TEXT NOT NULL,
"teamId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "TeamMembership_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "TeamMembership_teamId_revokedAt_idx" ON "TeamMembership"("teamId", "revokedAt");
CREATE INDEX "TeamMembership_userId_revokedAt_idx" ON "TeamMembership"("userId", "revokedAt");
CREATE UNIQUE INDEX "TeamMembership_teamId_userId_revokedAt_key" ON "TeamMembership"("teamId", "userId", "revokedAt");
CREATE UNIQUE INDEX "TeamMembership_active_key" ON "TeamMembership"("teamId", "userId") WHERE "revokedAt" IS NULL;
ALTER TABLE "TeamMembership" ADD CONSTRAINT "TeamMembership_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TeamMembership" ADD CONSTRAINT "TeamMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE TABLE "TeamExternalBinding" (
"id" TEXT NOT NULL,
"teamId" TEXT NOT NULL,
"principalType" "PrincipalType" NOT NULL,
"principalId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "TeamExternalBinding_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "TeamExternalBinding_principalType_principalId_revokedAt_idx" ON "TeamExternalBinding"("principalType", "principalId", "revokedAt");
CREATE INDEX "TeamExternalBinding_teamId_revokedAt_idx" ON "TeamExternalBinding"("teamId", "revokedAt");
CREATE UNIQUE INDEX "TeamExternalBinding_teamId_principalType_principalId_revokedAt_key" ON "TeamExternalBinding"("teamId", "principalType", "principalId", "revokedAt");
CREATE UNIQUE INDEX "TeamExternalBinding_active_key" ON "TeamExternalBinding"("teamId", "principalType", "principalId") WHERE "revokedAt" IS NULL;
ALTER TABLE "TeamExternalBinding" ADD CONSTRAINT "TeamExternalBinding_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE TABLE "ExternalPrincipalMembership" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"principalType" "PrincipalType" NOT NULL,
"principalId" TEXT NOT NULL,
"source" TEXT NOT NULL,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "ExternalPrincipalMembership_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ExternalPrincipalMembership_userId_revokedAt_idx" ON "ExternalPrincipalMembership"("userId", "revokedAt");
CREATE INDEX "ExternalPrincipalMembership_principalType_principalId_revokedAt_idx" ON "ExternalPrincipalMembership"("principalType", "principalId", "revokedAt");
CREATE INDEX "ExternalPrincipalMembership_source_syncedAt_idx" ON "ExternalPrincipalMembership"("source", "syncedAt");
CREATE UNIQUE INDEX "ExternalPrincipalMembership_userId_principalType_principalId_source_revokedAt_key" ON "ExternalPrincipalMembership"("userId", "principalType", "principalId", "source", "revokedAt");
CREATE UNIQUE INDEX "ExternalPrincipalMembership_active_key" ON "ExternalPrincipalMembership"("userId", "principalType", "principalId", "source") WHERE "revokedAt" IS NULL;
ALTER TABLE "ExternalPrincipalMembership" ADD CONSTRAINT "ExternalPrincipalMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- PermissionGrant: backfill legacy opaque principal strings as USER principals.
ALTER TABLE "PermissionGrant" DROP CONSTRAINT IF EXISTS "PermissionGrant_resourceId_fkey";
DROP INDEX IF EXISTS "PermissionGrant_resourceType_resourceId_principal_role_revo_key";
DROP INDEX IF EXISTS "PermissionGrant_principal_revokedAt_idx";
ALTER TABLE "PermissionGrant" ADD COLUMN "principalType" "PrincipalType" NOT NULL DEFAULT 'USER';
ALTER TABLE "PermissionGrant" ADD COLUMN "principalId" TEXT;
UPDATE "PermissionGrant" SET "principalId" = "principal";
ALTER TABLE "PermissionGrant" ALTER COLUMN "principalId" SET NOT NULL;
ALTER TABLE "PermissionGrant" ALTER COLUMN "principalType" DROP DEFAULT;
ALTER TABLE "PermissionGrant" DROP COLUMN "principal";
WITH ranked_permission_grants AS (
SELECT
"id",
row_number() OVER (
PARTITION BY "resourceType", "resourceId", "principalType", "principalId", "role"
ORDER BY "createdAt", "id"
) AS rn
FROM "PermissionGrant"
WHERE "revokedAt" IS NULL
)
DELETE FROM "PermissionGrant"
WHERE "id" IN (
SELECT "id" FROM ranked_permission_grants WHERE rn > 1
);
CREATE INDEX "PermissionGrant_principalType_principalId_revokedAt_idx" ON "PermissionGrant"("principalType", "principalId", "revokedAt");
CREATE UNIQUE INDEX "PermissionGrant_active_key" ON "PermissionGrant"("resourceType", "resourceId", "principalType", "principalId", "role") WHERE "revokedAt" IS NULL;
-- PermissionSettings resource is polymorphic; do not FK resourceId to Project.
ALTER TABLE "PermissionSettings" DROP CONSTRAINT IF EXISTS "PermissionSettings_resourceId_fkey";
-- RoleTriggerGrant: same backfill as PermissionGrant.
DROP INDEX IF EXISTS "RoleTriggerGrant_projectId_roleId_principal_revokedAt_key";
DROP INDEX IF EXISTS "RoleTriggerGrant_principal_revokedAt_idx";
ALTER TABLE "RoleTriggerGrant" ADD COLUMN "principalType" "PrincipalType" NOT NULL DEFAULT 'USER';
ALTER TABLE "RoleTriggerGrant" ADD COLUMN "principalId" TEXT;
UPDATE "RoleTriggerGrant" SET "principalId" = "principal";
ALTER TABLE "RoleTriggerGrant" ALTER COLUMN "principalId" SET NOT NULL;
ALTER TABLE "RoleTriggerGrant" ALTER COLUMN "principalType" DROP DEFAULT;
ALTER TABLE "RoleTriggerGrant" DROP COLUMN "principal";
WITH ranked_role_trigger_grants AS (
SELECT
"id",
row_number() OVER (
PARTITION BY "projectId", "roleId", "principalType", "principalId"
ORDER BY "createdAt", "id"
) AS rn
FROM "RoleTriggerGrant"
WHERE "revokedAt" IS NULL
)
DELETE FROM "RoleTriggerGrant"
WHERE "id" IN (
SELECT "id" FROM ranked_role_trigger_grants WHERE rn > 1
);
CREATE INDEX "RoleTriggerGrant_principalType_principalId_revokedAt_idx" ON "RoleTriggerGrant"("principalType", "principalId", "revokedAt");
CREATE UNIQUE INDEX "RoleTriggerGrant_active_key" ON "RoleTriggerGrant"("projectId", "roleId", "principalType", "principalId") WHERE "revokedAt" IS NULL;
+96 -21
View File
@@ -39,6 +39,8 @@ model User {
requestedRuns AgentRun[] @relation("runRequester")
heldLocks ProjectAgentLock[] @relation("lockHolder")
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
teamMemberships TeamMembership[]
externalPrincipalMemberships ExternalPrincipalMembership[]
permissionGrants PermissionGrant[] @relation("grantCreator")
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
auditEntries AuditEntry[] @relation("auditActor")
@@ -64,6 +66,86 @@ enum PlatformRole {
TEACHER
}
/// ADR-0019: typed principals for permission grants and actor resolution.
/// USER is identified by Feishu open_id; TEAM by Hub Team.id; Feishu external
/// principals by their Feishu ids.
enum PrincipalType {
USER
TEAM
FEISHU_CHAT
FEISHU_DEPARTMENT
FEISHU_USER_GROUP
APP
}
/// Hub-managed teacher team. A team is a first-class permission principal.
model Team {
id String @id @default(cuid())
slug String @unique
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
memberships TeamMembership[]
externalBindings TeamExternalBinding[]
@@index([archivedAt])
}
/// Direct Hub team membership. External Feishu groups can also map to teams via
/// TeamExternalBinding; both sources are resolved at authorization time.
model TeamMembership {
id String @id @default(cuid())
teamId String
userId String
createdAt DateTime @default(now())
revokedAt DateTime?
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([teamId, userId, revokedAt])
@@index([teamId, revokedAt])
@@index([userId, revokedAt])
}
/// Bind a Hub team to an external Feishu principal. When an actor resolves to
/// the external principal, the actor also resolves to this team.
model TeamExternalBinding {
id String @id @default(cuid())
teamId String
principalType PrincipalType
principalId String
createdAt DateTime @default(now())
revokedAt DateTime?
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@unique([teamId, principalType, principalId, revokedAt])
@@index([principalType, principalId, revokedAt])
@@index([teamId, revokedAt])
}
/// Locally synchronized Feishu external principal membership.
model ExternalPrincipalMembership {
id String @id @default(cuid())
userId String
principalType PrincipalType
principalId String
source String
syncedAt DateTime @default(now())
revokedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, principalType, principalId, source, revokedAt])
@@index([userId, revokedAt])
@@index([principalType, principalId, revokedAt])
@@index([source, syncedAt])
}
// --- Project & Feishu binding (ADR-0001) ---------------------------------
model Project {
@@ -80,8 +162,6 @@ model Project {
agentSessions AgentSession[]
agentRuns AgentRun[]
agentLock ProjectAgentLock?
permissionGrants PermissionGrant[] @relation("projectGrants")
permissionSettings PermissionSettings[] @relation("projectSettings")
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
auditEntries AuditEntry[] @relation("projectAudit")
fileChanges AgentFileChange[] @relation("projectFileChanges")
@@ -224,32 +304,29 @@ enum PermissionResourceType {
}
/// ADR-0004 PermissionGrant: resource × principal × role.
/// `principal` is an opaque string (principal sub-typology OPEN, ADR-0004).
/// The compound unique covers "one active grant per (resource, principal, role)"
/// — revoked rows keep `revokedAt` set, so a re-grant after revocation is a new
/// row, not a conflict.
/// ADR-0019 replaces the old opaque `principal` with typed
/// `principalType/principalId` so user/team/Feishu principals compose through
/// the same authorization path.
model PermissionGrant {
id String @id @default(cuid())
resourceType PermissionResourceType
resourceId String
principal String
principalType PrincipalType
principalId String
role PermissionRole
createdByUserId String?
createdAt DateTime @default(now())
revokedAt DateTime?
project Project? @relation("projectGrants", fields: [resourceId], references: [id], onDelete: Cascade)
createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@unique([resourceType, resourceId, principal, role, revokedAt])
@@index([resourceType, resourceId, revokedAt])
@@index([principal, revokedAt])
@@index([principalType, principalId, revokedAt])
}
/// ADR-0004 PermissionSettings: six policy knobs, values OPEN. Stored as one
/// opaque string column each; the enum/value-domain is decided by admin policy,
/// not by this schema. `resourceType`+`resourceId` identify the resource.
/// Related to Project only when resourceType=PROJECT (null otherwise).
/// opaque string column each. ADR-0019 pins `agentTrigger` values used by the
/// authorizer: ROLE, MANAGE_ONLY, DISABLED.
model PermissionSettings {
id String @id @default(cuid())
resourceType PermissionResourceType
@@ -263,8 +340,6 @@ model PermissionSettings {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project? @relation("projectSettings", fields: [resourceId], references: [id], onDelete: Cascade)
@@unique([resourceType, resourceId])
@@index([resourceType, resourceId])
}
@@ -276,15 +351,16 @@ model PermissionSettings {
/// /review vs /draft). Two gates in series — both must pass.
///
/// `roleId` is an opaque string matching RoleEntry.id (ADR-0017: roles are
/// data, not a code enum). `principal` mirrors ADR-0004's opaque principal
/// (sub-typology OPEN). A project-scoped row grants the role on that project;
/// data, not a code enum). ADR-0019 makes the principal typed. A project-scoped
/// row grants the role on that project;
/// the compound unique covers "one active grant per (project, principal, role)".
/// Revocation via `revokedAt`, same pattern as PermissionGrant.
model RoleTriggerGrant {
id String @id @default(cuid())
projectId String
roleId String
principal String
principalType PrincipalType
principalId String
createdByUserId String?
createdAt DateTime @default(now())
revokedAt DateTime?
@@ -292,9 +368,8 @@ model RoleTriggerGrant {
project Project @relation("projectRoleGrants", fields: [projectId], references: [id], onDelete: Cascade)
createdBy User? @relation("roleGrantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@unique([projectId, roleId, principal, revokedAt])
@@index([projectId, roleId, revokedAt])
@@index([principal, revokedAt])
@@index([principalType, principalId, revokedAt])
}
// --- Audit (ADR Audit, content OPEN) -------------------------------------