forked from bai/curriculum-project-hub
feat(hub): 支持教师团队权限
This commit is contained in:
+64
-15
@@ -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. " +
|
||||
|
||||
Reference in New Issue
Block a user