/** * @factory/permissions — the permission decision engine. * * Pure and deterministic: no I/O, no DB, no auth. The API layer resolves the * request context (user, their teams, their workspace role, the ancestor * chain from the workspace root to the resource, the grants and shares that * apply) and gets back an explainable {@link PermissionLevel}. * * ## Resolution contract (Phase-3 decision — the API codes against this) * * For a request `req`: * * a) `workspaceRoot := req.resourceType === "workspace"`. * * b) **base** — role baseline: * `owner → full_access`, `admin → full_access`, * `member → edit` (workspace root) **or `view`** (any descendant), * `guest → view` (workspace root) **or `none`** (any descendant). * (Rationale for the descendant collapse to `view`/`none`: the baseline * exists so that workspace sharing has something to act on; a guest has no * default access at all below the workspace root.) * * c) **inherited** — grants on an *ancestor*: walk `req.ancestorChain` from * the nearest ancestor up to the root (the resource itself is the last * chain entry and is excluded; the workspace-root entry is simply the * farthest ancestor — grants on the workspace inherit to everything * below). The **nearest ancestor that has ≥1 applicable grant wins** and * its max applicable level is `inherited`; a nearer ancestor whose grants * do not match the requester is skipped, not a block. No applicable grant * on any ancestor → `"none"`. (A grant on a closer ancestor always beats * a grant on a farther one — no `max` across levels.) * * d) **explicit** — grants on the *exact* resource (`resourceType` + * `resourceId` both match), max applicable level, `"none"` if none. * **Applicable** grantee match: `user` → `granteeId === userId`, * `team` → `granteeId ∈ userTeams`, `role` → `granteeId === workspaceRole`. * * e) **share** — max level over the shares visible to the requester * ({@link shareVisibility}): `"public"` → its level, `"workspace"` → its * level, `"team"` → its level iff the user is in `teamId`'s team, * `"users"` → its level iff the user is in `userIds`, `"private"` → never. * No visible share → `"none"`. * * f) **final** — * `final = explicit !== "none" ? explicit : maxLevel(inherited, maxLevel(base, share))` * * Explicit grants therefore **override everything and can RESTRICT below the * role baseline** (that is the point of per-resource grants); otherwise the * effective level is the **max** of inheritance, role baseline and shares * (shares are always a floor, never a ceiling). * * Every step appends human-readable strings to `reasons`, so any decision is * explainable and auditable (the API logs them / attaches them to FORBIDDEN * errors via {@link check}). */ import { ApiError, ErrorCodes } from "@factory/shared/errors"; import { levelAtLeast, maxLevel, type PermissionLevel, type ResourceType, type WorkspaceRole, } from "./levels.js"; import { shareVisibility, type Share } from "./rules.js"; /** Who a grant applies to (prisma `Permission.granteeType`). */ export type GranteeType = "user" | "team" | "role"; /** * A permission grant (DTO mirror of the prisma `Permission` model — * granteeType/granteeId/resourceType/resourceId/level/inheritsFrom). */ export interface Grant { granteeType: GranteeType; /** userId | teamId | role name — plain refs, same as prisma. */ granteeId: string; resourceType: ResourceType; resourceId: string; level: PermissionLevel; /** Id of the parent grant this one was derived from (inheritance chains). */ inheritsFrom?: string; } /** One step of the path from the workspace root down to the requested resource. */ export interface AncestorRef { resourceType: ResourceType; id: string; } /** Everything the engine needs to decide; the API builds it from the DB. */ export interface PermissionRequest { userId: string; userName?: string; /** Ids of all teams the user belongs to (team grants + team shares). */ userTeams: string[]; workspaceRole: WorkspaceRole; workspaceId: string; resourceType: ResourceType; resourceId: string; /** * Path from the workspace root down to the resource, e.g. * `[{workspace, wsp_1}, {teamspace, tms_1}, {page, pg_1}]` for a page. * The last entry is the resource itself. May be omitted; in that case * inheritance (step c) is simply `"none"`. */ ancestorChain?: AncestorRef[]; grants: Grant[]; shares: Share[]; } /** Result of {@link resolvePermission}: the level plus the audit trail. */ export interface PermissionResult { level: PermissionLevel; reasons: string[]; } const ROLE_BASE_AT_ROOT: Record = { owner: "full_access", admin: "full_access", member: "edit", guest: "view", }; const ROLE_BASE_ON_DESCENDANTS: Record = { owner: "full_access", admin: "full_access", member: "view", guest: "none", }; /** Does the grant's grantee match the requester (step d of the contract)? */ function grantAppliesToUser( grant: Grant, req: Pick, ): boolean { switch (grant.granteeType) { case "user": return grant.granteeId === req.userId; case "team": return req.userTeams.includes(grant.granteeId); case "role": return grant.granteeId === req.workspaceRole; } } function describeGrantee(grant: Grant): string { switch (grant.granteeType) { case "user": return `user ${grant.granteeId}`; case "team": return `team ${grant.granteeId}`; case "role": return `role ${grant.granteeId}`; } } /** Max applicable level among `grants` that exactly match the target, or "none". */ function maxApplicable(grants: Grant[], req: PermissionRequest): PermissionLevel { let best: PermissionLevel = "none"; for (const grant of grants) { if (grantAppliesToUser(grant, req) && grant.level !== "none") { best = maxLevel(best, grant.level); } } return best; } /** Step b — role baseline. */ function baselineFor(req: PermissionRequest, workspaceRoot: boolean): PermissionLevel { return (workspaceRoot ? ROLE_BASE_AT_ROOT : ROLE_BASE_ON_DESCENDANTS)[req.workspaceRole]; } /** Step c — nearest-ancestor inheritance. */ function inheritedFromAncestors( req: PermissionRequest, ): { level: PermissionLevel; ancestor: AncestorRef } | null { const chain = req.ancestorChain; if (chain === undefined || chain.length === 0) return null; // Ancestors of the resource, nearest first. The last entry is the resource // itself (excluded); the workspace-root entry participates as the farthest // ancestor (its role *baseline* is step b — grants on the workspace are // ordinary inheritance grants here). for (let i = chain.length - 2; i >= 0; i--) { const ancestor = chain[i]; if (ancestor === undefined) continue; const applicable = req.grants.filter( (g) => grantAppliesToUser(g, req) && g.resourceType === ancestor.resourceType && g.resourceId === ancestor.id, ); if (applicable.length > 0) { let level: PermissionLevel = "none"; for (const grant of applicable) level = maxLevel(level, grant.level); return { level, ancestor }; } } return null; } /** * Resolve the effective permission level for `req` (pure, deterministic — * see the contract in this module's header). */ export function resolvePermission(req: PermissionRequest): PermissionResult { const reasons: string[] = []; const workspaceRoot = req.resourceType === "workspace"; const who = req.userName !== undefined ? ` (${req.userName})` : ""; // (b) role baseline const base = baselineFor(req, workspaceRoot); reasons.push( workspaceRoot ? `role ${req.workspaceRole}${who} → baseline ${base} at workspace root` : `role ${req.workspaceRole}${who} → baseline ${base} on descendant resource`, ); // (e) shares const visibleShares = req.shares.filter((share) => shareVisibility(share, req)); let shareLevel: PermissionLevel = "none"; for (const share of visibleShares) { shareLevel = maxLevel(shareLevel, share.level); if (share.scope === "public") { reasons.push(`public share → floor ${share.level}`); } else if (share.scope === "workspace") { reasons.push(`workspace share → floor ${share.level}`); } else if (share.scope === "team") { reasons.push(`team share (${share.teamId}) → floor ${share.level}`); } else if (share.scope === "users") { reasons.push(`share to listed users → floor ${share.level}`); } } if (visibleShares.length === 0) { reasons.push("no shares visible to requester → no share contribution"); } // (c) inheritance from the nearest ancestor that has an applicable grant const inherited = inheritedFromAncestors(req); let inheritedLevel: PermissionLevel = "none"; if (inherited !== null) { inheritedLevel = inherited.level; reasons.push( `inherited from ${inherited.ancestor.resourceType} ${inherited.ancestor.id} → ${inheritedLevel}`, ); } else { reasons.push("no applicable grant on any ancestor → no inheritance"); } // (d) explicit grants on the exact resource const explicit = maxApplicable( req.grants.filter( (g) => g.resourceType === req.resourceType && g.resourceId === req.resourceId, ), req, ); if (explicit !== "none") { const matched = req.grants .filter( (g) => g.resourceType === req.resourceType && g.resourceId === req.resourceId && grantAppliesToUser(g, req), ) .map((g) => describeGrantee(g)); reasons.push( `explicit grant on ${req.resourceType} ${req.resourceId} by ${matched.join(", ")} → ${explicit}`, ); } // (f) final let level: PermissionLevel; if (explicit !== "none") { level = explicit; const superseded = [ inheritedLevel !== "none" ? `inherited ${inheritedLevel}` : null, base !== "none" ? `baseline ${base}` : null, shareLevel !== "none" ? `shares ${shareLevel}` : null, ] .filter((part): part is string => part !== null) .join(", "); reasons.push( superseded.length > 0 ? `final = explicit ${explicit} (overrides ${superseded})` : `final = explicit ${explicit}`, ); } else { level = maxLevel(inheritedLevel, maxLevel(base, shareLevel)); reasons.push( `final = max(inherited ${inheritedLevel}, baseline ${base}, shares ${shareLevel}) = ${level}`, ); } return { level, reasons }; } /** * Whether the resolved level satisfies `required`. * `can(req, "edit")` ⇔ `resolvePermission(req).level ≥ required` (a `none` * level never satisfies anything). */ export function can(req: PermissionRequest, required: Exclude): boolean { return levelAtLeast(resolvePermission(req).level, required); } /** * Assert the resolved level satisfies `required`; otherwise throw a * {@link ApiError} with code `FORBIDDEN` (HTTP 403) whose `details` carry * `{ required, granted, reasons }` — audit-friendly, no re-resolution by the * API layer. */ export function check(req: PermissionRequest, required: Exclude): void { const { level, reasons } = resolvePermission(req); if (!levelAtLeast(level, required)) { throw new ApiError(ErrorCodes.FORBIDDEN, { message: `Permission denied: requires ${required}, granted ${level}`, details: { required, granted: level, reasons }, }); } }