forked from EduCraft/curriculum-project-hub
feat(hub): RoleEntry 完整 bundle + per-run tool 白名单 + per-role 触发权限
RoleEntry 扩成 (model, systemPrompt, tools) bundle,id 兼任 slash command 名。 ToolRegistry.subset() 支持按白名单构造 per-run 视图,模型永远看不到 role 外的工具。 trigger 解析 /<role> 命令,查 RoleEntry 组装 systemPrompt + 子集 registry 传给 runner。 新增 RoleTriggerGrant 表 + canTriggerRole gate,与 ADR-0004 canTriggerAgent 串联: 先问'能不能触发 agent',再问'能触发哪个 role'。未配置 role 放行(back-compat)。 - models.ts: RoleEntry 加 systemPrompt + tools 字段 - tools.ts: ToolRegistry.subset(names) 返回共享 handler 的子集视图 - trigger.ts: extractRole 解析 slash; 传 systemPrompt + runTools; catch 链容错 P2025 - runner.ts: RunRequest.systemPrompt 改 string | undefined (exactOptionalPropertyTypes) - schema.prisma + migration: RoleTriggerGrant(projectId, roleId, principal, revokedAt) - permission.ts: canTriggerRole gate (有 grant 记录即白名单模式,含 revoked) - server.ts: draft/review 两个 role 加 systemPrompt + tools 白名单 - 测试: role-permission.test.ts (5) + trigger.test.ts (+3), 53 全绿
This commit is contained in:
@@ -22,6 +22,7 @@ export async function resetDb(): Promise<void> {
|
||||
"AuditEntry",
|
||||
"PermissionSettings",
|
||||
"PermissionGrant",
|
||||
"RoleTriggerGrant",
|
||||
"ProjectAgentLock",
|
||||
"AgentRun",
|
||||
"AgentSession",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||||
import { prisma, resetDb } from "./helpers.js";
|
||||
import { canTriggerRole } from "../../src/permission.js";
|
||||
|
||||
describe("canTriggerRole (integration, per-role gate)", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("allows when role is unconfigured on the project (back-compat: open)", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-role-1", name: "T", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
const r = await canTriggerRole(prisma, project.id, "draft", "ou_a");
|
||||
expect(r.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("allows when principal holds an active grant for the role", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-role-2", name: "T", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: project.id, roleId: "review", principal: "ou_a" },
|
||||
});
|
||||
const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
|
||||
expect(r.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("denies when grants exist for the role but principal has none", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-role-3", name: "T", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
// Someone else has the role; ou_b does not.
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: project.id, roleId: "review", principal: "ou_a" },
|
||||
});
|
||||
const r = await canTriggerRole(prisma, project.id, "review", "ou_b");
|
||||
expect(r.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("denies when the principal's grant was revoked", async () => {
|
||||
const project = await prisma.project.create({
|
||||
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() },
|
||||
});
|
||||
const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
|
||||
expect(r.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("scopes grants per-project (grant on p1 does not allow on p2)", async () => {
|
||||
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" },
|
||||
});
|
||||
// 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" },
|
||||
});
|
||||
const onP1 = await canTriggerRole(prisma, p1.id, "review", "ou_a");
|
||||
expect(onP1.allowed).toBe(true);
|
||||
const onP2 = await canTriggerRole(prisma, p2.id, "review", "ou_a");
|
||||
expect(onP2.allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,10 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
tools = new ToolRegistry();
|
||||
models = new InMemoryModelRegistry(
|
||||
[{ id: "mock-model", label: "Mock", toolCapable: true }],
|
||||
[{ id: "draft", label: "草稿", defaultModel: "mock-model" }],
|
||||
[
|
||||
{ id: "draft", label: "草稿", defaultModel: "mock-model", systemPrompt: undefined, tools: undefined },
|
||||
{ id: "review", label: "审校", defaultModel: "mock-model", systemPrompt: undefined, tools: ["read_file"] },
|
||||
],
|
||||
);
|
||||
rt = mockFeishuRuntime();
|
||||
});
|
||||
@@ -57,7 +60,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
// A status card was sent.
|
||||
expect(rt.sentCards.length).toBeGreaterThanOrEqual(1);
|
||||
expect(rt.sentTexts).toContain("已开始处理(model: mock-model)。");
|
||||
expect(rt.sentTexts).toContain("已开始处理(role: draft, model: mock-model)。");
|
||||
});
|
||||
|
||||
it("rejects a sender without edit grant (ADR-0004)", async () => {
|
||||
@@ -198,6 +201,52 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
expect(runs[0]?.prompt).toBe("/unknown");
|
||||
});
|
||||
});
|
||||
|
||||
it("denies /review when sender has no role grant (per-role gate)", async () => {
|
||||
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" },
|
||||
});
|
||||
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
|
||||
|
||||
await trigger(makeEvent("chat-10", "@_user_1 /review 看看这节"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain("无权限使用角色 review。");
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(0);
|
||||
});
|
||||
|
||||
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" },
|
||||
});
|
||||
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
|
||||
|
||||
await trigger(makeEvent("chat-11", "@_user_1 /review 看看这节"), rt);
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.status).toBe("COMPLETED");
|
||||
expect(runs[0]?.metadata).toMatchObject({ roleId: "review" });
|
||||
});
|
||||
});
|
||||
|
||||
it("extractRole: /draft sets roleId=draft, strips command from prompt", async () => {
|
||||
await seedProject("proj-12", "chat-12");
|
||||
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
|
||||
|
||||
await trigger(makeEvent("chat-12", "@_user_1 /draft 写第三单元"), rt);
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.prompt).toBe("写第三单元");
|
||||
expect(runs[0]?.metadata).toMatchObject({ roleId: "draft" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
Reference in New Issue
Block a user