Implementation patterns for polizy authorization. Use when implementing team access, folder inheritance, field-level permissions, temporary access, revocation, or any specific authorization scenario.
Implementation patterns for polizy authorization. Use when implementing team access, folder inheritance, field-level permissions, temporary access, revocation, or any specific authorization scenario.
allow(), addMember(), and setParent() are idempotent on
(subject, relation, object). Re-granting the same triple updates the
condition rather than adding a row — so you can't keep a standing grant and a
temporary grant that differ only by condition on the same triple. Use
distinct relations (e.g. viewer standing vs temp_viewer time-boxed).
Field-level ids are opt-in: declare fieldLevelObjects: ["document", ...].
addMember/setParent/removeMember/removeParent take an optional
as: "<relation>", required only when the schema declares more than one
group/hierarchy relation.
With exactly one group relation, addMember/removeMember infer it. If you
declare more than one (e.g. member and orgMember), pass
as: "member" on every member write/remove or it throws a SchemaError.
A grant on the base object (emp123) authorizes all of its fields
(emp123#salary, emp123#ssn, …). A grant on a specific field
(emp123#salary) stays scoped to that field. So the field-level pattern grants
narrow access on top of (not instead of) base access — give the base grant to
nobody, or only to roles that should see everything.
// HR sees the whole profile (base grant → authorizes every field too)await authz.allow({
who: { type: "user", id: "hr_manager" },
toBe: "viewer",
onWhat: { type: "profile", id: "emp123" }
});
// Payroll sees ONLY the salary field (scoped field grant, no base grant)await authz.allow({
who: { type: "user", id: "payroll" },
toBe: "viewer",
onWhat: { type: "profile", id: "emp123#salary" }
});
// HR can read salary via its base grantawait authz.check({
who: { type: "user", id: "hr_manager" },
canThey: "view",
onWhat: { type: "profile", id: "emp123#salary" }
}); // true (base → field)// Payroll can read salary, but not the rest of the recordawait authz.check({
who: { type: "user", id: "payroll" },
canThey: "view",
onWhat: { type: "profile", id: "emp123#salary" }
}); // trueawait authz.check({
who: { type: "user", id: "payroll" },
canThey: "view",
onWhat: { type: "profile", id: "emp123" }
}); // false (no base grant)
Base access flows to fields through direct, group, and hierarchy paths — a
folder viewer reaches doc#field of documents in that folder. To keep a field
private, don't grant the base object to that subject.
Pattern 5: Temporary Access
Grant time-limited permissions with a when condition.
0.3.0 gotcha:allow() is idempotent on (subject, relation, object). You
can NOT have a standing grant and a temporary grant on the same triple —
the second call overwrites the first's condition. Model "standing + temporary"
with distinct relations:
Map temp_editor in actionToRelations (e.g. edit: ["editor", "temp_editor"]).
See TIME-LIMITED.md.
Pattern 6: Revocation
Remove permissions. In 0.3.0 these deletes are precise — a single-tuple
disallowAllMatching({ who, was, onWhat }), removeMember, and removeParent
no longer over-delete unrelated tuples on either adapter.
// Remove specific permissionawait authz.disallowAllMatching({
who: { type: "user", id: "bob" },
was: "editor",
onWhat: { type: "document", id: "doc1" }
});
// Remove all user permissions on a resourceawait authz.disallowAllMatching({
who: { type: "user", id: "bob" },
onWhat: { type: "document", id: "doc1" }
});
// Remove all permissions on a resource (when deleting it)await authz.disallowAllMatching({
onWhat: { type: "document", id: "doc1" }
});
// Remove user from groupawait authz.removeMember({
member: { type: "user", id: "alice" },
group: { type: "team", id: "engineering" }
});
// If the schema declares MORE THAN ONE group/hierarchy relation, pass `as`:await authz.removeMember({
member: { type: "user", id: "alice" },
group: { type: "org", id: "acme" },
as: "orgMember"// required when >1 group relation exists
});
Grant an action to every subject of a type ("anyone with the link", public
docs). Import everyone and use it as the who.
import { everyone } from"polizy";
// Any user can view this documentawait authz.allow({
who: everyone("user"),
toBe: "viewer",
onWhat: { type: "document", id: "public-readme" }
});
// A specific, un-granted user passes the check via the wildcardawait authz.check({
who: { type: "user", id: "random-visitor" },
canThey: "view",
onWhat: { type: "document", id: "public-readme" }
}); // true
everyone("user") is sugar for the reserved subject { type: "user", id: "*" }.
Wildcard grants honor conditions, so you can scope them by time or attributes
(e.g. public during a launch window). Revoke with
disallowAllMatching({ who: everyone("user"), was: "viewer", onWhat }).
0.5.0: a wildcard assignment now also propagates through groups/roles —
assignRole(everyone("user"), role) grants the role (and its capabilities) to
every subject of that type. Honored in check(), explain(), and
listAccessibleObjects.
Pattern 10: Attribute Conditions (ABAC)
Gate a grant on request-time context. Predicates in when.attributes are
checked against the context you pass to check() (fail-closed: missing value
or type mismatch denies).
Operators: eq, ne, in, nin, gt, gte, lt, lte. attribute
supports dot-paths ("user.tier"). Combine with validSince/validUntil — all
predicates AND the time window must pass.
Pattern 11: Batch Checks for List Endpoints
Avoid N+1 round trips when filtering a fetched list. checkMany answers many
questions in one call.
For "what can this user reach" (rather than checking a known list), prefer
listAccessibleObjects (Pattern 7).
Pattern 12: Who Can Access This? (listSubjects)
Reverse expansion for share dialogs and audits — list the subjects that can
perform an action on an object, including those reachable via groups and
hierarchy.
// Everyone who can view doc1 (direct, via team, via folder, via wildcard)// Supports pagination (limit/offset) after a deterministic sortconst subjects = await authz.listSubjects({
canThey: "view",
onWhat: { type: "document", id: "doc1" },
limit: 50,
offset: 0
});
// [{ type: "user", id: "alice" }, { type: "user", id: "bob" }, ...]// Narrow to a subject typeconst users = await authz.listSubjects({
canThey: "view",
onWhat: { type: "document", id: "doc1" },
ofType: "user"
});
Pass context if any relevant grants use attribute conditions. Note that in field-level schemas, listSubjects/someoneCan/countSubjects now correctly surface subjects reachable through everyone(type) grants/memberships to group-acting types (which check() always allowed, but lists previously omitted).
Pattern 13: Debugging with explain
explain returns { allowed, via } where via is the path that produced the
decision (or null when denied) — the fastest way to answer "why?".
via.kind is one of direct, wildcard, field, group, or hierarchy;
nested via shows the full chain. See
polizy-troubleshooting for using explain
to diagnose failing checks.
Pattern 14: Runtime Custom Roles
Let end users define their own named roles (a permissions matrix: new
roles/columns over a fixed set of actions/rows) without a schema change. Roles
are pure tuples — withRoleScaffold adds a generic role type, a reserved
assignee group relation, and one cap_<action> relation per grantable
action, while preserving your schema's literal types. The engine is unchanged:
checking is the ordinary check().
import {
defineSchema,
AuthSystem,
InMemoryStorageAdapter,
withRoleScaffold,
RoleRegistry,
InMemoryRoleCatalog,
} from"polizy";
// 1. Your base schema (note the existing `member` group relation)const base = defineSchema({
relations: {
member: { type: "group" },
editor: { type: "direct" },
viewer: { type: "direct" },
},
actionToRelations: {
edit: ["editor"],
view: ["editor", "viewer"],
delete: ["editor"],
},
});
// 2. Merge in the role scaffold, declaring which actions are grantableconst schema = withRoleScaffold(base, {
grantable: ["edit", "view", "delete"],
});
// 3. The scaffold's `assignee` relation is auto-excluded from group inference,// so `member` is still the inferred default — name it to be explicit.const authz = newAuthSystem({
schema,
storage: newInMemoryStorageAdapter(),
defaultGroupRelation: "member",
});
// 4. Typed sugar over the existing write APIs; catalog keeps empty roles listableconst roles = newRoleRegistry(authz, schema, {
catalog: newInMemoryRoleCatalog(),
});
const tenant = { type: "workspace", id: "acme" };
// Define a role scoped to the tenant (caps written via one atomic allowMany)const editorRole = await roles.defineRole({
tenant,
name: "content-editor",
label: "Content Editor",
can: ["edit", "view"], // GrantableAction — typos rejected at COMPILE time
});
// Assign a user (membership via the `assignee` group relation)await roles.assignRole({ type: "user", id: "alice" }, editorRole);
// Toggle a cell in the matrix UIawait roles.grantToRole(editorRole, "delete"); // add capabilityawait roles.revokeFromRole(editorRole, "delete"); // remove capability// One read backing an "add role + click a cell to toggle" matrix UIconst matrix = await roles.permissionMatrix(tenant);
// { permissions: ["edit","view","delete"],// roles: [{ name: "content-editor", label: "Content Editor",// can: Set { "edit", "view" } }] }// Checking is UNCHANGED — no new verb:await authz.check({
who: { type: "user", id: "alice" },
canThey: "edit",
onWhat: { type: "document", id: "doc1" }, // a resource under the tenant
}); // true, via: user --assignee--> role --cap_edit--> resource
Roles vs. verbs (the honest boundary): runtime roles are named bundles of
existing actions — pure data, no schema change. A genuinely new permission
verb with new semantics is still a schema change (true in polizy and every
ReBAC system). The scaffold covers the common case: a permissions matrix with new
columns/roles over fixed rows/permissions.
See references/RUNTIME-ROLES.md for the full guide
(catalogs, RoleRef/roleRef, deleteRole cascades, wildcard roles, per-tenant
divergence, Prisma PolizyRole, and the nonSubjectTypes interaction).
Pattern 14: Read-Your-Writes / Contextual Tuples
Check permissions against temporary relationship tuples that act as if stored, allowing you to verify access (e.g. "read-your-writes" checks) before persisting tuples in the database.
const canView = await authz.check({
who: { type: "user", id: "alice" },
canThey: "view",
onWhat: { type: "document", id: "doc1" },
contextualTuples: [
{
subject: { type: "user", id: "alice" },
relation: "viewer",
object: { type: "document", id: "doc1" },
// Contextual tuples are raw InputTuples, so constraints ride under `condition`condition: {
validUntil: newDate("2026-12-31T23:59:59Z")
}
}
]
});
// => true (even if not in the database)
Contextual tuples are raw InputTuples, so time/attribute constraints must be defined under condition. Uniform read options supporting contextualTuples are accepted on check, checkOrThrow, explain, listSubjects, listAccessibleObjects, someoneCan, countSubjects, countAccessibleObjects, and withReadScope (scope-wide). checkMany supports contextualTuples only batch-wide (per-request not supported).