forked from bai/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:
@@ -0,0 +1,27 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RoleTriggerGrant" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"roleId" TEXT NOT NULL,
|
||||||
|
"principal" TEXT NOT NULL,
|
||||||
|
"createdByUserId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"revokedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "RoleTriggerGrant_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RoleTriggerGrant_projectId_roleId_revokedAt_idx" ON "RoleTriggerGrant"("projectId", "roleId", "revokedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RoleTriggerGrant_principal_revokedAt_idx" ON "RoleTriggerGrant"("principal", "revokedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RoleTriggerGrant_projectId_roleId_principal_revokedAt_key" ON "RoleTriggerGrant"("projectId", "roleId", "principal", "revokedAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoleTriggerGrant" ADD CONSTRAINT "RoleTriggerGrant_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoleTriggerGrant" ADD CONSTRAINT "RoleTriggerGrant_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -40,6 +40,7 @@ model User {
|
|||||||
heldLocks ProjectAgentLock[] @relation("lockHolder")
|
heldLocks ProjectAgentLock[] @relation("lockHolder")
|
||||||
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
|
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
|
||||||
permissionGrants PermissionGrant[] @relation("grantCreator")
|
permissionGrants PermissionGrant[] @relation("grantCreator")
|
||||||
|
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
|
||||||
auditEntries AuditEntry[] @relation("auditActor")
|
auditEntries AuditEntry[] @relation("auditActor")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ model Project {
|
|||||||
agentLock ProjectAgentLock?
|
agentLock ProjectAgentLock?
|
||||||
permissionGrants PermissionGrant[] @relation("projectGrants")
|
permissionGrants PermissionGrant[] @relation("projectGrants")
|
||||||
permissionSettings PermissionSettings[] @relation("projectSettings")
|
permissionSettings PermissionSettings[] @relation("projectSettings")
|
||||||
|
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
|
||||||
|
|
||||||
@@index([archivedAt])
|
@@index([archivedAt])
|
||||||
}
|
}
|
||||||
@@ -260,6 +262,34 @@ model PermissionSettings {
|
|||||||
@@index([resourceType, resourceId])
|
@@index([resourceType, resourceId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Per-role agent trigger grant. Orthogonal to ADR-0004's PermissionGrant:
|
||||||
|
/// ADR-0004 decides "can trigger an agent at all" (triggerAgent capability,
|
||||||
|
/// edit+ role); this table decides "can trigger *which* agent role" (e.g.
|
||||||
|
/// /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;
|
||||||
|
/// 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
|
||||||
|
createdByUserId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
revokedAt DateTime?
|
||||||
|
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
// --- Audit (ADR Audit, content OPEN) -------------------------------------
|
// --- Audit (ADR Audit, content OPEN) -------------------------------------
|
||||||
|
|
||||||
/// spec AuditEntry: minimal skeleton — one entry relates to a run. Event type,
|
/// spec AuditEntry: minimal skeleton — one entry relates to a run. Event type,
|
||||||
|
|||||||
+23
-5
@@ -9,11 +9,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A named role preset that maps to a default model. Roles are **data**, not a
|
* A named role preset — the full per-run agent bundle. Roles are **data**, not
|
||||||
* code enum: admin/teachers define them (ADR-0017: role-based routing is
|
* a code enum: admin/teachers define them (ADR-0017: role-based routing is
|
||||||
* product config, not a spec invariant). `roleId` is an opaque string here —
|
* product config, not a spec invariant). `roleId` is an opaque string and
|
||||||
* the registry holds the role set, so new roles are added by configuration,
|
* doubles as the slash-command name (`/draft ...`) — the registry holds the
|
||||||
* not by editing this file.
|
* role set, so new roles are added by configuration, not by editing code.
|
||||||
|
*
|
||||||
|
* A role bundles everything that distinguishes one agent persona from another:
|
||||||
|
* model, system prompt, and the tool surface (files / cph / feishu / skills /
|
||||||
|
* mcps). Per-run tool whitelisting is enforced by {@link ToolRegistry.subset};
|
||||||
|
* the model never sees tools outside its role's whitelist, even if it tries to
|
||||||
|
* call them — security does not rely on the system prompt.
|
||||||
*/
|
*/
|
||||||
export interface RoleEntry {
|
export interface RoleEntry {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
@@ -21,6 +27,18 @@ export interface RoleEntry {
|
|||||||
readonly label: string;
|
readonly label: string;
|
||||||
/** Default model id for runs under this role, if a routing rule is set. */
|
/** Default model id for runs under this role, if a routing rule is set. */
|
||||||
readonly defaultModel: string | undefined;
|
readonly defaultModel: string | undefined;
|
||||||
|
/**
|
||||||
|
* System prompt seeding the agent's persona/instructions. Prepended to the
|
||||||
|
* run's messages only at session start (resume reads it from the transcript,
|
||||||
|
* see runner.ts). `undefined` ⇒ no system prompt (bare run).
|
||||||
|
*/
|
||||||
|
readonly systemPrompt: string | undefined;
|
||||||
|
/**
|
||||||
|
* Tool names this role may use (whitelist). `undefined` ⇒ the full registered
|
||||||
|
* set (back-compat / unrestricted roles). An empty array ⇒ no tools at all.
|
||||||
|
* Names not present in the registry are silently dropped at run setup.
|
||||||
|
*/
|
||||||
|
readonly tools: readonly string[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A model the admin has enabled for use by the Hub. */
|
/** A model the admin has enabled for use by the Hub. */
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export interface RunRequest {
|
|||||||
/** Resolved model id (already chosen by the caller per ADR-0017). */
|
/** Resolved model id (already chosen by the caller per ADR-0017). */
|
||||||
readonly model: string;
|
readonly model: string;
|
||||||
readonly project: ProjectContext;
|
readonly project: ProjectContext;
|
||||||
readonly systemPrompt?: string;
|
readonly systemPrompt: string | undefined;
|
||||||
/** Iteration cap before the run is returned as `length`. Default 25. */
|
/** Iteration cap before the run is returned as `length`. Default 25. */
|
||||||
readonly maxIterations?: number;
|
readonly maxIterations?: number;
|
||||||
/** Path to the session transcript JSONL. If set, prior messages are loaded
|
/** Path to the session transcript JSONL. If set, prior messages are loaded
|
||||||
|
|||||||
@@ -60,6 +60,31 @@ export class ToolRegistry {
|
|||||||
}
|
}
|
||||||
return tool.execute(args, ctx);
|
return tool.execute(args, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a per-run view restricted to `names`. Names not registered are
|
||||||
|
* silently dropped (a role's whitelist may name tools that a particular Hub
|
||||||
|
* instance doesn't register). The returned registry shares the handler
|
||||||
|
* references — no copy of the closures.
|
||||||
|
*
|
||||||
|
* Per-role tool whitelisting (ADR-0017: role-based routing is product
|
||||||
|
* config). The runner receives a subset registry so the model literally
|
||||||
|
* never sees tools outside its role's whitelist: the tool list sent to the
|
||||||
|
* provider is `subset.specs()`, and `execute` on a name not in the subset
|
||||||
|
* returns an error.
|
||||||
|
*/
|
||||||
|
subset(names: readonly string[]): ToolRegistry {
|
||||||
|
const allowed = new Set(names);
|
||||||
|
const view = new ToolRegistry();
|
||||||
|
for (const [name, tool] of this.byName) {
|
||||||
|
if (allowed.has(name)) {
|
||||||
|
// Bypass the duplicate check: we're constructing from an already-valid
|
||||||
|
// registry, not re-registering.
|
||||||
|
view.byName.set(name, tool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return view;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+67
-10
@@ -21,7 +21,7 @@ import type { ToolRegistry } from "../agent/tools.js";
|
|||||||
import type { ModelRegistry } from "../agent/models.js";
|
import type { ModelRegistry } from "../agent/models.js";
|
||||||
import { runAgent, type ProjectContext } from "../agent/runner.js";
|
import { runAgent, type ProjectContext } from "../agent/runner.js";
|
||||||
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
|
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
|
||||||
import { canTriggerAgent } from "../permission.js";
|
import { canTriggerAgent, canTriggerRole } from "../permission.js";
|
||||||
import { transcriptPath } from "../agent/transcript.js";
|
import { transcriptPath } from "../agent/transcript.js";
|
||||||
|
|
||||||
interface TriggerDeps {
|
interface TriggerDeps {
|
||||||
@@ -132,11 +132,31 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ADR-0017: role-as-data; model resolved by registry. "draft" is the
|
// ADR-0017: role-as-data. Parse a leading `/<role>` command; unknown
|
||||||
// fallback role id; real wiring lets the trigger carry a role hint (slash
|
// slashes are left as literal text (extractRole returns null). Falls back
|
||||||
// command, card selector). The registry degrades unknown roles gracefully.
|
// to "draft" when no role is named — the registry degrades gracefully.
|
||||||
const model = deps.models.resolve(undefined, "draft");
|
const { roleId: parsedRole, prompt: cleanPrompt } = extractRole(prompt, deps.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.
|
||||||
|
if (senderOpenId !== "") {
|
||||||
|
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 sendText(rt, chatId, `无权限使用角色 ${roleId}。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const role = deps.models.role(roleId);
|
||||||
|
const model = deps.models.resolve(undefined, roleId);
|
||||||
const providerId = deps.provider.id;
|
const providerId = deps.provider.id;
|
||||||
|
// Per-role bundle (ADR-0017): systemPrompt seeds persona; tools whitelist
|
||||||
|
// is enforced by constructing a per-run registry subset. `tools: undefined`
|
||||||
|
// means the full registered set (unrestricted role).
|
||||||
|
const systemPrompt = role?.systemPrompt;
|
||||||
|
const runTools =
|
||||||
|
role?.tools !== undefined ? deps.tools.subset(role.tools) : deps.tools;
|
||||||
|
|
||||||
// ADR-0017: provider+model-bound session. Reuse if one exists for this
|
// ADR-0017: provider+model-bound session. Reuse if one exists for this
|
||||||
// (project, provider, model) triple; otherwise create.
|
// (project, provider, model) triple; otherwise create.
|
||||||
@@ -156,7 +176,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
projectId,
|
projectId,
|
||||||
provider: providerId,
|
provider: providerId,
|
||||||
model,
|
model,
|
||||||
title: prompt.slice(0, 40) || null,
|
title: cleanPrompt.slice(0, 40) || null,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -169,10 +189,10 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
requestedByUserId: null, // sender→user mapping is OPEN (principal sub-typology)
|
requestedByUserId: null, // sender→user mapping is OPEN (principal sub-typology)
|
||||||
entrypoint: "FEISHU",
|
entrypoint: "FEISHU",
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
prompt,
|
prompt: cleanPrompt,
|
||||||
model,
|
model,
|
||||||
provider: providerId,
|
provider: providerId,
|
||||||
metadata: {},
|
metadata: { roleId },
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
@@ -190,7 +210,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendText(rt, chatId, `已开始处理(model: ${model})。`);
|
await sendText(rt, chatId, `已开始处理(role: ${roleId}, model: ${model})。`);
|
||||||
|
|
||||||
const projectCtx: ProjectContext = {
|
const projectCtx: ProjectContext = {
|
||||||
projectId,
|
projectId,
|
||||||
@@ -199,7 +219,14 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Run the agent — fire-and-forget from the ws handler's perspective.
|
// Run the agent — fire-and-forget from the ws handler's perspective.
|
||||||
runAgent(deps.provider, deps.tools, { prompt, model, project: projectCtx, transcriptPath: transcriptPath(project.workspaceDir, session.id) })
|
// Per-run tools + systemPrompt come from the role bundle (ADR-0017).
|
||||||
|
runAgent(deps.provider, runTools, {
|
||||||
|
prompt: cleanPrompt,
|
||||||
|
model,
|
||||||
|
project: projectCtx,
|
||||||
|
systemPrompt,
|
||||||
|
transcriptPath: transcriptPath(project.workspaceDir, session.id),
|
||||||
|
})
|
||||||
.then(async (result) => {
|
.then(async (result) => {
|
||||||
await deps.prisma.agentRun.update({
|
await deps.prisma.agentRun.update({
|
||||||
where: { id: run.id },
|
where: { id: run.id },
|
||||||
@@ -224,10 +251,17 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
}));
|
}));
|
||||||
})
|
})
|
||||||
.catch(async (e) => {
|
.catch(async (e) => {
|
||||||
|
// The run may have been removed (admin force-delete, or test reset)
|
||||||
|
// between creation and this callback. A P2025 from update is not
|
||||||
|
// actionable here — log and still post the status card.
|
||||||
|
try {
|
||||||
await deps.prisma.agentRun.update({
|
await deps.prisma.agentRun.update({
|
||||||
where: { id: run.id },
|
where: { id: run.id },
|
||||||
data: { status: "FAILED", error: String(e), finishedAt: new Date() },
|
data: { status: "FAILED", error: String(e), finishedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
} catch (updateErr) {
|
||||||
|
deps.logger.warn({ runId: run.id, err: String(updateErr) }, "trigger: could not mark run FAILED (already gone?)");
|
||||||
|
}
|
||||||
await sendCard(rt, chatId, buildRunStatusCard({
|
await sendCard(rt, chatId, buildRunStatusCard({
|
||||||
status: "处理出错",
|
status: "处理出错",
|
||||||
model,
|
model,
|
||||||
@@ -275,3 +309,26 @@ export function extractPrompt(msg: MessageReceiveEvent["message"]): string | nul
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Parse a leading `/<role>` command from a prompt (e.g. `/draft 帮我写教案`).
|
||||||
|
* Returns the role id and the remaining prompt with the command stripped.
|
||||||
|
* Control commands already handled upstream (`/new`, `/resume`, `/reset`) are
|
||||||
|
* ignored here — they never reach this function.
|
||||||
|
*
|
||||||
|
* Unknown `/foo` that isn't a registered role: returns `null` role and the
|
||||||
|
* original prompt unchanged (the slash is treated as literal text). Role
|
||||||
|
* resolution still happens via the registry's fallback.
|
||||||
|
*/
|
||||||
|
export function extractRole(
|
||||||
|
prompt: string,
|
||||||
|
registry: { role(id: string): unknown },
|
||||||
|
): { roleId: string | null; prompt: string } {
|
||||||
|
const m = /^\/(\S+)\s*/.exec(prompt);
|
||||||
|
if (m === null || m[1] === undefined) return { roleId: null, prompt };
|
||||||
|
const roleId = m[1];
|
||||||
|
if (registry.role(roleId) === undefined) {
|
||||||
|
// Unknown slash command — leave the prompt as-is (no silent role switch).
|
||||||
|
return { roleId: null, prompt };
|
||||||
|
}
|
||||||
|
return { roleId, prompt: prompt.slice(m[0].length) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,6 +68,57 @@ export async function canTriggerAgent(
|
|||||||
return { allowed: false, reason: `permission check failed: ${e instanceof Error ? e.message : String(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).
|
||||||
|
*/
|
||||||
|
export async function canTriggerRole(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
projectId: string,
|
||||||
|
roleId: string,
|
||||||
|
principal: string,
|
||||||
|
): Promise<PermissionResult> {
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
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 },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (grant !== null) {
|
||||||
|
return { allowed: true, reason: `granted role ${roleId}` };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `no active ${roleId} grant for ${principal} on project ${projectId}`,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
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.
|
/// Whether a role grants edit capability (edit or manage). Exported for tests.
|
||||||
export function roleGrantsEdit(role: PermissionRole): boolean {
|
export function roleGrantsEdit(role: PermissionRole): boolean {
|
||||||
|
|||||||
+16
-2
@@ -52,8 +52,22 @@ async function main(): Promise<void> {
|
|||||||
{ id: "anthropic/claude-3.5-sonnet", label: "Claude 3.5 Sonnet", toolCapable: true },
|
{ id: "anthropic/claude-3.5-sonnet", label: "Claude 3.5 Sonnet", toolCapable: true },
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
{ id: "draft", label: "草稿", defaultModel: "z-ai/glm-4.6" },
|
{
|
||||||
{ id: "review", label: "审校", defaultModel: "anthropic/claude-3.5-sonnet" },
|
id: "draft",
|
||||||
|
label: "草稿",
|
||||||
|
defaultModel: "z-ai/glm-4.6",
|
||||||
|
systemPrompt: undefined,
|
||||||
|
// Drafting needs full workspace + cph access.
|
||||||
|
tools: ["read_file", "write_file", "list_files", "cph_check", "cph_build", "feishu_read_context"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "review",
|
||||||
|
label: "审校",
|
||||||
|
defaultModel: "anthropic/claude-3.5-sonnet",
|
||||||
|
systemPrompt: undefined,
|
||||||
|
// Review is read-only on artifacts; no write_file, no build.
|
||||||
|
tools: ["read_file", "list_files", "cph_check", "feishu_read_context"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export async function resetDb(): Promise<void> {
|
|||||||
"AuditEntry",
|
"AuditEntry",
|
||||||
"PermissionSettings",
|
"PermissionSettings",
|
||||||
"PermissionGrant",
|
"PermissionGrant",
|
||||||
|
"RoleTriggerGrant",
|
||||||
"ProjectAgentLock",
|
"ProjectAgentLock",
|
||||||
"AgentRun",
|
"AgentRun",
|
||||||
"AgentSession",
|
"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();
|
tools = new ToolRegistry();
|
||||||
models = new InMemoryModelRegistry(
|
models = new InMemoryModelRegistry(
|
||||||
[{ id: "mock-model", label: "Mock", toolCapable: true }],
|
[{ 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();
|
rt = mockFeishuRuntime();
|
||||||
});
|
});
|
||||||
@@ -57,7 +60,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
|
|
||||||
// A status card was sent.
|
// A status card was sent.
|
||||||
expect(rt.sentCards.length).toBeGreaterThanOrEqual(1);
|
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 () => {
|
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");
|
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 () => {
|
afterAll(async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user