/** * apps/worker — automation permission check (TASK-0112 §5/§10). * * Mirrors the API's single permission walk * (`apps/api/src/lib/authorize.ts::effectiveLevel`) so the worker never * bypasses authorization: * effectiveLevel = max(role baseline, best applicable explicit grant), * with an EXPLICIT NONE grant as a hard stop (overrides everything). * The automation actor is the automation CREATOR (spec §10 DECISION) — the * worker checks the creator's level on the target resource and, on denial, * throws fatal PERMISSION_DENIED (the pipeline writes the * `automation.permission_denied` audit). */ import type { PrismaClient } from "@prisma/client"; import { getLogger } from "../../../logger.js"; import { FatalError } from "./classify.js"; const log = getLogger("automation-perm"); const ROLE_LEVEL: Record = { OWNER: "FULL_ACCESS", ADMIN: "FULL_ACCESS", MEMBER: "EDIT", GUEST: "VIEW" }; const LEVEL_ORDER: Record = { NONE: 0, VIEW: 1, COMMENT: 2, EDIT: 3, FULL_ACCESS: 4 }; const RESOURCE_SPECIFICITY: Record = { workspace: 0, teamspace: 1, database: 2, page: 3, record: 4, file: 5 }; interface Scope { pageId?: string | null; databaseId?: string | null; teamId?: string | null; recordId?: string | null; fileId?: string | null; } function namedResources(resource: Scope): { resourceType: string; resourceId: string }[] { const named: { resourceType: string; resourceId: string }[] = []; if (resource.fileId) named.push({ resourceType: "file", resourceId: resource.fileId }); if (resource.recordId) named.push({ resourceType: "record", resourceId: resource.recordId }); if (resource.pageId) named.push({ resourceType: "page", resourceId: resource.pageId }); if (resource.databaseId) named.push({ resourceType: "database", resourceId: resource.databaseId }); if (resource.teamId) named.push({ resourceType: "teamspace", resourceId: resource.teamId }); return named; } function appliesToResource(grant: { resourceType: string; resourceId: string }, workspaceId: string, resource: Scope): boolean { switch (grant.resourceType) { case "workspace": return grant.resourceId === workspaceId; case "teamspace": return resource.teamId !== null && resource.teamId !== undefined && resource.teamId === grant.resourceId; case "page": return resource.pageId !== null && resource.pageId !== undefined && resource.pageId === grant.resourceId; case "database": return resource.databaseId !== null && resource.databaseId !== undefined && resource.databaseId === grant.resourceId; case "record": return resource.recordId !== null && resource.recordId !== undefined && resource.recordId === grant.resourceId; case "file": return resource.fileId !== null && resource.fileId !== undefined && resource.fileId === grant.resourceId; default: return false; } } const maxLevel = (a: string, b: string): string => ((LEVEL_ORDER[a] ?? 0) >= (LEVEL_ORDER[b] ?? 0) ? a : b); /** * The effective level of `userId` in `workspaceId` for `resource` * (the exact mirror of the API walk). Never throws — NONE when no access. */ export async function effectiveLevel( prisma: PrismaClient, workspaceId: string, userId: string, resource: Scope ): Promise { const membership = await prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId } }, select: { role: true } }); let level: string = membership ? ROLE_LEVEL[membership.role] ?? "NONE" : "NONE"; const teams = await prisma.teamMember.findMany({ where: { userId }, select: { teamId: true } }); const grants = await prisma.permission.findMany({ where: { workspaceId, OR: [ { granteeType: "user", granteeId: userId }, { granteeType: "team", granteeId: { in: teams.map((t) => t.teamId) } } ] } }); let best: { specificity: number; level: string } | undefined; for (const grant of grants) { const specificity = RESOURCE_SPECIFICITY[grant.resourceType]; if (specificity === undefined) continue; if (!appliesToResource(grant, workspaceId, resource)) continue; if ( !best || specificity > best.specificity || (specificity === best.specificity && (LEVEL_ORDER[grant.level] ?? 0) > (LEVEL_ORDER[best.level] ?? 0)) ) { best = { specificity, level: grant.level }; } } // Explicit NONE = hard stop (TASK-0104): overrides role + higher grants. for (const named of namedResources(resource)) { if (grants.some((g) => g.resourceType === named.resourceType && g.resourceId === named.resourceId && g.level === "NONE")) { return "NONE"; } } if (best) level = maxLevel(level, best.level); return level; } /** * Assert the automation actor (creator) has at least `need` on the record's * resource chain. Denied → FatalError PERMISSION_DENIED (the pipeline writes * the audit row; the run is FAILED, never retried). */ export async function assertAutomationCanEdit( prisma: PrismaClient, workspaceId: string, userId: string, resource: Scope, need: string = "EDIT" ): Promise { const have = await effectiveLevel(prisma, workspaceId, userId, resource); if ((LEVEL_ORDER[have] ?? 0) < (LEVEL_ORDER[need] ?? 0)) { log.warn({ workspaceId, userId, need, have, resourceType: "record", resource: resource.recordId ?? "?" }, "automation permission denied"); throw new FatalError("PERMISSION_DENIED", `automation actor has ${have}, requires ${need}`); } }