import type { OrganizationStatus, PermissionResourceType, PermissionRole, PrismaClient, PrincipalType } from "@prisma/client"; import { PrismaPrincipalResolver, type ActorInput, type PrincipalRef, type PrincipalResolution, type PrincipalResolver } from "./principals.js"; import { inactiveOrganizationReason } from "../org/status.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 organizationId: string; 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; } export class PrismaPermissionAuthorizer implements PermissionAuthorizer { constructor( private readonly prisma: PrismaClient, private readonly resolver: PrincipalResolver = new PrismaPrincipalResolver(prisma), ) {} async can(req: AuthorizationRequest): Promise { if (req.action === "role.trigger" && (req.roleId === undefined || req.roleId === "")) { throw new Error("role.trigger authorization requires roleId"); } const organization = await this.organizationForResource(req.resource); const inactiveReason = inactiveOrganizationReason(organization.id, organization.status); if (inactiveReason !== undefined) { return this.decision(req, inactivePrincipalResolution(req.actor, organization.id), { allowed: false, reason: inactiveReason, requiredRole: defaultRequiredRole(req.action), }); } const resolution = await this.resolver.resolveActor(req.actor, { organizationId: organization.id }); 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 { let required = defaultRequiredRole(req.action); // ADR-0019 + PermissionGrant spec: agentTrigger and agentCancel are // distinct policy knobs ("谁能触发" ≠ "谁能取消"). Each can tighten the // default role to MANAGE_ONLY or DISABLE the action entirely. The default // role (trigger=EDIT, cancel=MANAGE) is the floor when no policy is set. if (req.resource.type === "PROJECT" && (req.action === "agent.trigger" || req.action === "role.trigger")) { 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"; } else if (req.resource.type === "PROJECT" && req.action === "agent.cancel") { const settings = await this.prisma.permissionSettings.findFirst({ where: { resourceType: req.resource.type, resourceId: req.resource.id }, select: { agentCancel: true }, }); const policy = normalizePolicy(settings?.agentCancel); if (policy === "DISABLED") return "DISABLED"; if (policy === "MANAGE_ONLY") required = "MANAGE"; } return required; } private async bestGrant( resource: AuthorizationResource, principals: readonly PrincipalRef[], threshold: PermissionRole, ): Promise { 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 { return { action: req.action, resource: req.resource, actor: req.actor, organizationId: resolution.organizationId, ...(resolution.userId !== undefined ? { actorUserId: resolution.userId } : {}), principals: resolution.principals, ...result, }; } private async organizationForResource( resource: AuthorizationResource, ): Promise<{ readonly id: string; readonly status: OrganizationStatus }> { switch (resource.type) { case "PROJECT": { const project = await this.prisma.project.findUnique({ where: { id: resource.id }, select: { organization: { select: { id: true, status: true } } }, }); if (project === null) { throw new Error(`authorization resource missing: PROJECT ${resource.id}`); } return project.organization; } case "PROJECT_GROUP": { const binding = await this.prisma.projectGroupBinding.findFirst({ where: { chatId: resource.id, archivedAt: null }, select: { project: { select: { organization: { select: { id: true, status: true } } } } }, }); if (binding === null) { throw new Error(`authorization resource missing: PROJECT_GROUP ${resource.id}`); } return binding.project.organization; } case "ARTIFACT": throw new Error("authorization for ARTIFACT resources requires an organization resolver"); } } } 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, })); } function inactivePrincipalResolution(actor: ActorInput, organizationId: string): PrincipalResolution { const principals: PrincipalRef[] = [{ type: "USER", id: actor.feishuOpenId }]; if (actor.chatId !== undefined && actor.chatId !== "") { principals.push({ type: "FEISHU_CHAT", id: actor.chatId }); } return { actor, organizationId, principals, resolved: [], }; }