Files
curriculum-project-hub/hub/test/integration/authorization.test.ts
T
hongjr03 d01ea0e1cb feat: interrupt running agent via card button with confirm dialog
Add a  中断 button to live streaming cards; Feishu's native confirm
dialog is the confirmation step, so no extra card round-trip is needed.

- builder.ts: render interrupt action (danger button + confirm) while the
  run is live; new `interrupted` flag renders a 已中断 footer instead of
  完成/失败 on the complete card.
- streaming-card.ts: finish(text, { interrupted }) threads the flag into
  the final patch; overflow cards suppress the button.
- runner.ts: RunRequest gains abortController, passed to the SDK query;
  abort surfaces as RunStatus "interrupted" (no error) in the catch.
- trigger.ts: activeRuns registry maps runId → AbortController; onCardAction
  resolves the interrupt button, authorizes agent.cancel, aborts the run.
  Interrupted runs persist as CANCELED (spec RunState.canceled, terminal →
  lock released per ADR-0002).
- authorizer.ts: agent.cancel now consults PermissionSettings.agentCancel
  (MANAGE_ONLY/DISABLED) — previously only agentTrigger was honored, but
  spec/PermissionGrant deliberately splits these knobs ("谁能触发" ≠
  "谁能取消"). Default role remains MANAGE (Capability.normalCancel).

Tests: runner abort unit test, trigger interrupt + denied integration
tests, three agent.cancel authorization tests (manage allowed, edit denied,
DISABLED policy denies even manage). 26 files / 175 tests pass.
2026-07-09 18:59:31 +08:00

376 lines
13 KiB
TypeScript

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");
});
it("agent.cancel defaults to MANAGE and is allowed for a manage grant", async () => {
await prisma.project.create({ data: { id: "p-cancel", name: "Cancel", workspaceDir: "/tmp/cancel" } });
await prisma.user.create({ data: { id: "u-cancel", feishuOpenId: "ou_cancel", displayName: "Manager" } });
await prisma.permissionGrant.create({
data: { resourceType: "PROJECT", resourceId: "p-cancel", principalType: "USER", principalId: "ou_cancel", role: "MANAGE" },
});
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_cancel" },
action: "agent.cancel",
resource: { type: "PROJECT", id: "p-cancel" },
});
expect(decision.allowed).toBe(true);
expect(decision.requiredRole).toBe("MANAGE");
});
it("agent.cancel is denied to an edit grant (below the MANAGE default)", async () => {
await prisma.project.create({ data: { id: "p-cancel-edit", name: "Cancel Edit", workspaceDir: "/tmp/cancel-edit" } });
await prisma.user.create({ data: { id: "u-cancel-edit", feishuOpenId: "ou_cancel_edit", displayName: "Editor" } });
await prisma.permissionGrant.create({
data: { resourceType: "PROJECT", resourceId: "p-cancel-edit", principalType: "USER", principalId: "ou_cancel_edit", role: "EDIT" },
});
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_cancel_edit" },
action: "agent.cancel",
resource: { type: "PROJECT", id: "p-cancel-edit" },
});
expect(decision.allowed).toBe(false);
});
it("agentCancel DISABLED policy denies cancel even for a manage grant", async () => {
await prisma.project.create({ data: { id: "p-cancel-off", name: "Cancel Off", workspaceDir: "/tmp/cancel-off" } });
await prisma.user.create({ data: { id: "u-cancel-off", feishuOpenId: "ou_cancel_off", displayName: "Manager Off" } });
await prisma.permissionGrant.create({
data: { resourceType: "PROJECT", resourceId: "p-cancel-off", principalType: "USER", principalId: "ou_cancel_off", role: "MANAGE" },
});
await prisma.permissionSettings.create({
data: defaultProjectSettings("p-cancel-off", { agentCancel: "DISABLED" }),
});
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_cancel_off" },
action: "agent.cancel",
resource: { type: "PROJECT", id: "p-cancel-off" },
});
expect(decision.allowed).toBe(false);
expect(decision.reason).toContain("disables this action");
});
});
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; agentCancel?: string } = {},
) {
return {
resourceType: "PROJECT" as const,
resourceId: projectId,
externalShare: "ROLE",
comment: "ROLE",
copyDownload: "ROLE",
collaboratorMgmt: "ROLE",
agentTrigger: overrides.agentTrigger ?? "ROLE",
agentCancel: overrides.agentCancel ?? "ROLE",
};
}