/** * Server-side authorization — the single place where "may I?" is decided. * * Effective level resolution: * 1. workspace role: OWNER → FULL_ACCESS, ADMIN → FULL_ACCESS, * MEMBER → EDIT, GUEST → VIEW (no membership → NONE) * 2. `Permission` rows (grantee × resource × level) for this workspace: * - grantee: user (direct) or team (member of that team in this workspace) * - resource scope: workspace (applies to everything), teamspace, * page, database, record, file * The most specific applicable row wins (workspace < teamspace < * database < page < record < file); ties at one specificity take the * max level. The result is maxed with the workspace-role level. * * Levels: NONE < VIEW < COMMENT < EDIT < FULL_ACCESS. * * The `prisma` parameter is a structural interface (the three delegates * used) so the resolution is unit-testable with a fixture object. */ import { ApiError } from "../common/errors.js"; import { ActorContext } from "../common/actor-context.js"; // OAuth scope rules (TASK-0129): the domain lives in modules/oauth/scopes.ts // (catalog + cap + the "which scope was missing" helper) — it type-imports // `Level` from HERE, so this value import is one-directional at runtime // (no cycle). Reused here, never duplicated (AGENTS rule 4). import { oauthScopeCap, missingOAuthScope } from "../modules/oauth/scopes.js"; export type Level = "NONE" | "VIEW" | "COMMENT" | "EDIT" | "FULL_ACCESS"; /** * API-key scope rules (TASK-0127, docs/api/authentication.md §7): * READ key → may pass only VIEW-level assertions (reads); * WRITE key → may pass up to EDIT-level assertions (mutations; COMMENT * ops are covered since the level ladder is flat there); * ADMIN key → all levels (ADMIN implies WRITE implies READ). * Anything beyond the key's cap yields 403 FORBIDDEN with * `details: [{reason: "api_key_scope_write"}]` (the documented public * alias: **SCOPE_DENIED** — `docs/api/authentication.md` §9.1). * * The cap is derived from the key's SCOPES (never from the asserted * resource, never from the creator's membership). */ function apiKeyScopeCap(scopes: string[]): Level { if (scopes.includes("ADMIN")) return "FULL_ACCESS"; if (scopes.includes("WRITE")) return "EDIT"; return "VIEW"; } /** * The OAuth scope-cap, re-exported from modules/oauth/scopes.ts so callers * (and tests) can import it from the authorize module too. */ export { oauthScopeCap }; /** * OAuth branch of {@link assertCan} (TASK-0129, docs §8.2). Runs instead of * the membership path when the in-flight actor is an OAuth token * (`kind: "user"` + `oauthScopes`). The cap (`oauthScopeCap`) and the * "which scope was missing" detail (`missingOAuthScope`) come from * modules/oauth/scopes.ts — imported, never duplicated. * * The target workspace comes from the `X-Factory-Workspace` header (the * OAuth JWT carries no workspace claim). Two gates, in order: * 1. The token's user must be a MEMBER of the target workspace — not a * member → 401 (the workspace context is invalid for this user; never a * 200 on another tenant, never a membership probe leak). * 2. The scope CAP must cover the operation — otherwise 403 reusing the * documented `api_key_scope_write` reason (the public alias * SCOPE_DENIED), with the needed scope in `details[0].scope`. */ async function assertCanForOAuth( prisma: AuthorizePrisma, actor: { userId?: string; oauthScopes?: string[] }, workspaceId: string, need: Level, resource?: ResourceScope ): Promise { const membership = actor.userId && workspaceId ? await prisma.workspaceMember .findUnique({ where: { workspaceId_userId: { workspaceId, userId: actor.userId } } }) .catch(() => null) : null; if (!membership) { throw new ApiError("UNAUTHORIZED", 401, "You are not a member of the requested workspace.", [ { reason: "workspace_member_required" } ]); } const scopes = actor.oauthScopes ?? []; const cap = oauthScopeCap(scopes); if (LEVEL_ORDER[cap] < LEVEL_ORDER[need]) { throw new ApiError("FORBIDDEN", 403, `OAuth scope does not cover ${need} access.`, [ { reason: "api_key_scope_write", scope: missingOAuthScope(need, scopes, resource) } ]); } return cap; } export const LEVELS: Level[] = ["NONE", "VIEW", "COMMENT", "EDIT", "FULL_ACCESS"]; export const LEVEL_ORDER: Record = { NONE: 0, VIEW: 1, COMMENT: 2, EDIT: 3, FULL_ACCESS: 4 }; /** Workspace role → baseline permission level. */ export const ROLE_LEVEL: Record = { OWNER: "FULL_ACCESS", ADMIN: "FULL_ACCESS", MEMBER: "EDIT", GUEST: "VIEW" }; /** Resource specificity: higher = more specific = wins over broader grants. */ const RESOURCE_SPECIFICITY: Record = { workspace: 0, teamspace: 1, database: 2, page: 3, record: 4, file: 5 }; export interface ResourceScope { pageId?: string; databaseId?: string; teamId?: string; recordId?: string; fileId?: string; } export interface EffectiveLevelInput { workspaceId: string; userId: string; resource?: ResourceScope; } /** Minimal structural type of the Prisma delegates used here (testable). * * NOTE (2026-09-29, TASK-0101): the argument parameters are typed as * `object` — the Prisma 5.22 generated delegates take their own * `*FindManyArgs`/`*FindUniqueArgs` generics, which no single structural * signature can satisfy without importing the generated client (the test * fixture must stay import-free). The CALLER's `where` shape is still * checked by the delegate at runtime; the caller-side literals are checked * by the real client when `PrismaService` is passed (the only production * implementation). This is the fix for the Prisma-5.22 drift that made * `PrismaService` fail to satisfy this interface in ~15 services. */ export interface AuthorizePrisma { workspaceMember: { findUnique(args: object): Promise<{ id: string; role: string } | null>; }; teamMember: { findMany(args: object): Promise<{ teamId: string }[]>; }; permission: { findMany(args: object): Promise; }; } export interface PermissionRow { id: string; workspaceId: string; granteeType: string; // "user" | "team" | "role" granteeId: string; resourceType: string; // workspace | teamspace | page | database | record | file resourceId: string; level: Level; inheritsFrom?: string | null; } function maxLevel(a: Level, b: Level): Level { return LEVEL_ORDER[a] >= LEVEL_ORDER[b] ? a : b; } /** * TASK-0135: optional permission-decision cache. This lib is plain (no Nest * DI), so the live `CacheService` is handed in ONCE at boot by the * `PermissionCacheBridge` provider (CoreModule) via {@link setPermissionCache}. * When unset (tests, or `PERF_CACHE` off) decisions go straight to Postgres — * the cache is a transparent accelerator, never a change in the decision. * * The bound function's `loader` is the live resolution; the cache layer calls * it on a miss and stores the level (zod-validated on read), busting the * subject's entries on `permission.changed` (TTL 60 s as the safety net). */ type GetCachedLevel = ( workspaceId: string, userId: string, scope: ResourceScope | undefined, loader: () => Promise ) => Promise<{ level: string; cached: boolean }>; let permCacheFn: GetCachedLevel | null = null; /** Hand the live (bound) permission-cache accessor in, or `null` to disable. */ export function setPermissionCache(fn: GetCachedLevel | null): void { permCacheFn = fn; } /** * Resolve the effective permission level of `userId` in `workspaceId` for an * optional resource scope. Never throws — returns NONE when not a member * and no grant applies. */ export async function effectiveLevel( prisma: AuthorizePrisma, input: EffectiveLevelInput ): Promise { const { workspaceId, userId, resource } = input; // TASK-0135: the decision is cached (TTL 60 s + `permission.changed` bust) // when the live accessor is bound; `loader` is the live resolution. When the // cache is off/unbound, go straight to Postgres (loader runs inline). if (permCacheFn) { const { level } = await permCacheFn(workspaceId, userId, resource, () => rawEffectiveLevel(prisma, input) ); return level as Level; } return rawEffectiveLevel(prisma, input); } /** The live resolution (un-cached) — the loader the permission cache calls. */ async function rawEffectiveLevel( prisma: AuthorizePrisma, input: EffectiveLevelInput ): Promise { const { workspaceId, userId, resource } = input; const membership = await prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId } } }); let level: Level = membership ? ROLE_LEVEL[membership.role] ?? "NONE" : "NONE"; // Sequential (NOT Promise.all): the permission query's team-grant filter // DEPENDS on `teams`, so it cannot run in parallel. (A prior sync commit // inlined `teams.map(…)` inside the `Promise.all` argument — a temporal-dead- // zone `ReferenceError` that threw on EVERY `effectiveLevel` call; fixed // 2026-09-28, the two queries are semantically identical.) 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: Level } | 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] > LEVEL_ORDER[best.level])) { best = { specificity, level: grant.level }; } } // TASK-0104 §2/§Security — explicit NONE deny (a hard stop). // // A grant at level "NONE" is NOT "no grant" here: when ANY resource named // in the requested scope (most specific first: file → record → page → // database → teamspace) carries an explicit NONE grant for this user, the // effective level is a hard NONE. It overrides the role baseline, broader // inheritance, and even a HIGHER grant on the same resource — an explicit // deny can never be bypassed by role (AGENTS.md §27, spec §Security "Explicit // deny (NONE) is a hard stop"). `grants` is already filtered by the query to // grants that apply to this user (user=userId or team∈teams), so a match on // resourceType+resourceId+level is sufficient. // // Walking the whole named chain (not just the top resource) is what makes a // NONE on the containing PAGE deny its files: a file inherits the page's // access, so an explicit page deny is a cap the file cannot escape. A NONE // grant on a resource NOT named in the scope (a different file, a different // page) never fires — it is not part of this read's inheritance chain. // // NOTE: `maxLevel(level, "NONE")` in the loop above is a no-op, so without // this branch an explicit NONE would be silently ignored (the pre-TASK-0104 // behavior) — the deny is what gives NONE its teeth. No NONE grant rows // existed in production before TASK-0104 (share levels are VIEW/COMMENT/ // EDIT), so this is a pure capability addition, not a behavior break. 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; } /** * The resources named in a `ResourceScope`, ordered most → least specific by * `RESOURCE_SPECIFICITY` (file > record > page > database > teamspace). Empty * when the scope names nothing. Used for the explicit-NONE deny walk. */ function namedResources(resource: ResourceScope | undefined): { resourceType: string; resourceId: string }[] { if (!resource) return []; 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: Pick, workspaceId: string, resource: ResourceScope | undefined ): boolean { switch (grant.resourceType) { case "workspace": return grant.resourceId === workspaceId; case "teamspace": return resource?.teamId !== undefined && resource.teamId === grant.resourceId; case "page": return resource?.pageId !== undefined && resource.pageId === grant.resourceId; case "database": return resource?.databaseId !== undefined && resource.databaseId === grant.resourceId; case "record": return resource?.recordId !== undefined && resource.recordId === grant.resourceId; case "file": return resource?.fileId !== undefined && resource.fileId === grant.resourceId; default: return false; } } /** * The actor of the in-flight request (undefined = no HTTP context: workers, * tests calling services directly — the legacy path, unchanged). */ function currentActor() { return ActorContext.get(); } /** * API-key branch of {@link assertCan} (TASK-0127). Runs BEFORE the * membership lookup so a key in workspace A can never inherit its creator's * membership in workspace B. Returns the level the operation resolves to * (EDIT for WRITE/ADMIN keys, VIEW for READ keys — the levels services may * subsequently grant); throws 403 on a scope mismatch. */ function assertCanForApiKey(auth: { workspaceId?: string; scopes?: string[]; apiKeyId?: string; userId?: string; }, workspaceId: string, need: Level): Level { if (auth.workspaceId !== workspaceId) { // The key is bound to its own workspace (docs §7); a cross-workspace // call is a FORBIDDEN — never a 200 on someone else's tenant, and // never a membership probe of the key creator. throw new ApiError("FORBIDDEN", 403, "API key is bound to a different workspace.", [ { reason: "workspace_mismatch" } ]); } const cap = apiKeyScopeCap(auth.scopes ?? []); if (LEVEL_ORDER[cap] < LEVEL_ORDER[need]) { throw new ApiError( "FORBIDDEN", 403, `API key scope does not cover ${need} access.`, [{ reason: "api_key_scope_write" }] ); } // The level the key "has" for this workspace — the scope cap itself. // Services that branch on the returned level (e.g. admin-view flags) see // the key's real capability, not an inflated one. return cap; } /** * The effective permission level for the ACTUAL actor (API key → its scope * cap in its own workspace, never the creator's grants; user → the normal * {@link effectiveLevel}). For the DIRECT `effectiveLevel` call sites that * compare against a level threshold themselves (comment resolve/reopen, * file purge, AI context assembly) instead of going through {@link assertCan}. */ export async function actorLevel(prisma: AuthorizePrisma, input: EffectiveLevelInput): Promise { const actor = currentActor(); if (actor?.kind === "api") { if (actor.workspaceId !== input.workspaceId) return "NONE"; const scopes = actor.scopes ?? []; if (scopes.includes("ADMIN")) return "FULL_ACCESS"; if (scopes.includes("WRITE")) return "EDIT"; return "VIEW"; } // TASK-0129: OAuth token → its SCOPE CAP (never the user's membership). // The cap is already workspace-agnostic (VIEW/EDIT), so the membership // gate lives in the membership lookups, not here. if (actor?.kind === "user" && actor.oauthScopes) { return oauthScopeCap(actor.oauthScopes); } return effectiveLevel(prisma, input); } /** True when the in-flight actor is an ADMIN-scoped API key of `workspaceId`. */ export function actorIsAdminKey(workspaceId: string): boolean { const actor = currentActor(); return actor?.kind === "api" && actor.workspaceId === workspaceId && (actor.scopes ?? []).includes("ADMIN"); } /** * Assert the caller has at least `need` on the resource. * Throws `ApiError(FORBIDDEN, 403)` with `details: [{reason: "requires_…" }]`. * * TASK-0127: when the in-flight actor is an API key, the decision is the * key's SCOPE (and its workspace binding), not the membership of the user * the caller passed as `userId` — see {@link assertCanForApiKey}. */ export async function assertCan( prisma: AuthorizePrisma, input: EffectiveLevelInput, need: Level ): Promise { const actor = currentActor(); if (actor?.kind === "api") { return assertCanForApiKey(actor, input.workspaceId, need); } // TASK-0129: an OAuth token (kind "user" + oauthScopes) is gated by its // SCOPE CAP in the X-Factory-Workspace workspace, never by the user's // membership — see {@link assertCanForOAuth}. if (actor?.kind === "user" && actor.oauthScopes) { return assertCanForOAuth(prisma, actor, input.workspaceId, need, input.resource); } const have = await effectiveLevel(prisma, input); if (LEVEL_ORDER[have] < LEVEL_ORDER[need]) { throw new ApiError("FORBIDDEN", 403, `Requires ${need} access (you have ${have}).`, [ { reason: `requires_${need}`, have } ]); } return have; } /** * Whether the in-flight actor is an API key bound to `workspaceId` (the key * is a member-equivalent of its OWN workspace; cross-workspace → false). * Used by the DIRECT membership lookups that bypass {@link assertCan} * (services that 404 non-members instead of 403) so an API key never * probes its creator's membership (TASK-0127, docs §7). */ export function actorIsWorkspaceKey(actor: { kind?: string; workspaceId?: string } | undefined, workspaceId: string): boolean { return actor?.kind === "api" && actor.workspaceId === workspaceId; } /** * The permission level an API key exercises for `need` in `workspaceId` — * the same scope rules as {@link assertCanForApiKey} but WITHOUT throwing * (for the membership-shape lookups: the result is used as the caller's * "role level", not as a grant). READ → VIEW, WRITE/ADMIN → EDIT, ADMIN → * FULL_ACCESS (the max the key may pass); cross-workspace → NONE. */ export function apiKeyLevelFor( actor: { kind?: string; workspaceId?: string; scopes?: string[] } | undefined, workspaceId: string, need: Level ): Level { if (actor?.kind !== "api" || actor.workspaceId !== workspaceId) return "NONE"; const scopes = actor.scopes ?? []; let cap: Level = "VIEW"; if (scopes.includes("WRITE") || scopes.includes("ADMIN")) cap = "EDIT"; if (scopes.includes("ADMIN")) cap = "FULL_ACCESS"; return LEVEL_ORDER[cap] >= LEVEL_ORDER[need] ? need : "NONE"; } /** * Resolve the workspace membership for the ACTUAL actor (user → the user's * membership; API key → a SYNTHETIC membership whose role covers the key's * scope level in `workspaceId`, never the creator's real row). This is the * actor-aware stand-in for a direct `workspaceMember.findUnique` at the * CALLER's-own-membership sites (list-members / usage / sidebar gates); * target-user lookups (is `X` a member?) keep the raw query. */ export async function actorMembership( prisma: AuthorizePrisma, userId: string, workspaceId: string, need: Level = "VIEW" ): Promise<{ id: string; role: string } | null> { const actor = currentActor(); if (actor?.kind === "api") { if (actor.workspaceId !== workspaceId) return null; const scopes = actor.scopes ?? []; const cap: Level = scopes.includes("ADMIN") ? "FULL_ACCESS" : scopes.includes("WRITE") ? "EDIT" : "VIEW"; if (LEVEL_ORDER[cap] < LEVEL_ORDER[need]) return null; // scope too small // Synthesize a role from the key's CAP (its scope, never the creator's // membership row): the key acts as the workspace at that level. const role = cap === "FULL_ACCESS" ? "ADMIN" : cap === "EDIT" ? "MEMBER" : "GUEST"; return { id: `apikey-${actor.apiKeyId ?? "unknown"}`, role }; } // TASK-0129: an OAuth token acts as its USER's membership (it carries the // user's identity) but at most its SCOPE CAP — a member with a read-only // token still resolves the membership, but `need` above the cap → null. if (actor?.kind === "user" && actor.oauthScopes) { if (!userId) return null; if (LEVEL_ORDER[oauthScopeCap(actor.oauthScopes)] < LEVEL_ORDER[need]) return null; // scope too small const m = await prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId } }, select: { id: true, role: true } }); return m; } if (!userId) return null; const m = await prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId } }, select: { id: true, role: true } }); return m; } /** * Assert the caller has one of the workspace roles (owner/admin gating). * * TASK-0127: API keys — the key's ADMIN scope is the role gate (an ADMIN * key acts as the workspace admin); a READ/WRITE key is rejected with the * scope reason (docs §7: "member/permission/api-key management requires * ADMIN"). The key's creator membership is never consulted. */ export async function assertRole( prisma: AuthorizePrisma, input: { workspaceId: string; userId: string }, roles: string[] ): Promise { const actor = currentActor(); if (actor?.kind === "api") { if (actor.workspaceId !== input.workspaceId) { throw new ApiError("FORBIDDEN", 403, "API key is bound to a different workspace.", [ { reason: "workspace_mismatch" } ]); } if (!actor.scopes?.includes("ADMIN")) { throw new ApiError("FORBIDDEN", 403, "This operation requires the ADMIN scope.", [ { reason: "api_key_scope_write" } ]); } // The ADMIN scope maps to the workspace ADMIN role — NOT the owner. // OWNER-only gates (delete workspace, transfer ownership) stay closed // to every API key: an API key can never own a workspace (docs §7). if (!roles.includes("ADMIN")) { throw new ApiError("FORBIDDEN", 403, `Requires workspace role ${roles.join(" or ")}.`, [ { reason: `requires_role_${roles.join("_")}` } ]); } return "ADMIN"; } // TASK-0129: an OAuth token carries NO workspace role — its scope cap tops // out at EDIT (there is no FULL_ACCESS scope). Every OWNER/ADMIN role gate // (app registration, member management, …) is therefore closed to it. if (actor?.kind === "user" && actor.oauthScopes) { if (oauthScopeCap(actor.oauthScopes) !== "FULL_ACCESS") { throw new ApiError("FORBIDDEN", 403, `This operation requires workspace role ${roles.join(" or ")}.`, [ { reason: "oauth_role_denied" } ]); } } const membership = await prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId: input.workspaceId, userId: input.userId } } }); if (!membership || !roles.includes(membership.role)) { throw new ApiError("FORBIDDEN", 403, `Requires workspace role ${roles.join(" or ")}.`, [ { reason: `requires_role_${roles.join("_")}` } ]); } return membership.role; }