forked from EduCraft/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. " +
|
||||
|
||||
+39
-74
@@ -1,89 +1,64 @@
|
||||
/**
|
||||
* Permission gate — ADR-0004 in code, project-scope.
|
||||
* Permission compatibility exports.
|
||||
*
|
||||
* `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.
|
||||
* ADR-0019 moves production authorization behind PrincipalResolver +
|
||||
* PermissionAuthorizer. The wrappers below preserve the old helper names for
|
||||
* tests and incremental callers while routing project trigger checks through
|
||||
* the new authorizer.
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { PermissionRole } from "@prisma/client";
|
||||
import { createPermissionAuthorizer } from "./permissions/authorizer.js";
|
||||
|
||||
/// The roles that grant `edit` capability (edit itself + manage, by monotonicity).
|
||||
const EDIT_OR_HIGHER: ReadonlySet<PermissionRole> = new Set(["EDIT", "MANAGE"]);
|
||||
export { createPermissionAuthorizer, PrismaPermissionAuthorizer, roleGrantsEdit } from "./permissions/authorizer.js";
|
||||
export { PrismaPrincipalResolver, principalKey } from "./permissions/principals.js";
|
||||
export { syncExternalPrincipalMemberships } from "./permissions/externalSync.js";
|
||||
export type {
|
||||
AuthorizationAction,
|
||||
AuthorizationDecision,
|
||||
AuthorizationRequest,
|
||||
AuthorizationResource,
|
||||
PermissionAuthorizer,
|
||||
} from "./permissions/authorizer.js";
|
||||
export type {
|
||||
ActorInput,
|
||||
PrincipalRef,
|
||||
PrincipalResolution,
|
||||
PrincipalResolver,
|
||||
ResolvedPrincipal,
|
||||
} from "./permissions/principals.js";
|
||||
export type {
|
||||
ExternalPrincipalMembershipInput,
|
||||
ExternalPrincipalType,
|
||||
SyncExternalPrincipalMembershipsInput,
|
||||
SyncExternalPrincipalMembershipsResult,
|
||||
} from "./permissions/externalSync.js";
|
||||
|
||||
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 },
|
||||
const decision = await createPermissionAuthorizer(prisma).can({
|
||||
actor: { feishuOpenId: principal },
|
||||
action: "agent.trigger",
|
||||
resource: { type: "PROJECT", id: projectId },
|
||||
});
|
||||
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}`,
|
||||
};
|
||||
return { allowed: decision.allowed, reason: decision.reason };
|
||||
} 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).
|
||||
* Role-only legacy helper. New trigger code uses `role.trigger` on
|
||||
* PermissionAuthorizer so project edit+ and role grant composition happen in
|
||||
* one decision.
|
||||
*/
|
||||
export async function canTriggerRole(
|
||||
prisma: PrismaClient,
|
||||
@@ -92,10 +67,6 @@ export async function canTriggerRole(
|
||||
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 },
|
||||
@@ -103,9 +74,8 @@ export async function canTriggerRole(
|
||||
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 },
|
||||
where: { projectId, roleId, principalType: "USER", principalId: principal, revokedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (grant !== null) {
|
||||
@@ -119,8 +89,3 @@ export async function canTriggerRole(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { PermissionResourceType, PermissionRole, PrismaClient, PrincipalType } from "@prisma/client";
|
||||
import { PrismaPrincipalResolver, type ActorInput, type PrincipalRef, type PrincipalResolution, type PrincipalResolver } from "./principals.js";
|
||||
|
||||
export type AuthorizationAction =
|
||||
| "project.read"
|
||||
| "project.edit"
|
||||
| "collaborator.manage"
|
||||
| "agent.trigger"
|
||||
| "agent.cancel"
|
||||
| "role.trigger";
|
||||
|
||||
export interface AuthorizationResource {
|
||||
readonly type: PermissionResourceType;
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export interface AuthorizationRequest {
|
||||
readonly actor: ActorInput;
|
||||
readonly action: AuthorizationAction;
|
||||
readonly resource: AuthorizationResource;
|
||||
readonly roleId?: string | undefined;
|
||||
}
|
||||
|
||||
export interface MatchedPermissionGrant {
|
||||
readonly id: string;
|
||||
readonly principal: PrincipalRef;
|
||||
readonly role: PermissionRole;
|
||||
}
|
||||
|
||||
export interface MatchedRoleTriggerGrant {
|
||||
readonly id: string;
|
||||
readonly principal: PrincipalRef;
|
||||
}
|
||||
|
||||
export interface AuthorizationDecision {
|
||||
readonly allowed: boolean;
|
||||
readonly reason: string;
|
||||
readonly action: AuthorizationAction;
|
||||
readonly resource: AuthorizationResource;
|
||||
readonly actor: ActorInput;
|
||||
readonly actorUserId?: string | undefined;
|
||||
readonly principals: readonly PrincipalRef[];
|
||||
readonly requiredRole: PermissionRole;
|
||||
readonly effectiveRole?: PermissionRole | undefined;
|
||||
readonly matchedGrant?: MatchedPermissionGrant | undefined;
|
||||
readonly matchedRoleGrant?: MatchedRoleTriggerGrant | undefined;
|
||||
}
|
||||
|
||||
export interface PermissionAuthorizer {
|
||||
can(req: AuthorizationRequest): Promise<AuthorizationDecision>;
|
||||
}
|
||||
|
||||
export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly resolver: PrincipalResolver = new PrismaPrincipalResolver(prisma),
|
||||
) {}
|
||||
|
||||
async can(req: AuthorizationRequest): Promise<AuthorizationDecision> {
|
||||
if (req.action === "role.trigger" && (req.roleId === undefined || req.roleId === "")) {
|
||||
throw new Error("role.trigger authorization requires roleId");
|
||||
}
|
||||
const resolution = await this.resolver.resolveActor(req.actor);
|
||||
const threshold = await this.requiredRole(req);
|
||||
if (threshold === "DISABLED") {
|
||||
return this.decision(req, resolution, {
|
||||
allowed: false,
|
||||
reason: "permission setting disables this action",
|
||||
requiredRole: defaultRequiredRole(req.action),
|
||||
});
|
||||
}
|
||||
|
||||
const grant = await this.bestGrant(req.resource, resolution.principals, threshold);
|
||||
if (grant === undefined) {
|
||||
return this.decision(req, resolution, {
|
||||
allowed: false,
|
||||
reason: `no active ${threshold}+ grant for resolved principals`,
|
||||
requiredRole: threshold,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.action !== "role.trigger") {
|
||||
return this.decision(req, resolution, {
|
||||
allowed: true,
|
||||
reason: `granted by ${grant.principal.type}:${grant.principal.id} ${grant.role}`,
|
||||
requiredRole: threshold,
|
||||
effectiveRole: grant.role,
|
||||
matchedGrant: grant,
|
||||
});
|
||||
}
|
||||
|
||||
const roleGate = await this.roleTriggerGrant(req.resource, req.roleId!, resolution.principals);
|
||||
if (roleGate.status === "open") {
|
||||
return this.decision(req, resolution, {
|
||||
allowed: true,
|
||||
reason: `role ${req.roleId} unconfigured on project (open)`,
|
||||
requiredRole: threshold,
|
||||
effectiveRole: grant.role,
|
||||
matchedGrant: grant,
|
||||
});
|
||||
}
|
||||
if (roleGate.grant !== undefined) {
|
||||
return this.decision(req, resolution, {
|
||||
allowed: true,
|
||||
reason: `role ${req.roleId} granted by ${roleGate.grant.principal.type}:${roleGate.grant.principal.id}`,
|
||||
requiredRole: threshold,
|
||||
effectiveRole: grant.role,
|
||||
matchedGrant: grant,
|
||||
matchedRoleGrant: roleGate.grant,
|
||||
});
|
||||
}
|
||||
|
||||
return this.decision(req, resolution, {
|
||||
allowed: false,
|
||||
reason: `no active role ${req.roleId} grant for resolved principals`,
|
||||
requiredRole: threshold,
|
||||
effectiveRole: grant.role,
|
||||
matchedGrant: grant,
|
||||
});
|
||||
}
|
||||
|
||||
private async requiredRole(req: AuthorizationRequest): Promise<PermissionRole | "DISABLED"> {
|
||||
let required = defaultRequiredRole(req.action);
|
||||
if ((req.action === "agent.trigger" || req.action === "role.trigger") && req.resource.type === "PROJECT") {
|
||||
const settings = await this.prisma.permissionSettings.findFirst({
|
||||
where: { resourceType: req.resource.type, resourceId: req.resource.id },
|
||||
select: { agentTrigger: true },
|
||||
});
|
||||
const policy = normalizePolicy(settings?.agentTrigger);
|
||||
if (policy === "DISABLED") return "DISABLED";
|
||||
if (policy === "MANAGE_ONLY") required = "MANAGE";
|
||||
}
|
||||
return required;
|
||||
}
|
||||
|
||||
private async bestGrant(
|
||||
resource: AuthorizationResource,
|
||||
principals: readonly PrincipalRef[],
|
||||
threshold: PermissionRole,
|
||||
): Promise<MatchedPermissionGrant | undefined> {
|
||||
const grants = await this.prisma.permissionGrant.findMany({
|
||||
where: {
|
||||
resourceType: resource.type,
|
||||
resourceId: resource.id,
|
||||
revokedAt: null,
|
||||
OR: principalWhere(principals),
|
||||
},
|
||||
select: { id: true, principalType: true, principalId: true, role: true },
|
||||
});
|
||||
const sufficient = grants
|
||||
.filter((grant) => roleRank(grant.role) >= roleRank(threshold))
|
||||
.sort((a, b) => roleRank(b.role) - roleRank(a.role))[0];
|
||||
if (sufficient === undefined) return undefined;
|
||||
return {
|
||||
id: sufficient.id,
|
||||
principal: { type: sufficient.principalType, id: sufficient.principalId },
|
||||
role: sufficient.role,
|
||||
};
|
||||
}
|
||||
|
||||
private async roleTriggerGrant(
|
||||
resource: AuthorizationResource,
|
||||
roleId: string,
|
||||
principals: readonly PrincipalRef[],
|
||||
): Promise<{ readonly status: "open" } | { readonly status: "configured"; readonly grant?: MatchedRoleTriggerGrant | undefined }> {
|
||||
if (resource.type !== "PROJECT") {
|
||||
return { status: "configured" };
|
||||
}
|
||||
const anyGrant = await this.prisma.roleTriggerGrant.findFirst({
|
||||
where: { projectId: resource.id, roleId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (anyGrant === null) return { status: "open" };
|
||||
|
||||
const grant = await this.prisma.roleTriggerGrant.findFirst({
|
||||
where: {
|
||||
projectId: resource.id,
|
||||
roleId,
|
||||
revokedAt: null,
|
||||
OR: principalWhere(principals),
|
||||
},
|
||||
select: { id: true, principalType: true, principalId: true },
|
||||
});
|
||||
if (grant === null) return { status: "configured" };
|
||||
return {
|
||||
status: "configured",
|
||||
grant: {
|
||||
id: grant.id,
|
||||
principal: { type: grant.principalType, id: grant.principalId },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private decision(
|
||||
req: AuthorizationRequest,
|
||||
resolution: PrincipalResolution,
|
||||
result: Omit<AuthorizationDecision, "action" | "resource" | "actor" | "actorUserId" | "principals">,
|
||||
): AuthorizationDecision {
|
||||
return {
|
||||
action: req.action,
|
||||
resource: req.resource,
|
||||
actor: req.actor,
|
||||
...(resolution.userId !== undefined ? { actorUserId: resolution.userId } : {}),
|
||||
principals: resolution.principals,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createPermissionAuthorizer(
|
||||
prisma: PrismaClient,
|
||||
resolver?: PrincipalResolver,
|
||||
): PermissionAuthorizer {
|
||||
return new PrismaPermissionAuthorizer(prisma, resolver);
|
||||
}
|
||||
|
||||
export function roleGrantsEdit(role: PermissionRole): boolean {
|
||||
return roleRank(role) >= roleRank("EDIT");
|
||||
}
|
||||
|
||||
export function roleRank(role: PermissionRole): number {
|
||||
switch (role) {
|
||||
case "READ": return 1;
|
||||
case "EDIT": return 2;
|
||||
case "MANAGE": return 3;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultRequiredRole(action: AuthorizationAction): PermissionRole {
|
||||
switch (action) {
|
||||
case "project.read": return "READ";
|
||||
case "project.edit": return "EDIT";
|
||||
case "agent.trigger": return "EDIT";
|
||||
case "role.trigger": return "EDIT";
|
||||
case "collaborator.manage": return "MANAGE";
|
||||
case "agent.cancel": return "MANAGE";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePolicy(policy: string | undefined): "ROLE" | "MANAGE_ONLY" | "DISABLED" {
|
||||
if (policy === undefined || policy.trim() === "") return "ROLE";
|
||||
const normalized = policy.trim().toUpperCase();
|
||||
if (normalized === "DISABLED" || normalized === "DENY" || normalized === "OFF") return "DISABLED";
|
||||
if (normalized === "MANAGE_ONLY") return "MANAGE_ONLY";
|
||||
return "ROLE";
|
||||
}
|
||||
|
||||
function principalWhere(principals: readonly PrincipalRef[]): Array<{ principalType: PrincipalType; principalId: string }> {
|
||||
return principals.map((principal) => ({
|
||||
principalType: principal.type,
|
||||
principalId: principal.id,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { PrismaClient, PrincipalType } from "@prisma/client";
|
||||
|
||||
export type ExternalPrincipalType = Extract<
|
||||
PrincipalType,
|
||||
"FEISHU_CHAT" | "FEISHU_DEPARTMENT" | "FEISHU_USER_GROUP"
|
||||
>;
|
||||
|
||||
export interface ExternalPrincipalMembershipInput {
|
||||
readonly feishuOpenId: string;
|
||||
readonly displayName: string;
|
||||
readonly principalType: ExternalPrincipalType;
|
||||
readonly principalId: string;
|
||||
}
|
||||
|
||||
export interface SyncExternalPrincipalMembershipsInput {
|
||||
readonly source: string;
|
||||
readonly replaceSource?: boolean | undefined;
|
||||
readonly memberships: readonly ExternalPrincipalMembershipInput[];
|
||||
}
|
||||
|
||||
export interface SyncExternalPrincipalMembershipsResult {
|
||||
readonly synced: number;
|
||||
readonly revoked: number;
|
||||
}
|
||||
|
||||
export async function syncExternalPrincipalMemberships(
|
||||
prisma: PrismaClient,
|
||||
input: SyncExternalPrincipalMembershipsInput,
|
||||
): Promise<SyncExternalPrincipalMembershipsResult> {
|
||||
const syncedAt = new Date();
|
||||
const incomingKeys = new Set<string>();
|
||||
let synced = 0;
|
||||
|
||||
for (const item of input.memberships) {
|
||||
assertExternalPrincipalType(item.principalType);
|
||||
const user = await prisma.user.upsert({
|
||||
where: { feishuOpenId: item.feishuOpenId },
|
||||
update: { displayName: item.displayName },
|
||||
create: {
|
||||
feishuOpenId: item.feishuOpenId,
|
||||
displayName: item.displayName,
|
||||
platformRoles: { create: { role: "TEACHER" } },
|
||||
},
|
||||
});
|
||||
incomingKeys.add(externalMembershipKey(user.id, item.principalType, item.principalId));
|
||||
const existing = await prisma.externalPrincipalMembership.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
principalType: item.principalType,
|
||||
principalId: item.principalId,
|
||||
source: input.source,
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing === null) {
|
||||
await prisma.externalPrincipalMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
principalType: item.principalType,
|
||||
principalId: item.principalId,
|
||||
source: input.source,
|
||||
syncedAt,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.externalPrincipalMembership.update({
|
||||
where: { id: existing.id },
|
||||
data: { syncedAt },
|
||||
});
|
||||
}
|
||||
synced++;
|
||||
}
|
||||
|
||||
let revoked = 0;
|
||||
if (input.replaceSource === true) {
|
||||
const active = await prisma.externalPrincipalMembership.findMany({
|
||||
where: { source: input.source, revokedAt: null },
|
||||
select: { id: true, userId: true, principalType: true, principalId: true },
|
||||
});
|
||||
const revokeIds = active
|
||||
.filter((membership) => !incomingKeys.has(externalMembershipKey(
|
||||
membership.userId,
|
||||
membership.principalType,
|
||||
membership.principalId,
|
||||
)))
|
||||
.map((membership) => membership.id);
|
||||
if (revokeIds.length > 0) {
|
||||
const result = await prisma.externalPrincipalMembership.updateMany({
|
||||
where: { id: { in: revokeIds } },
|
||||
data: { revokedAt: syncedAt },
|
||||
});
|
||||
revoked = result.count;
|
||||
}
|
||||
}
|
||||
|
||||
return { synced, revoked };
|
||||
}
|
||||
|
||||
function externalMembershipKey(userId: string, type: PrincipalType, id: string): string {
|
||||
return `${userId}:${type}:${id}`;
|
||||
}
|
||||
|
||||
function assertExternalPrincipalType(type: PrincipalType): asserts type is ExternalPrincipalType {
|
||||
if (type !== "FEISHU_CHAT" && type !== "FEISHU_DEPARTMENT" && type !== "FEISHU_USER_GROUP") {
|
||||
throw new Error(`unsupported external principal type: ${type}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { PrismaClient, PrincipalType } from "@prisma/client";
|
||||
|
||||
export interface PrincipalRef {
|
||||
readonly type: PrincipalType;
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export interface ActorInput {
|
||||
readonly feishuOpenId: string;
|
||||
readonly chatId?: string | undefined;
|
||||
}
|
||||
|
||||
export type PrincipalSource =
|
||||
| "actor-user"
|
||||
| "context-chat"
|
||||
| "team-membership"
|
||||
| "external-membership"
|
||||
| "team-external-binding";
|
||||
|
||||
export interface ResolvedPrincipal {
|
||||
readonly principal: PrincipalRef;
|
||||
readonly source: PrincipalSource;
|
||||
readonly via?: PrincipalRef | undefined;
|
||||
}
|
||||
|
||||
export interface PrincipalResolution {
|
||||
readonly actor: ActorInput;
|
||||
readonly userId?: string | undefined;
|
||||
readonly principals: readonly PrincipalRef[];
|
||||
readonly resolved: readonly ResolvedPrincipal[];
|
||||
}
|
||||
|
||||
export interface PrincipalResolver {
|
||||
resolveActor(actor: ActorInput): Promise<PrincipalResolution>;
|
||||
}
|
||||
|
||||
export class PrismaPrincipalResolver implements PrincipalResolver {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async resolveActor(actor: ActorInput): Promise<PrincipalResolution> {
|
||||
const resolved = new PrincipalCollector();
|
||||
resolved.add({ type: "USER", id: actor.feishuOpenId }, "actor-user");
|
||||
if (actor.chatId !== undefined && actor.chatId !== "") {
|
||||
resolved.add({ type: "FEISHU_CHAT", id: actor.chatId }, "context-chat");
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { feishuOpenId: actor.feishuOpenId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (user !== null) {
|
||||
const memberships = await this.prisma.teamMembership.findMany({
|
||||
where: { userId: user.id, revokedAt: null, team: { archivedAt: null } },
|
||||
select: { teamId: true },
|
||||
});
|
||||
for (const membership of memberships) {
|
||||
resolved.add({ type: "TEAM", id: membership.teamId }, "team-membership");
|
||||
}
|
||||
|
||||
const externalMemberships = await this.prisma.externalPrincipalMembership.findMany({
|
||||
where: { userId: user.id, revokedAt: null },
|
||||
select: { principalType: true, principalId: true },
|
||||
});
|
||||
for (const membership of externalMemberships) {
|
||||
resolved.add(
|
||||
{ type: membership.principalType, id: membership.principalId },
|
||||
"external-membership",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const externalPrincipals = resolved.principals().filter((principal) => isExternalPrincipal(principal.type));
|
||||
if (externalPrincipals.length > 0) {
|
||||
const bindings = await this.prisma.teamExternalBinding.findMany({
|
||||
where: {
|
||||
revokedAt: null,
|
||||
team: { archivedAt: null },
|
||||
OR: externalPrincipals.map((principal) => ({
|
||||
principalType: principal.type,
|
||||
principalId: principal.id,
|
||||
})),
|
||||
},
|
||||
select: { teamId: true, principalType: true, principalId: true },
|
||||
});
|
||||
for (const binding of bindings) {
|
||||
resolved.add(
|
||||
{ type: "TEAM", id: binding.teamId },
|
||||
"team-external-binding",
|
||||
{ type: binding.principalType, id: binding.principalId },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
actor,
|
||||
...(user !== null ? { userId: user.id } : {}),
|
||||
principals: resolved.principals(),
|
||||
resolved: resolved.entries(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function principalKey(principal: PrincipalRef): string {
|
||||
return `${principal.type}:${principal.id}`;
|
||||
}
|
||||
|
||||
function isExternalPrincipal(type: PrincipalType): boolean {
|
||||
return type === "FEISHU_CHAT" || type === "FEISHU_DEPARTMENT" || type === "FEISHU_USER_GROUP";
|
||||
}
|
||||
|
||||
class PrincipalCollector {
|
||||
private readonly refs = new Map<string, PrincipalRef>();
|
||||
private readonly provenance: ResolvedPrincipal[] = [];
|
||||
|
||||
add(principal: PrincipalRef, source: PrincipalSource, via?: PrincipalRef): void {
|
||||
const key = principalKey(principal);
|
||||
if (!this.refs.has(key)) {
|
||||
this.refs.set(key, principal);
|
||||
}
|
||||
this.provenance.push({
|
||||
principal,
|
||||
source,
|
||||
...(via !== undefined ? { via } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
principals(): readonly PrincipalRef[] {
|
||||
return [...this.refs.values()];
|
||||
}
|
||||
|
||||
entries(): readonly ResolvedPrincipal[] {
|
||||
return this.provenance;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user