forked from EduCraft/curriculum-project-hub
320 lines
11 KiB
TypeScript
320 lines
11 KiB
TypeScript
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<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 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<PermissionRole | "DISABLED"> {
|
|
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<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" | "organizationId" | "actorUserId" | "principals">,
|
|
): 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: [],
|
|
};
|
|
}
|