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
+64 -15
View File
@@ -15,7 +15,7 @@ import { sendText, sendTextMessage, patchTextMessage, reactToMessage, downloadMe
import { runAgent as defaultRunAgent, type ProjectContext, type RunRequest, type RunResult } from "../agent/runner.js";
import type { RuntimeSettings } from "../settings/runtime.js";
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
import { canTriggerAgent, canTriggerRole } from "../permission.js";
import { createPermissionAuthorizer, type AuthorizationDecision, type PermissionAuthorizer } from "../permission.js";
import { writeAudit } from "../audit.js";
import { PatchableTextStream } from "./textStream.js";
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
@@ -25,6 +25,7 @@ interface TriggerDeps {
readonly settings: RuntimeSettings;
readonly logger: FastifyBaseLogger;
readonly runAgent?: (req: RunRequest) => Promise<RunResult>;
readonly authorizer?: PermissionAuthorizer | undefined;
}
/**
@@ -33,6 +34,7 @@ interface TriggerDeps {
* agent run is the long leg); the lock guarantees one run per project at a time.
*/
export function makeTriggerHandler(deps: TriggerDeps) {
const authorizer = deps.authorizer ?? createPermissionAuthorizer(deps.prisma);
return async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void> => {
const runAgent = deps.runAgent ?? defaultRunAgent;
const msg = event.message;
@@ -77,8 +79,8 @@ export function makeTriggerHandler(deps: TriggerDeps) {
return;
}
const projectId = binding.projectId;
// ADR-0004: triggerAgent requires edit on the project. principal=sender
// open_id (sub-typology OPEN — pragmatic choice, see permission.ts).
// ADR-0019: triggerAgent is authorized through actor → principal-set
// resolution, then grant/settings composition.
// A missing open_id is treated as an untrusted event: deny explicitly
// rather than skipping the check (fail closed).
const senderOpenId = event.sender.sender_id.open_id ?? "";
@@ -88,10 +90,20 @@ export function makeTriggerHandler(deps: TriggerDeps) {
await sendText(rt, chatId, "无法识别发送者,拒绝触发。");
return;
}
const perm = await canTriggerAgent(deps.prisma, projectId, senderOpenId);
if (!perm.allowed) {
deps.logger.info({ projectId, senderOpenId, reason: perm.reason }, "feishu trigger: permission denied");
await writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId, action: "trigger.denied", metadata: { reason: perm.reason } });
const actor = { feishuOpenId: senderOpenId, chatId };
const triggerDecision = await authorizer.can({
actor,
action: "agent.trigger",
resource: { type: "PROJECT", id: projectId },
});
if (!triggerDecision.allowed) {
deps.logger.info({ projectId, senderOpenId, reason: triggerDecision.reason }, "feishu trigger: permission denied");
await writeAudit(deps.prisma, {
projectId,
...(triggerDecision.actorUserId !== undefined ? { actorUserId: triggerDecision.actorUserId } : {}),
action: "trigger.denied",
metadata: { decision: auditDecision(triggerDecision) },
});
await sendText(rt, chatId, "无权限触发。");
return;
}
@@ -186,13 +198,23 @@ export function makeTriggerHandler(deps: TriggerDeps) {
const models = await deps.settings.modelRegistry({ projectId });
const { roleId: parsedRole, prompt: agentPrompt } = extractRole(cleanPrompt, 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.
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 writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId, action: "trigger.role_denied", metadata: { roleId, reason: rolePerm.reason } });
// ADR-0019: role trigger is the second gate after project agent.trigger.
// Unconfigured roles remain open for back-compat; configured roles require
// a matching active RoleTriggerGrant for any resolved principal.
const roleDecision = await authorizer.can({
actor,
action: "role.trigger",
resource: { type: "PROJECT", id: projectId },
roleId,
});
if (!roleDecision.allowed) {
deps.logger.info({ projectId, roleId, senderOpenId, reason: roleDecision.reason }, "feishu trigger: role permission denied");
await writeAudit(deps.prisma, {
projectId,
...(roleDecision.actorUserId !== undefined ? { actorUserId: roleDecision.actorUserId } : {}),
action: "trigger.role_denied",
metadata: { roleId, decision: auditDecision(roleDecision) },
});
await sendText(rt, chatId, `无权限使用角色 ${roleId}`);
return;
}
@@ -235,7 +257,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
data: {
projectId,
sessionId: session.id,
requestedByUserId: null, // sender→user mapping is OPEN (principal sub-typology)
requestedByUserId: roleDecision.actorUserId ?? null,
entrypoint: "FEISHU",
status: "ACTIVE",
prompt: agentPrompt,
@@ -395,6 +417,33 @@ function mergeSessionMetadata(metadata: unknown, patch: SessionMetadata): Prisma
return base;
}
function auditDecision(decision: AuthorizationDecision): Record<string, unknown> {
return {
allowed: decision.allowed,
reason: decision.reason,
action: decision.action,
requiredRole: decision.requiredRole,
effectiveRole: decision.effectiveRole ?? null,
principals: decision.principals.map((principal) => ({
type: principal.type,
id: principal.id,
})),
matchedGrant: decision.matchedGrant === undefined
? null
: {
id: decision.matchedGrant.id,
principal: decision.matchedGrant.principal,
role: decision.matchedGrant.role,
},
matchedRoleGrant: decision.matchedRoleGrant === undefined
? null
: {
id: decision.matchedRoleGrant.id,
principal: decision.matchedRoleGrant.principal,
},
};
}
function withFileDeliveryInstructions(systemPrompt: string | undefined): string {
const fileDeliveryPrompt =
"When the user asks you to send, resend, attach, or provide a file, call the cph_hub send_file tool with the actual existing file path. " +