From dfcc2d70f889d60cb532f5687e90fe477021e6cd Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 8 Jul 2026 15:42:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(hub):=20=E6=94=AF=E6=8C=81=E6=95=99?= =?UTF-8?q?=E5=B8=88=E5=9B=A2=E9=98=9F=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0019-principal-set-team-permissions.md | 129 +++++++ .../migration.sql | 143 ++++++++ hub/prisma/schema.prisma | 117 +++++-- hub/src/feishu/trigger.ts | 79 ++++- hub/src/permission.ts | 113 +++--- hub/src/permissions/authorizer.ts | 253 ++++++++++++++ hub/src/permissions/externalSync.ts | 108 ++++++ hub/src/permissions/principals.ts | 135 ++++++++ hub/test/integration/authorization.test.ts | 323 ++++++++++++++++++ hub/test/integration/helpers.ts | 7 +- hub/test/integration/role-permission.test.ts | 10 +- hub/test/integration/trigger.test.ts | 4 +- 12 files changed, 1303 insertions(+), 118 deletions(-) create mode 100644 docs/adr/0019-principal-set-team-permissions.md create mode 100644 hub/prisma/migrations/20260708160000_typed_principals_team_permissions/migration.sql create mode 100644 hub/src/permissions/authorizer.ts create mode 100644 hub/src/permissions/externalSync.ts create mode 100644 hub/src/permissions/principals.ts create mode 100644 hub/test/integration/authorization.test.ts diff --git a/docs/adr/0019-principal-set-team-permissions.md b/docs/adr/0019-principal-set-team-permissions.md new file mode 100644 index 0000000..5faac94 --- /dev/null +++ b/docs/adr/0019-principal-set-team-permissions.md @@ -0,0 +1,129 @@ +# ADR 0019: Resolve Actors to Principal Sets for Team Permissions + +## Status + +Accepted. + +## Context + +ADR-0004 deliberately chose a Feishu Docs-like permission model: +`grant(resource, principal, role)` plus separate resource settings. The first +Hub implementation stored `principal` as an opaque string and the Feishu trigger +used the sender's `open_id` directly. That was enough for individual teacher +grants, but not for production collaboration: + +- a curriculum team needs one grant to apply to many teachers; +- Feishu departments, user groups, and project chats should be grantable + principals; +- role-specific agent grants (`/review`, `/draft`) must compose with project + permissions consistently; +- audit/debug output must explain which principal and grant allowed or denied a + run. + +## Decision + +Introduce typed principals and resolve every actor to a principal set before +authorization. + +### Principal types + +`PrincipalType` is: + +- `USER`: a Feishu user, identified by `feishuOpenId`. +- `TEAM`: a Hub-managed teacher team. +- `FEISHU_CHAT`: a Feishu chat/group. +- `FEISHU_DEPARTMENT`: a Feishu department. +- `FEISHU_USER_GROUP`: a Feishu user group. +- `APP`: an integration or bot principal. + +`PermissionGrant` and `RoleTriggerGrant` store `principalType` and +`principalId`. Legacy opaque principal strings are backfilled as +`USER/`. + +### Team membership + +Hub teams are first-class. A user can be a direct active member of a team. A +team can also be bound to external Feishu principals: chat, department, or user +group. If an actor resolves to any bound external principal, the actor also +resolves to that Hub team. + +Membership changes are effective immediately because authorization resolves the +principal set at request time. + +### External Feishu principal sync + +Feishu department, user group, and chat memberships are not scattered through +callers. They are synchronized into `ExternalPrincipalMembership` rows and then +read by `PrincipalResolver`. The Feishu message context can also contribute the +current `FEISHU_CHAT` principal directly because receiving a group event is +already contextual proof that the actor is speaking in that group. + +### Grant merging + +Authorization evaluates all active grants matching any resolved principal for +the requested resource. There is no explicit deny in this ADR. Among matching +grants, the highest role wins: + +```text +READ < EDIT < MANAGE +``` + +Action thresholds: + +- project read requires `READ`. +- project edit requires `EDIT`. +- collaborator management requires `MANAGE`. +- agent trigger requires `EDIT`. +- normal agent cancel requires `MANAGE`. + +`PermissionSettings` never grants access beyond grants. Settings only constrain +an already-granted capability. For `agentTrigger`: + +- missing setting or `ROLE` means role-derived grants decide; +- `MANAGE_ONLY` raises the threshold to `MANAGE`; +- `DISABLED` denies the action. + +Other settings keep their existing string storage, but the same rule applies: +settings are policy constraints, not positive grants. + +### Role-trigger composition + +Role-trigger grants are a second gate after the project-level `agent.trigger` +gate: + +1. The actor must pass project `agent.trigger` for the project. +2. If no `RoleTriggerGrant` row has ever existed for `(projectId, roleId)`, + the role is open for backward compatibility. +3. Once any row exists for `(projectId, roleId)`, including a revoked row, the + role is configured. The actor must match an active role grant by any resolved + principal. + +This keeps `/review` restrictions orthogonal to ordinary edit access while +preventing a fully revoked role from silently reopening. + +### Module seam + +Callers use a single authorization module: + +```ts +authorizer.can({ + actor, + action, + resource, + roleId, +}) +``` + +The caller does not know how users, teams, Feishu departments, user groups, or +chats expand. `PrincipalResolver` owns that implementation, and +`PermissionAuthorizer` owns grant/settings/role-trigger composition. + +## Consequences + +- Teacher teams become production-grade principals rather than UI sugar. +- Feishu external organization concepts can be synchronized and granted without + leaking Feishu-specific checks into the trigger path. +- A single audit decision can report actor, principal set, matched grant, and + matched role grant. +- Backward compatibility is explicit: old principal strings become user + principals, and unconfigured role grants remain open. diff --git a/hub/prisma/migrations/20260708160000_typed_principals_team_permissions/migration.sql b/hub/prisma/migrations/20260708160000_typed_principals_team_permissions/migration.sql new file mode 100644 index 0000000..1c0af88 --- /dev/null +++ b/hub/prisma/migrations/20260708160000_typed_principals_team_permissions/migration.sql @@ -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; diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index 50ea89c..156fee0 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -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) ------------------------------------- diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index b2a9d80..01a13a9 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -15,7 +15,7 @@ import { sendText, sendTextMessage, patchTextMessage, reactToMessage, downloadMe import { runAgent as defaultRunAgent, type ProjectContext, type RunRequest, type RunResult } from "../agent/runner.js"; import type { RuntimeSettings } from "../settings/runtime.js"; import { acquireLock, currentLockRunId, releaseLock } from "../lock.js"; -import { canTriggerAgent, canTriggerRole } from "../permission.js"; +import { createPermissionAuthorizer, type AuthorizationDecision, type PermissionAuthorizer } from "../permission.js"; import { writeAudit } from "../audit.js"; import { PatchableTextStream } from "./textStream.js"; import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js"; @@ -25,6 +25,7 @@ interface TriggerDeps { readonly settings: RuntimeSettings; readonly logger: FastifyBaseLogger; readonly runAgent?: (req: RunRequest) => Promise; + readonly authorizer?: PermissionAuthorizer | undefined; } /** @@ -33,6 +34,7 @@ interface TriggerDeps { * agent run is the long leg); the lock guarantees one run per project at a time. */ export function makeTriggerHandler(deps: TriggerDeps) { + const authorizer = deps.authorizer ?? createPermissionAuthorizer(deps.prisma); return async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise => { const runAgent = deps.runAgent ?? defaultRunAgent; const msg = event.message; @@ -77,8 +79,8 @@ export function makeTriggerHandler(deps: TriggerDeps) { return; } const projectId = binding.projectId; - // ADR-0004: triggerAgent requires edit on the project. principal=sender - // open_id (sub-typology OPEN — pragmatic choice, see permission.ts). + // ADR-0019: triggerAgent is authorized through actor → principal-set + // resolution, then grant/settings composition. // A missing open_id is treated as an untrusted event: deny explicitly // rather than skipping the check (fail closed). const senderOpenId = event.sender.sender_id.open_id ?? ""; @@ -88,10 +90,20 @@ export function makeTriggerHandler(deps: TriggerDeps) { await sendText(rt, chatId, "无法识别发送者,拒绝触发。"); return; } - const perm = await canTriggerAgent(deps.prisma, projectId, senderOpenId); - if (!perm.allowed) { - deps.logger.info({ projectId, senderOpenId, reason: perm.reason }, "feishu trigger: permission denied"); - await writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId, action: "trigger.denied", metadata: { reason: perm.reason } }); + const actor = { feishuOpenId: senderOpenId, chatId }; + const triggerDecision = await authorizer.can({ + actor, + action: "agent.trigger", + resource: { type: "PROJECT", id: projectId }, + }); + if (!triggerDecision.allowed) { + deps.logger.info({ projectId, senderOpenId, reason: triggerDecision.reason }, "feishu trigger: permission denied"); + await writeAudit(deps.prisma, { + projectId, + ...(triggerDecision.actorUserId !== undefined ? { actorUserId: triggerDecision.actorUserId } : {}), + action: "trigger.denied", + metadata: { decision: auditDecision(triggerDecision) }, + }); await sendText(rt, chatId, "无权限触发。"); return; } @@ -186,13 +198,23 @@ export function makeTriggerHandler(deps: TriggerDeps) { const models = await deps.settings.modelRegistry({ projectId }); const { roleId: parsedRole, prompt: agentPrompt } = extractRole(cleanPrompt, models); const roleId = parsedRole ?? "draft"; - // Per-role trigger gate (ADR-0017). Orthogonal to ADR-0004's - // canTriggerAgent above: that checked "can trigger an agent at all"; - // this checks "can trigger *this* role". Unconfigured role ⇒ open. - const rolePerm = await canTriggerRole(deps.prisma, projectId, roleId, senderOpenId); - if (!rolePerm.allowed) { - deps.logger.info({ projectId, roleId, senderOpenId, reason: rolePerm.reason }, "feishu trigger: role permission denied"); - await writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId, action: "trigger.role_denied", metadata: { roleId, reason: rolePerm.reason } }); + // ADR-0019: role trigger is the second gate after project agent.trigger. + // Unconfigured roles remain open for back-compat; configured roles require + // a matching active RoleTriggerGrant for any resolved principal. + const roleDecision = await authorizer.can({ + actor, + action: "role.trigger", + resource: { type: "PROJECT", id: projectId }, + roleId, + }); + if (!roleDecision.allowed) { + deps.logger.info({ projectId, roleId, senderOpenId, reason: roleDecision.reason }, "feishu trigger: role permission denied"); + await writeAudit(deps.prisma, { + projectId, + ...(roleDecision.actorUserId !== undefined ? { actorUserId: roleDecision.actorUserId } : {}), + action: "trigger.role_denied", + metadata: { roleId, decision: auditDecision(roleDecision) }, + }); await sendText(rt, chatId, `无权限使用角色 ${roleId}。`); return; } @@ -235,7 +257,7 @@ export function makeTriggerHandler(deps: TriggerDeps) { data: { projectId, sessionId: session.id, - requestedByUserId: null, // sender→user mapping is OPEN (principal sub-typology) + requestedByUserId: roleDecision.actorUserId ?? null, entrypoint: "FEISHU", status: "ACTIVE", prompt: agentPrompt, @@ -395,6 +417,33 @@ function mergeSessionMetadata(metadata: unknown, patch: SessionMetadata): Prisma return base; } +function auditDecision(decision: AuthorizationDecision): Record { + return { + allowed: decision.allowed, + reason: decision.reason, + action: decision.action, + requiredRole: decision.requiredRole, + effectiveRole: decision.effectiveRole ?? null, + principals: decision.principals.map((principal) => ({ + type: principal.type, + id: principal.id, + })), + matchedGrant: decision.matchedGrant === undefined + ? null + : { + id: decision.matchedGrant.id, + principal: decision.matchedGrant.principal, + role: decision.matchedGrant.role, + }, + matchedRoleGrant: decision.matchedRoleGrant === undefined + ? null + : { + id: decision.matchedRoleGrant.id, + principal: decision.matchedRoleGrant.principal, + }, + }; +} + function withFileDeliveryInstructions(systemPrompt: string | undefined): string { const fileDeliveryPrompt = "When the user asks you to send, resend, attach, or provide a file, call the cph_hub send_file tool with the actual existing file path. " + diff --git a/hub/src/permission.ts b/hub/src/permission.ts index 41c157d..3863b71 100644 --- a/hub/src/permission.ts +++ b/hub/src/permission.ts @@ -1,89 +1,64 @@ /** - * Permission gate — ADR-0004 in code, project-scope. + * Permission compatibility exports. * - * `triggerAgent` requires the `edit` capability (ADR-0004). `edit` is granted - * by a `PermissionGrant` with role `edit` or `manage` (manage ⊇ edit, spec - * `Role.can_mono`). This module checks that for the run's requester against - * the project resource. - * - * Scope decision (project-wise): the resource is `project`, not - * `project_group` — the run and lock scope to the project (ADR-0002), so the - * capability is checked against the project. per-artifact permission is `OPEN` - * pending a Courseware-side artifact id (ADR-0004 + ADR-0007 mapping). - * - * principal→user mapping is `OPEN` (ADR-0004 principal sub-typology). Here we - * use the sender's Feishu open_id as the principal string literal — a pragmatic - * choice for the first cut, not a spec decision. When a real principal model - * lands, this is the single seam to change. - * - * settings.agentTrigger composition is `OPEN` (ADR-0004 separates role-derived - * capabilities from settings policy knobs but doesn't pin the composition - * rule). This gate implements the role half only; settings composition is - * deferred and clearly marked. + * ADR-0019 moves production authorization behind PrincipalResolver + + * PermissionAuthorizer. The wrappers below preserve the old helper names for + * tests and incremental callers while routing project trigger checks through + * the new authorizer. */ import type { PrismaClient } from "@prisma/client"; -import type { PermissionRole } from "@prisma/client"; +import { createPermissionAuthorizer } from "./permissions/authorizer.js"; -/// The roles that grant `edit` capability (edit itself + manage, by monotonicity). -const EDIT_OR_HIGHER: ReadonlySet = new Set(["EDIT", "MANAGE"]); +export { createPermissionAuthorizer, PrismaPermissionAuthorizer, roleGrantsEdit } from "./permissions/authorizer.js"; +export { PrismaPrincipalResolver, principalKey } from "./permissions/principals.js"; +export { syncExternalPrincipalMemberships } from "./permissions/externalSync.js"; +export type { + AuthorizationAction, + AuthorizationDecision, + AuthorizationRequest, + AuthorizationResource, + PermissionAuthorizer, +} from "./permissions/authorizer.js"; +export type { + ActorInput, + PrincipalRef, + PrincipalResolution, + PrincipalResolver, + ResolvedPrincipal, +} from "./permissions/principals.js"; +export type { + ExternalPrincipalMembershipInput, + ExternalPrincipalType, + SyncExternalPrincipalMembershipsInput, + SyncExternalPrincipalMembershipsResult, +} from "./permissions/externalSync.js"; export interface PermissionResult { readonly allowed: boolean; readonly reason: string; } -/** - * Check whether `principal` may trigger an agent run on `projectId`. - * - * Returns `{allowed, reason}`. On allow, the caller proceeds; on deny, the - * caller replies with the reason. Never throws — a DB error is a deny with the - * error as reason, so the run is not started on a broken state. - */ export async function canTriggerAgent( prisma: PrismaClient, projectId: string, principal: string, ): Promise { try { - // Look for an active (non-revoked) grant on the project resource for this - // principal, with a role that grants edit or higher. - const grant = await prisma.permissionGrant.findFirst({ - where: { - resourceType: "PROJECT", - resourceId: projectId, - principal, - role: { in: ["EDIT", "MANAGE"] }, - revokedAt: null, - }, - select: { id: true, role: true }, + const decision = await createPermissionAuthorizer(prisma).can({ + actor: { feishuOpenId: principal }, + action: "agent.trigger", + resource: { type: "PROJECT", id: projectId }, }); - if (grant !== null) { - return { allowed: true, reason: `granted by role ${grant.role}` }; - } - return { - allowed: false, - reason: `no active edit+ grant for ${principal} on project ${projectId}`, - }; + return { allowed: decision.allowed, reason: decision.reason }; } catch (e) { return { allowed: false, reason: `permission check failed: ${e instanceof Error ? e.message : String(e)}` }; } } + /** - * Per-role trigger gate (ADR-0017: roles are product config). Orthogonal to - * {@link canTriggerAgent}: that decides "can trigger an agent at all" - * (ADR-0004 triggerAgent capability); this decides "can trigger *which* role". - * Two gates in series — both must pass. - * - * Semantics: if **no** `RoleTriggerGrant` row exists for `(projectId, roleId)` - * (regardless of principal), the role is unconfigured on this project → - * **allow** (back-compat: a project that hasn't configured per-role grants - * doesn't get locked down). Once any grant exists for that role on the - * project, only principals with an active grant may trigger it. "Grants exist - * but this principal has none" → deny (admin explicitly restricted the role). - * - * `roleId` matches `RoleEntry.id` (the slash-command name). `principal` is the - * same opaque string ADR-0004 uses (sender open_id here; principal - * sub-typology OPEN). + * Role-only legacy helper. New trigger code uses `role.trigger` on + * PermissionAuthorizer so project edit+ and role grant composition happen in + * one decision. */ export async function canTriggerRole( prisma: PrismaClient, @@ -92,10 +67,6 @@ export async function canTriggerRole( principal: string, ): Promise { try { - // Any grant record (active OR revoked) for this (project, role)? If none, - // the role is unconfigured → allow (back-compat: no per-role restriction). - // A revoked grant still counts as "configured" — revoking one person must - // not silently reopen the role to everyone. const anyGrant = await prisma.roleTriggerGrant.findFirst({ where: { projectId, roleId }, select: { id: true }, @@ -103,9 +74,8 @@ export async function canTriggerRole( if (anyGrant === null) { return { allowed: true, reason: `role ${roleId} unconfigured on project (open)` }; } - // Grants exist — this principal must hold one. const grant = await prisma.roleTriggerGrant.findFirst({ - where: { projectId, roleId, principal, revokedAt: null }, + where: { projectId, roleId, principalType: "USER", principalId: principal, revokedAt: null }, select: { id: true }, }); if (grant !== null) { @@ -119,8 +89,3 @@ export async function canTriggerRole( return { allowed: false, reason: `role permission check failed: ${e instanceof Error ? e.message : String(e)}` }; } } - -/// Whether a role grants edit capability (edit or manage). Exported for tests. -export function roleGrantsEdit(role: PermissionRole): boolean { - return EDIT_OR_HIGHER.has(role); -} diff --git a/hub/src/permissions/authorizer.ts b/hub/src/permissions/authorizer.ts new file mode 100644 index 0000000..9397910 --- /dev/null +++ b/hub/src/permissions/authorizer.ts @@ -0,0 +1,253 @@ +import type { PermissionResourceType, PermissionRole, PrismaClient, PrincipalType } from "@prisma/client"; +import { PrismaPrincipalResolver, type ActorInput, type PrincipalRef, type PrincipalResolution, type PrincipalResolver } from "./principals.js"; + +export type AuthorizationAction = + | "project.read" + | "project.edit" + | "collaborator.manage" + | "agent.trigger" + | "agent.cancel" + | "role.trigger"; + +export interface AuthorizationResource { + readonly type: PermissionResourceType; + readonly id: string; +} + +export interface AuthorizationRequest { + readonly actor: ActorInput; + readonly action: AuthorizationAction; + readonly resource: AuthorizationResource; + readonly roleId?: string | undefined; +} + +export interface MatchedPermissionGrant { + readonly id: string; + readonly principal: PrincipalRef; + readonly role: PermissionRole; +} + +export interface MatchedRoleTriggerGrant { + readonly id: string; + readonly principal: PrincipalRef; +} + +export interface AuthorizationDecision { + readonly allowed: boolean; + readonly reason: string; + readonly action: AuthorizationAction; + readonly resource: AuthorizationResource; + readonly actor: ActorInput; + readonly actorUserId?: string | undefined; + readonly principals: readonly PrincipalRef[]; + readonly requiredRole: PermissionRole; + readonly effectiveRole?: PermissionRole | undefined; + readonly matchedGrant?: MatchedPermissionGrant | undefined; + readonly matchedRoleGrant?: MatchedRoleTriggerGrant | undefined; +} + +export interface PermissionAuthorizer { + can(req: AuthorizationRequest): Promise; +} + +export class PrismaPermissionAuthorizer implements PermissionAuthorizer { + constructor( + private readonly prisma: PrismaClient, + private readonly resolver: PrincipalResolver = new PrismaPrincipalResolver(prisma), + ) {} + + async can(req: AuthorizationRequest): Promise { + if (req.action === "role.trigger" && (req.roleId === undefined || req.roleId === "")) { + throw new Error("role.trigger authorization requires roleId"); + } + const resolution = await this.resolver.resolveActor(req.actor); + const threshold = await this.requiredRole(req); + if (threshold === "DISABLED") { + return this.decision(req, resolution, { + allowed: false, + reason: "permission setting disables this action", + requiredRole: defaultRequiredRole(req.action), + }); + } + + const grant = await this.bestGrant(req.resource, resolution.principals, threshold); + if (grant === undefined) { + return this.decision(req, resolution, { + allowed: false, + reason: `no active ${threshold}+ grant for resolved principals`, + requiredRole: threshold, + }); + } + + if (req.action !== "role.trigger") { + return this.decision(req, resolution, { + allowed: true, + reason: `granted by ${grant.principal.type}:${grant.principal.id} ${grant.role}`, + requiredRole: threshold, + effectiveRole: grant.role, + matchedGrant: grant, + }); + } + + const roleGate = await this.roleTriggerGrant(req.resource, req.roleId!, resolution.principals); + if (roleGate.status === "open") { + return this.decision(req, resolution, { + allowed: true, + reason: `role ${req.roleId} unconfigured on project (open)`, + requiredRole: threshold, + effectiveRole: grant.role, + matchedGrant: grant, + }); + } + if (roleGate.grant !== undefined) { + return this.decision(req, resolution, { + allowed: true, + reason: `role ${req.roleId} granted by ${roleGate.grant.principal.type}:${roleGate.grant.principal.id}`, + requiredRole: threshold, + effectiveRole: grant.role, + matchedGrant: grant, + matchedRoleGrant: roleGate.grant, + }); + } + + return this.decision(req, resolution, { + allowed: false, + reason: `no active role ${req.roleId} grant for resolved principals`, + requiredRole: threshold, + effectiveRole: grant.role, + matchedGrant: grant, + }); + } + + private async requiredRole(req: AuthorizationRequest): Promise { + let required = defaultRequiredRole(req.action); + if ((req.action === "agent.trigger" || req.action === "role.trigger") && req.resource.type === "PROJECT") { + const settings = await this.prisma.permissionSettings.findFirst({ + where: { resourceType: req.resource.type, resourceId: req.resource.id }, + select: { agentTrigger: true }, + }); + const policy = normalizePolicy(settings?.agentTrigger); + if (policy === "DISABLED") return "DISABLED"; + if (policy === "MANAGE_ONLY") required = "MANAGE"; + } + return required; + } + + private async bestGrant( + resource: AuthorizationResource, + principals: readonly PrincipalRef[], + threshold: PermissionRole, + ): Promise { + const grants = await this.prisma.permissionGrant.findMany({ + where: { + resourceType: resource.type, + resourceId: resource.id, + revokedAt: null, + OR: principalWhere(principals), + }, + select: { id: true, principalType: true, principalId: true, role: true }, + }); + const sufficient = grants + .filter((grant) => roleRank(grant.role) >= roleRank(threshold)) + .sort((a, b) => roleRank(b.role) - roleRank(a.role))[0]; + if (sufficient === undefined) return undefined; + return { + id: sufficient.id, + principal: { type: sufficient.principalType, id: sufficient.principalId }, + role: sufficient.role, + }; + } + + private async roleTriggerGrant( + resource: AuthorizationResource, + roleId: string, + principals: readonly PrincipalRef[], + ): Promise<{ readonly status: "open" } | { readonly status: "configured"; readonly grant?: MatchedRoleTriggerGrant | undefined }> { + if (resource.type !== "PROJECT") { + return { status: "configured" }; + } + const anyGrant = await this.prisma.roleTriggerGrant.findFirst({ + where: { projectId: resource.id, roleId }, + select: { id: true }, + }); + if (anyGrant === null) return { status: "open" }; + + const grant = await this.prisma.roleTriggerGrant.findFirst({ + where: { + projectId: resource.id, + roleId, + revokedAt: null, + OR: principalWhere(principals), + }, + select: { id: true, principalType: true, principalId: true }, + }); + if (grant === null) return { status: "configured" }; + return { + status: "configured", + grant: { + id: grant.id, + principal: { type: grant.principalType, id: grant.principalId }, + }, + }; + } + + private decision( + req: AuthorizationRequest, + resolution: PrincipalResolution, + result: Omit, + ): AuthorizationDecision { + return { + action: req.action, + resource: req.resource, + actor: req.actor, + ...(resolution.userId !== undefined ? { actorUserId: resolution.userId } : {}), + principals: resolution.principals, + ...result, + }; + } +} + +export function createPermissionAuthorizer( + prisma: PrismaClient, + resolver?: PrincipalResolver, +): PermissionAuthorizer { + return new PrismaPermissionAuthorizer(prisma, resolver); +} + +export function roleGrantsEdit(role: PermissionRole): boolean { + return roleRank(role) >= roleRank("EDIT"); +} + +export function roleRank(role: PermissionRole): number { + switch (role) { + case "READ": return 1; + case "EDIT": return 2; + case "MANAGE": return 3; + } +} + +function defaultRequiredRole(action: AuthorizationAction): PermissionRole { + switch (action) { + case "project.read": return "READ"; + case "project.edit": return "EDIT"; + case "agent.trigger": return "EDIT"; + case "role.trigger": return "EDIT"; + case "collaborator.manage": return "MANAGE"; + case "agent.cancel": return "MANAGE"; + } +} + +function normalizePolicy(policy: string | undefined): "ROLE" | "MANAGE_ONLY" | "DISABLED" { + if (policy === undefined || policy.trim() === "") return "ROLE"; + const normalized = policy.trim().toUpperCase(); + if (normalized === "DISABLED" || normalized === "DENY" || normalized === "OFF") return "DISABLED"; + if (normalized === "MANAGE_ONLY") return "MANAGE_ONLY"; + return "ROLE"; +} + +function principalWhere(principals: readonly PrincipalRef[]): Array<{ principalType: PrincipalType; principalId: string }> { + return principals.map((principal) => ({ + principalType: principal.type, + principalId: principal.id, + })); +} diff --git a/hub/src/permissions/externalSync.ts b/hub/src/permissions/externalSync.ts new file mode 100644 index 0000000..10d87a6 --- /dev/null +++ b/hub/src/permissions/externalSync.ts @@ -0,0 +1,108 @@ +import type { PrismaClient, PrincipalType } from "@prisma/client"; + +export type ExternalPrincipalType = Extract< + PrincipalType, + "FEISHU_CHAT" | "FEISHU_DEPARTMENT" | "FEISHU_USER_GROUP" +>; + +export interface ExternalPrincipalMembershipInput { + readonly feishuOpenId: string; + readonly displayName: string; + readonly principalType: ExternalPrincipalType; + readonly principalId: string; +} + +export interface SyncExternalPrincipalMembershipsInput { + readonly source: string; + readonly replaceSource?: boolean | undefined; + readonly memberships: readonly ExternalPrincipalMembershipInput[]; +} + +export interface SyncExternalPrincipalMembershipsResult { + readonly synced: number; + readonly revoked: number; +} + +export async function syncExternalPrincipalMemberships( + prisma: PrismaClient, + input: SyncExternalPrincipalMembershipsInput, +): Promise { + const syncedAt = new Date(); + const incomingKeys = new Set(); + let synced = 0; + + for (const item of input.memberships) { + assertExternalPrincipalType(item.principalType); + const user = await prisma.user.upsert({ + where: { feishuOpenId: item.feishuOpenId }, + update: { displayName: item.displayName }, + create: { + feishuOpenId: item.feishuOpenId, + displayName: item.displayName, + platformRoles: { create: { role: "TEACHER" } }, + }, + }); + incomingKeys.add(externalMembershipKey(user.id, item.principalType, item.principalId)); + const existing = await prisma.externalPrincipalMembership.findFirst({ + where: { + userId: user.id, + principalType: item.principalType, + principalId: item.principalId, + source: input.source, + revokedAt: null, + }, + select: { id: true }, + }); + if (existing === null) { + await prisma.externalPrincipalMembership.create({ + data: { + userId: user.id, + principalType: item.principalType, + principalId: item.principalId, + source: input.source, + syncedAt, + }, + }); + } else { + await prisma.externalPrincipalMembership.update({ + where: { id: existing.id }, + data: { syncedAt }, + }); + } + synced++; + } + + let revoked = 0; + if (input.replaceSource === true) { + const active = await prisma.externalPrincipalMembership.findMany({ + where: { source: input.source, revokedAt: null }, + select: { id: true, userId: true, principalType: true, principalId: true }, + }); + const revokeIds = active + .filter((membership) => !incomingKeys.has(externalMembershipKey( + membership.userId, + membership.principalType, + membership.principalId, + ))) + .map((membership) => membership.id); + if (revokeIds.length > 0) { + const result = await prisma.externalPrincipalMembership.updateMany({ + where: { id: { in: revokeIds } }, + data: { revokedAt: syncedAt }, + }); + revoked = result.count; + } + } + + return { synced, revoked }; +} + +function externalMembershipKey(userId: string, type: PrincipalType, id: string): string { + return `${userId}:${type}:${id}`; +} + +function assertExternalPrincipalType(type: PrincipalType): asserts type is ExternalPrincipalType { + if (type !== "FEISHU_CHAT" && type !== "FEISHU_DEPARTMENT" && type !== "FEISHU_USER_GROUP") { + throw new Error(`unsupported external principal type: ${type}`); + } +} diff --git a/hub/src/permissions/principals.ts b/hub/src/permissions/principals.ts new file mode 100644 index 0000000..07303b7 --- /dev/null +++ b/hub/src/permissions/principals.ts @@ -0,0 +1,135 @@ +import type { PrismaClient, PrincipalType } from "@prisma/client"; + +export interface PrincipalRef { + readonly type: PrincipalType; + readonly id: string; +} + +export interface ActorInput { + readonly feishuOpenId: string; + readonly chatId?: string | undefined; +} + +export type PrincipalSource = + | "actor-user" + | "context-chat" + | "team-membership" + | "external-membership" + | "team-external-binding"; + +export interface ResolvedPrincipal { + readonly principal: PrincipalRef; + readonly source: PrincipalSource; + readonly via?: PrincipalRef | undefined; +} + +export interface PrincipalResolution { + readonly actor: ActorInput; + readonly userId?: string | undefined; + readonly principals: readonly PrincipalRef[]; + readonly resolved: readonly ResolvedPrincipal[]; +} + +export interface PrincipalResolver { + resolveActor(actor: ActorInput): Promise; +} + +export class PrismaPrincipalResolver implements PrincipalResolver { + constructor(private readonly prisma: PrismaClient) {} + + async resolveActor(actor: ActorInput): Promise { + const resolved = new PrincipalCollector(); + resolved.add({ type: "USER", id: actor.feishuOpenId }, "actor-user"); + if (actor.chatId !== undefined && actor.chatId !== "") { + resolved.add({ type: "FEISHU_CHAT", id: actor.chatId }, "context-chat"); + } + + const user = await this.prisma.user.findUnique({ + where: { feishuOpenId: actor.feishuOpenId }, + select: { id: true }, + }); + + if (user !== null) { + const memberships = await this.prisma.teamMembership.findMany({ + where: { userId: user.id, revokedAt: null, team: { archivedAt: null } }, + select: { teamId: true }, + }); + for (const membership of memberships) { + resolved.add({ type: "TEAM", id: membership.teamId }, "team-membership"); + } + + const externalMemberships = await this.prisma.externalPrincipalMembership.findMany({ + where: { userId: user.id, revokedAt: null }, + select: { principalType: true, principalId: true }, + }); + for (const membership of externalMemberships) { + resolved.add( + { type: membership.principalType, id: membership.principalId }, + "external-membership", + ); + } + } + + const externalPrincipals = resolved.principals().filter((principal) => isExternalPrincipal(principal.type)); + if (externalPrincipals.length > 0) { + const bindings = await this.prisma.teamExternalBinding.findMany({ + where: { + revokedAt: null, + team: { archivedAt: null }, + OR: externalPrincipals.map((principal) => ({ + principalType: principal.type, + principalId: principal.id, + })), + }, + select: { teamId: true, principalType: true, principalId: true }, + }); + for (const binding of bindings) { + resolved.add( + { type: "TEAM", id: binding.teamId }, + "team-external-binding", + { type: binding.principalType, id: binding.principalId }, + ); + } + } + + return { + actor, + ...(user !== null ? { userId: user.id } : {}), + principals: resolved.principals(), + resolved: resolved.entries(), + }; + } +} + +export function principalKey(principal: PrincipalRef): string { + return `${principal.type}:${principal.id}`; +} + +function isExternalPrincipal(type: PrincipalType): boolean { + return type === "FEISHU_CHAT" || type === "FEISHU_DEPARTMENT" || type === "FEISHU_USER_GROUP"; +} + +class PrincipalCollector { + private readonly refs = new Map(); + private readonly provenance: ResolvedPrincipal[] = []; + + add(principal: PrincipalRef, source: PrincipalSource, via?: PrincipalRef): void { + const key = principalKey(principal); + if (!this.refs.has(key)) { + this.refs.set(key, principal); + } + this.provenance.push({ + principal, + source, + ...(via !== undefined ? { via } : {}), + }); + } + + principals(): readonly PrincipalRef[] { + return [...this.refs.values()]; + } + + entries(): readonly ResolvedPrincipal[] { + return this.provenance; + } +} diff --git a/hub/test/integration/authorization.test.ts b/hub/test/integration/authorization.test.ts new file mode 100644 index 0000000..f70fbc6 --- /dev/null +++ b/hub/test/integration/authorization.test.ts @@ -0,0 +1,323 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { prisma, resetDb } from "./helpers.js"; +import { createPermissionAuthorizer, PrismaPrincipalResolver, syncExternalPrincipalMemberships } from "../../src/permission.js"; + +describe("ADR-0019 authorization", () => { + beforeEach(async () => { + await resetDb(); + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + it("resolves actor principals from user, chat, direct teams, external memberships, and external team bindings", async () => { + const user = await prisma.user.create({ + data: { id: "u-auth-1", feishuOpenId: "ou_auth_1", displayName: "Teacher A" }, + }); + const directTeam = await prisma.team.create({ + data: { slug: "direct-auth", name: "Direct Team" }, + }); + const departmentTeam = await prisma.team.create({ + data: { slug: "department-auth", name: "Department Team" }, + }); + const chatTeam = await prisma.team.create({ + data: { slug: "chat-auth", name: "Chat Team" }, + }); + await prisma.teamMembership.create({ + data: { teamId: directTeam.id, userId: user.id }, + }); + await prisma.externalPrincipalMembership.create({ + data: { + userId: user.id, + principalType: "FEISHU_DEPARTMENT", + principalId: "dep-physics", + source: "test", + }, + }); + await prisma.teamExternalBinding.create({ + data: { teamId: departmentTeam.id, principalType: "FEISHU_DEPARTMENT", principalId: "dep-physics" }, + }); + await prisma.teamExternalBinding.create({ + data: { teamId: chatTeam.id, principalType: "FEISHU_CHAT", principalId: "chat-auth" }, + }); + + const resolution = await new PrismaPrincipalResolver(prisma).resolveActor({ + feishuOpenId: "ou_auth_1", + chatId: "chat-auth", + }); + + expect(resolution.userId).toBe(user.id); + expect(resolution.principals).toEqual(expect.arrayContaining([ + { type: "USER", id: "ou_auth_1" }, + { type: "FEISHU_CHAT", id: "chat-auth" }, + { type: "FEISHU_DEPARTMENT", id: "dep-physics" }, + { type: "TEAM", id: directTeam.id }, + { type: "TEAM", id: departmentTeam.id }, + { type: "TEAM", id: chatTeam.id }, + ])); + }); + + it("allows agent trigger through a team project grant", async () => { + const { team } = await seedUserTeamProject("auth-team-grant"); + await prisma.permissionGrant.create({ + data: { + resourceType: "PROJECT", + resourceId: "p-auth-team-grant", + principalType: "TEAM", + principalId: team.id, + role: "EDIT", + }, + }); + + const decision = await createPermissionAuthorizer(prisma).can({ + actor: { feishuOpenId: "ou_auth_team_grant" }, + action: "agent.trigger", + resource: { type: "PROJECT", id: "p-auth-team-grant" }, + }); + + expect(decision.allowed).toBe(true); + expect(decision.matchedGrant).toMatchObject({ + principal: { type: "TEAM", id: team.id }, + role: "EDIT", + }); + }); + + it("uses external Feishu principal memberships as grantable principals", async () => { + await prisma.project.create({ + data: { id: "p-auth-ext", name: "External", workspaceDir: "/tmp/auth-ext" }, + }); + await syncExternalPrincipalMemberships(prisma, { + source: "feishu-test", + memberships: [ + { + feishuOpenId: "ou_auth_ext", + displayName: "Teacher Ext", + principalType: "FEISHU_USER_GROUP", + principalId: "ug-lesson-design", + }, + ], + }); + await prisma.permissionGrant.create({ + data: { + resourceType: "PROJECT", + resourceId: "p-auth-ext", + principalType: "FEISHU_USER_GROUP", + principalId: "ug-lesson-design", + role: "EDIT", + }, + }); + + const decision = await createPermissionAuthorizer(prisma).can({ + actor: { feishuOpenId: "ou_auth_ext" }, + action: "agent.trigger", + resource: { type: "PROJECT", id: "p-auth-ext" }, + }); + + expect(decision.allowed).toBe(true); + expect(decision.matchedGrant?.principal).toEqual({ + type: "FEISHU_USER_GROUP", + id: "ug-lesson-design", + }); + }); + + it("revokes missing external memberships when replacing a sync source", async () => { + await syncExternalPrincipalMemberships(prisma, { + source: "feishu-directory", + memberships: [ + { + feishuOpenId: "ou_sync_a", + displayName: "Teacher A", + principalType: "FEISHU_DEPARTMENT", + principalId: "dep-a", + }, + { + feishuOpenId: "ou_sync_b", + displayName: "Teacher B", + principalType: "FEISHU_DEPARTMENT", + principalId: "dep-b", + }, + ], + }); + + const result = await syncExternalPrincipalMemberships(prisma, { + source: "feishu-directory", + replaceSource: true, + memberships: [ + { + feishuOpenId: "ou_sync_a", + displayName: "Teacher A", + principalType: "FEISHU_DEPARTMENT", + principalId: "dep-a", + }, + ], + }); + + expect(result).toEqual({ synced: 1, revoked: 1 }); + const activeB = await new PrismaPrincipalResolver(prisma).resolveActor({ feishuOpenId: "ou_sync_b" }); + expect(activeB.principals).not.toContainEqual({ type: "FEISHU_DEPARTMENT", id: "dep-b" }); + }); + + it("lets PermissionSettings constrain agent trigger without granting by itself", async () => { + await prisma.project.create({ + data: { id: "p-auth-policy", name: "Policy", workspaceDir: "/tmp/auth-policy" }, + }); + await prisma.user.create({ + data: { id: "u-auth-policy", feishuOpenId: "ou_auth_policy", displayName: "Teacher Policy" }, + }); + await prisma.permissionGrant.create({ + data: { + resourceType: "PROJECT", + resourceId: "p-auth-policy", + principalType: "USER", + principalId: "ou_auth_policy", + role: "EDIT", + }, + }); + await prisma.permissionSettings.create({ + data: defaultProjectSettings("p-auth-policy", { agentTrigger: "MANAGE_ONLY" }), + }); + + const editDecision = await createPermissionAuthorizer(prisma).can({ + actor: { feishuOpenId: "ou_auth_policy" }, + action: "agent.trigger", + resource: { type: "PROJECT", id: "p-auth-policy" }, + }); + expect(editDecision.allowed).toBe(false); + + await prisma.permissionGrant.create({ + data: { + resourceType: "PROJECT", + resourceId: "p-auth-policy", + principalType: "USER", + principalId: "ou_auth_policy", + role: "MANAGE", + }, + }); + + const manageDecision = await createPermissionAuthorizer(prisma).can({ + actor: { feishuOpenId: "ou_auth_policy" }, + action: "agent.trigger", + resource: { type: "PROJECT", id: "p-auth-policy" }, + }); + expect(manageDecision.allowed).toBe(true); + expect(manageDecision.requiredRole).toBe("MANAGE"); + }); + + it("requires project agent.trigger and configured role trigger grant for role.trigger", async () => { + const { team } = await seedUserTeamProject("auth-role"); + await prisma.permissionGrant.create({ + data: { + resourceType: "PROJECT", + resourceId: "p-auth-role", + principalType: "TEAM", + principalId: team.id, + role: "EDIT", + }, + }); + await prisma.roleTriggerGrant.create({ + data: { + projectId: "p-auth-role", + roleId: "review", + principalType: "USER", + principalId: "ou_someone_else", + }, + }); + + const denied = await createPermissionAuthorizer(prisma).can({ + actor: { feishuOpenId: "ou_auth_role" }, + action: "role.trigger", + resource: { type: "PROJECT", id: "p-auth-role" }, + roleId: "review", + }); + expect(denied.allowed).toBe(false); + + await prisma.roleTriggerGrant.create({ + data: { + projectId: "p-auth-role", + roleId: "review", + principalType: "TEAM", + principalId: team.id, + }, + }); + + const allowed = await createPermissionAuthorizer(prisma).can({ + actor: { feishuOpenId: "ou_auth_role" }, + action: "role.trigger", + resource: { type: "PROJECT", id: "p-auth-role" }, + roleId: "review", + }); + expect(allowed.allowed).toBe(true); + expect(allowed.matchedRoleGrant?.principal).toEqual({ type: "TEAM", id: team.id }); + }); + + it("keeps a role configured when all role grants are revoked", async () => { + await prisma.project.create({ + data: { id: "p-auth-revoked-role", name: "Revoked", workspaceDir: "/tmp/auth-revoked" }, + }); + await prisma.user.create({ + data: { id: "u-auth-revoked-role", feishuOpenId: "ou_auth_revoked", displayName: "Teacher Revoked" }, + }); + await prisma.permissionGrant.create({ + data: { + resourceType: "PROJECT", + resourceId: "p-auth-revoked-role", + principalType: "USER", + principalId: "ou_auth_revoked", + role: "EDIT", + }, + }); + await prisma.roleTriggerGrant.create({ + data: { + projectId: "p-auth-revoked-role", + roleId: "review", + principalType: "USER", + principalId: "ou_auth_revoked", + revokedAt: new Date(), + }, + }); + + const decision = await createPermissionAuthorizer(prisma).can({ + actor: { feishuOpenId: "ou_auth_revoked" }, + action: "role.trigger", + resource: { type: "PROJECT", id: "p-auth-revoked-role" }, + roleId: "review", + }); + + expect(decision.allowed).toBe(false); + expect(decision.reason).toContain("no active role review grant"); + }); +}); + +async function seedUserTeamProject(suffix: string) { + const user = await prisma.user.create({ + data: { + id: `u-${suffix}`, + feishuOpenId: `ou_${suffix.replaceAll("-", "_")}`, + displayName: "Teacher Team", + }, + }); + const team = await prisma.team.create({ + data: { slug: `team-${suffix}`, name: `Team ${suffix}` }, + }); + await prisma.teamMembership.create({ + data: { teamId: team.id, userId: user.id }, + }); + const project = await prisma.project.create({ + data: { id: `p-${suffix}`, name: `Project ${suffix}`, workspaceDir: `/tmp/${suffix}` }, + }); + return { user, team, project }; +} + +function defaultProjectSettings(projectId: string, overrides: { agentTrigger?: string } = {}) { + return { + resourceType: "PROJECT" as const, + resourceId: projectId, + externalShare: "ROLE", + comment: "ROLE", + copyDownload: "ROLE", + collaboratorMgmt: "ROLE", + agentTrigger: overrides.agentTrigger ?? "ROLE", + agentCancel: "ROLE", + }; +} diff --git a/hub/test/integration/helpers.ts b/hub/test/integration/helpers.ts index 4088e20..3a18816 100644 --- a/hub/test/integration/helpers.ts +++ b/hub/test/integration/helpers.ts @@ -34,6 +34,10 @@ export async function resetDb(): Promise { "PermissionSettings", "PermissionGrant", "RoleTriggerGrant", + "ExternalPrincipalMembership", + "TeamExternalBinding", + "TeamMembership", + "Team", "ProjectAgentLock", "AgentRun", "AgentSession", @@ -277,7 +281,8 @@ export async function seedProject( create: { resourceType: "PROJECT", resourceId: projectId, - principal, + principalType: "USER", + principalId: principal, role, }, }, diff --git a/hub/test/integration/role-permission.test.ts b/hub/test/integration/role-permission.test.ts index 7c2ae10..f51f680 100644 --- a/hub/test/integration/role-permission.test.ts +++ b/hub/test/integration/role-permission.test.ts @@ -24,7 +24,7 @@ describe("canTriggerRole (integration, per-role gate)", () => { data: { id: "p-role-2", name: "T", workspaceDir: "/tmp/x" }, }); await prisma.roleTriggerGrant.create({ - data: { projectId: project.id, roleId: "review", principal: "ou_a" }, + data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a" }, }); const r = await canTriggerRole(prisma, project.id, "review", "ou_a"); expect(r.allowed).toBe(true); @@ -36,7 +36,7 @@ describe("canTriggerRole (integration, per-role gate)", () => { }); // Someone else has the role; ou_b does not. await prisma.roleTriggerGrant.create({ - data: { projectId: project.id, roleId: "review", principal: "ou_a" }, + data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a" }, }); const r = await canTriggerRole(prisma, project.id, "review", "ou_b"); expect(r.allowed).toBe(false); @@ -47,7 +47,7 @@ describe("canTriggerRole (integration, per-role gate)", () => { data: { id: "p-role-4", name: "T", workspaceDir: "/tmp/x" }, }); await prisma.roleTriggerGrant.create({ - data: { projectId: project.id, roleId: "review", principal: "ou_a", revokedAt: new Date() }, + data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a", revokedAt: new Date() }, }); const r = await canTriggerRole(prisma, project.id, "review", "ou_a"); expect(r.allowed).toBe(false); @@ -57,11 +57,11 @@ describe("canTriggerRole (integration, per-role gate)", () => { const p1 = await prisma.project.create({ data: { id: "p-role-5a", name: "T", workspaceDir: "/tmp/x" } }); const p2 = await prisma.project.create({ data: { id: "p-role-5b", name: "T", workspaceDir: "/tmp/x" } }); await prisma.roleTriggerGrant.create({ - data: { projectId: p1.id, roleId: "review", principal: "ou_a" }, + data: { projectId: p1.id, roleId: "review", principalType: "USER", principalId: "ou_a" }, }); // p2 has the role configured for someone else; ou_a has no grant there. await prisma.roleTriggerGrant.create({ - data: { projectId: p2.id, roleId: "review", principal: "ou_other" }, + data: { projectId: p2.id, roleId: "review", principalType: "USER", principalId: "ou_other" }, }); const onP1 = await canTriggerRole(prisma, p1.id, "review", "ou_a"); expect(onP1.allowed).toBe(true); diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index f85b80a..23b1d09 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -270,7 +270,7 @@ describe("trigger full lifecycle (integration)", () => { await seedProject("proj-10", "chat-10"); // Someone else holds review; ou_test_user does not. await prisma.roleTriggerGrant.create({ - data: { projectId: "proj-10", roleId: "review", principal: "ou_other" }, + data: { projectId: "proj-10", roleId: "review", principalType: "USER", principalId: "ou_other" }, }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); @@ -285,7 +285,7 @@ describe("trigger full lifecycle (integration)", () => { it("allows /review when sender holds the role grant", async () => { await seedProject("proj-11", "chat-11"); await prisma.roleTriggerGrant.create({ - data: { projectId: "proj-11", roleId: "review", principal: "ou_test_user" }, + data: { projectId: "proj-11", roleId: "review", principalType: "USER", principalId: "ou_test_user" }, }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });