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
+323
View File
@@ -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",
};
}
+6 -1
View File
@@ -34,6 +34,10 @@ export async function resetDb(): Promise<void> {
"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,
},
},
+5 -5
View File
@@ -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);
+2 -2
View File
@@ -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 });