@factory/permissions
Pure permission decision engine for AI Software Factory. No I/O, no DB, no auth — the API layer resolves the request context (user, teams, workspace role, ancestor chain, grants, shares) and gets back one deterministic, explainable permission level. Used by the API (NestJS guards) in Phase 1 (exit criterion: "permissions enforced server-side"), Phase 3 (page permissions: inherit + override, sharing scopes), Phase 8 (permission metadata for search index filtering) and Phase 14 (guest restrictions / advanced permissions build on this engine).
Public API
| Module | Exports | Purpose |
|---|---|---|
@factory/permissions (root) |
everything below | single import surface |
@factory/permissions/levels |
LEVELS, PermissionLevel, AccessLevel, ROLES, WorkspaceRole, RESOURCE_TYPES, ResourceType, levelRank, maxLevel, minLevel, levelAtLeast, levelFromPrisma, levelToPrisma, roleFromPrisma, roleToPrisma |
level ordering + wire↔Prisma enum mapping (snake_case ↔ SCREAMING_SNAKE) |
@factory/permissions/rules |
Share, ShareVisibilityUser, shareVisibility, ROLE_ACTIONS, RoleAction, canRole |
static role→action matrix + share visibility (extracted, testable) |
@factory/permissions/resolve |
Grant, AncestorRef, PermissionRequest, PermissionResult, resolvePermission, can, check |
the decision engine |
Usage
import { resolvePermission, can, check } from "@factory/permissions";
const req = {
userId: "u_1",
userName: "Ada",
userTeams: ["tms_1"],
workspaceRole: "member",
workspaceId: "wsp_1",
resourceType: "page",
resourceId: "pg_1",
ancestorChain: [
{ resourceType: "workspace", id: "wsp_1" },
{ resourceType: "teamspace", id: "tms_a" },
{ resourceType: "page", id: "pg_1" },
],
grants: [{ granteeType: "user", granteeId: "u_1", resourceType: "page", resourceId: "pg_1", level: "edit" }],
shares: [{ scope: "team", level: "view", teamId: "tms_1" }],
};
const { level, reasons } = resolvePermission(req); // level: "edit", reasons: [...audit trail...]
can(req, "edit"); // true
check(req, "full_access"); // throws ApiError(FORBIDDEN, { details: { required, granted, reasons } })
Grant mirrors the prisma Permission model
(granteeType/granteeId/resourceType/resourceId/level/inheritsFrom); Share
mirrors the prisma Share model (scope PRIVATE/WORKSPACE/TEAM/USERS/PUBLIC in
snake_case on the wire, teamId singular, userIds the resolved member list).
Resolution contract (Phase-3 decision — the API codes against this)
For a request req (see the JSDoc in src/resolve.ts for the full text):
- (a)
workspaceRoot := resourceType === "workspace". - (b) base — role baseline:
owner/admin→full_access;member→editat the workspace root,viewon any descendant;guest→viewat the root,noneon any descendant. - (c) inherited — walk
ancestorChainfrom the nearest ancestor up to the root (the last chain entry — the resource itself — is excluded; the workspace-root entry is the farthest ancestor, so grants on the workspace inherit to everything below): the nearest ancestor with ≥1 applicable grant wins, its max applicable level isinherited; a nearer ancestor whose grants do not match the requester is skipped, not a block; none anywhere →none. - (d) explicit — max applicable grant on the exact resource
(
resourceType+resourceId),noneif none. A grant is applicable when its grantee matches:user→granteeId === userId,team→granteeId ∈ userTeams,role→granteeId === workspaceRole. - (e) share — max level over shares visible to the requester:
public→ its level,workspace→ its level,team→ its level iff the user is inteamId's team,users→ its level iff inuserIds,private→ never.password/slugnever affect visibility. - (f) final —
explicit !== "none" ? explicit : maxLevel(inherited, maxLevel(base, share)).
Consequences: explicit grants override everything and can restrict below
the role baseline (the point of per-resource grants); otherwise the level is
the max of inheritance, role baseline and shares (shares are a floor,
never a ceiling). Every step appends human-readable entries to reasons —
check() attaches them to the FORBIDDEN details for audit logging.
Roadmap
- Phase 1 — API guards call
check()server-side (never trust the FE). - Phase 3 — page permissions (inherit + override) and sharing scopes use
the exact contract above; the API builds
ancestorChainfrom the DB. - Phase 8 — search indexer stores per-document permission metadata via
this engine;
resolvePermissionresults feed permission-filtered results. - Phase 14 — advanced permissions (policy expressions, guest restrictions) extend the engine; the role matrix and share scopes stay the baseline.
Recorded spec conflicts / decisions
- Contract (b) descendant baseline: the member baseline collapses from
edit(workspace root) toviewon descendant resources, and guests getnonebelow the root. The prismaPermissionmodel has no "default level per resource type", so the baseline is the only way workspace sharing has something to act on; guests have no default access at all below the root. - Inheritance is "nearest ancestor with an applicable grant wins" — a
farther ancestor's grant never
maxes with a nearer one (a closer grant is an intentional decision, even at a lower level). Grants whose grantee does not match the requester are skipped when searching for the nearest level (an unrelated grant on the teamspace does not block a workspace grant). - Prisma schema divergence: the prisma
Sharemodel storesscope,teamId(singular, plain ref),allowComments/allowDuplicate/allowSearchIndexing,passwordHashandexpiresAt— nouserIdscolumn. The engine'sShare.userIdsis the resolved user list the API expands from the team relation (or an explicit user list) before callingresolvePermission;expiresAt/passwordHashare validated by the API, not the engine (it stays pure). canRoleexport_datais owner/admin (per the spec's role matrix): this matrix answers workspace-management questions. Per-user data export (a data-subject right, available to members/guests for their own data) is a user-level feature the API handles outside this matrix — record kept so nobody "fixes" the engine to self-serve exports.