forked from EduCraft/curriculum-project-hub
afaf5bee09
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 全绿
127 lines
5.0 KiB
TypeScript
127 lines
5.0 KiB
TypeScript
/**
|
|
* Permission gate — ADR-0004 in code, project-scope.
|
|
*
|
|
* `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.
|
|
*/
|
|
import type { PrismaClient } from "@prisma/client";
|
|
import type { PermissionRole } from "@prisma/client";
|
|
|
|
/// The roles that grant `edit` capability (edit itself + manage, by monotonicity).
|
|
const EDIT_OR_HIGHER: ReadonlySet<PermissionRole> = new Set(["EDIT", "MANAGE"]);
|
|
|
|
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<PermissionResult> {
|
|
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 },
|
|
});
|
|
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}`,
|
|
};
|
|
} 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).
|
|
*/
|
|
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.
|
|
export function roleGrantsEdit(role: PermissionRole): boolean {
|
|
return EDIT_OR_HIGHER.has(role);
|
|
}
|