Polizy Implementation Patterns
Copy-paste patterns for common authorization scenarios.
When to Apply
- User says "how do I implement X"
- User says "give team access to project"
- User says "make files inherit folder permissions"
- User says "grant temporary access"
- User says "revoke all permissions"
- User wants to implement a specific authorization scenario
Pattern Selection Guide
| Scenario | Pattern | Reference |
|---|---|---|
| Specific user → specific resource | Direct Permissions | DIRECT-PERMISSIONS.md |
| Team/group access | Group Access | GROUP-ACCESS.md |
| Folder/file inheritance | Hierarchy | HIERARCHY.md |
| Sensitive fields (salary, PII) | Field-Level | FIELD-LEVEL.md |
| Contractor/expiring access | Time-Limited | TIME-LIMITED.md |
| Removing access | Revocation | REVOCATION.md |
| Tenant isolation | Multi-Tenant | MULTI-TENANT.md |
| Public / "anyone with the link" | Wildcard | Pattern 9 |
| Conditional / context-based (ABAC) | Attribute conditions | Pattern 10 |
| Filtering a fetched list | Batch checks | Pattern 11 |
| Share dialog / access audit | Reverse expansion | Pattern 12 |
| Debugging "why allowed/denied" | Explain | Pattern 13 |
| End users define their own roles / permissions matrix | Runtime Custom Roles | references/RUNTIME-ROLES.md |
| Temporary/ephemeral checks | Read-Your-Writes | Pattern 14 |
0.3.0 quick notes used throughout these patterns
allow(),addMember(), andsetParent()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.viewerstanding vstemp_viewertime-boxed).- Field-level ids are opt-in: declare
fieldLevelObjects: ["document", ...].addMember/setParent/removeMember/removeParenttake an optionalas: "<relation>", required only when the schema declares more than one group/hierarchy relation.
Pattern 1: Direct Permissions
Grant specific user access to specific resource.
// Grant permission
await authz.allow({
who: { type: "user", id: "alice" },
toBe: "owner",
onWhat: { type: "document", id: "doc1" }
});
// Check permission
const canEdit = await authz.check({
who: { type: "user", id: "alice" },
canThey: "edit",
onWhat: { type: "document", id: "doc1" }
});
Pattern 2: Team-Based Access
Grant access through group membership.
// 1. Add users to team
await authz.addMember({
member: { type: "user", id: "alice" },
group: { type: "team", id: "engineering" }
});
await authz.addMember({
member: { type: "user", id: "bob" },
group: { type: "team", id: "engineering" }
});
// 2. Grant team access to resource
await authz.allow({
who: { type: "team", id: "engineering" },
toBe: "editor",
onWhat: { type: "project", id: "project1" }
});
// 3. Team members can now access
const canAliceEdit = await authz.check({
who: { type: "user", id: "alice" },
canThey: "edit",
onWhat: { type: "project", id: "project1" }
}); // true
Schema requirement:
relations: {
member: { type: "group" }, // Required!
editor: { type: "direct" },
}
With exactly one group relation,
addMember/removeMemberinfer it. If you declare more than one (e.g.memberandorgMember), passas: "member"on every member write/remove or it throws aSchemaError.
Pattern 3: Folder/File Hierarchy
Inherit permissions from parent resources.
// 1. Set up hierarchy
await authz.setParent({
child: { type: "document", id: "doc1" },
parent: { type: "folder", id: "folder1" }
});
// 2. Grant access at folder level
await authz.allow({
who: { type: "user", id: "alice" },
toBe: "viewer",
onWhat: { type: "folder", id: "folder1" }
});
// 3. Document inherits folder permission
const canView = await authz.check({
who: { type: "user", id: "alice" },
canThey: "view",
onWhat: { type: "document", id: "doc1" }
}); // true
Schema requirement:
relations: {
parent: { type: "hierarchy" }, // Required!
viewer: { type: "direct" },
},
hierarchyPropagation: {
view: ["view"], // CRITICAL: Without this, no inheritance!
}
With exactly one hierarchy relation,
setParent/removeParentinfer it. With more than one, passas: "parent"(or the relevant relation name).
Pattern 4: Field-Level Permissions
Grant access to specific fields within a record. Field-level ids are opt-in in
0.3.0 — list the object types that use them in fieldLevelObjects:
const schema = defineSchema({
relations: { viewer: { type: "direct" } },
actionToRelations: { view: ["viewer"] },
fieldLevelObjects: ["profile"], // ← required to enable "profile:emp123#salary"
});
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 grant
await 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 record
await authz.check({
who: { type: "user", id: "payroll" },
canThey: "view",
onWhat: { type: "profile", id: "emp123#salary" }
}); // true
await 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#fieldof 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.
// Access valid for 30 days
await authz.allow({
who: { type: "user", id: "contractor" },
toBe: "editor",
onWhat: { type: "project", id: "project1" },
when: {
validSince: new Date(),
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
}
});
// Scheduled future access
await authz.allow({
who: { type: "user", id: "new_hire" },
toBe: "viewer",
onWhat: { type: "onboarding", id: "docs" },
when: {
validSince: new Date("2026-02-01") // Starts Feb 1
}
});
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:// Standing viewer (permanent) await authz.allow({ who: alice, toBe: "viewer", onWhat: project }); // Temporary elevated editor — different relation, so both coexist await authz.allow({ who: alice, toBe: "temp_editor", onWhat: project, when: { validUntil: new Date(Date.now() + 86_400_000) } });Map
temp_editorinactionToRelations(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 permission
await authz.disallowAllMatching({
who: { type: "user", id: "bob" },
was: "editor",
onWhat: { type: "document", id: "doc1" }
});
// Remove all user permissions on a resource
await 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 group
await 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
});
Pattern 7: Listing Accessible Objects
Find what a user can access.
// List all documents alice can access
const result = await authz.listAccessibleObjects({
who: { type: "user", id: "alice" },
ofType: "document"
});
// Result:
// {
// accessible: [
// { object: { type: "document", id: "doc1" }, actions: ["edit", "view", "delete"] },
// { object: { type: "document", id: "doc2" }, actions: ["view"] },
// ]
// }
// Filter by action
const editableOnly = await authz.listAccessibleObjects({
who: { type: "user", id: "alice" },
ofType: "document",
canThey: "edit" // Only return editable documents
});
Pattern 8: Combining Patterns
Real apps often combine multiple patterns:
// Organizational structure (groups)
await authz.addMember({ member: alice, group: frontend });
await authz.addMember({ member: frontend, group: engineering });
// Resource hierarchy
await authz.setParent({ child: codeFile, parent: srcFolder });
await authz.setParent({ child: srcFolder, parent: projectRoot });
// Team access at project level
await authz.allow({ who: engineering, toBe: "editor", onWhat: projectRoot });
// Alice can now edit codeFile through:
// alice → member → frontend → member → engineering → editor → projectRoot ← parent ← srcFolder ← parent ← codeFile
await authz.check({ who: alice, canThey: "edit", onWhat: codeFile }); // true
Pattern 9: Public / Wildcard Access
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 document
await authz.allow({
who: everyone("user"),
toBe: "viewer",
onWhat: { type: "document", id: "public-readme" }
});
// A specific, un-granted user passes the check via the wildcard
await 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 incheck(),explain(), andlistAccessibleObjects.
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).
// Only viewable by users whose context says department === "eng"
await authz.allow({
who: { type: "user", id: "alice" },
toBe: "viewer",
onWhat: { type: "document", id: "eng-doc" },
when: { attributes: [{ attribute: "department", operator: "eq", value: "eng" }] }
});
await authz.check({
who: { type: "user", id: "alice" },
canThey: "view",
onWhat: { type: "document", id: "eng-doc" },
context: { department: "eng" }
}); // true
await authz.check({
who: { type: "user", id: "alice" },
canThey: "view",
onWhat: { type: "document", id: "eng-doc" },
context: { department: "sales" }
}); // false
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.
const docs = await db.documents.findMany({ take: 50 });
const allowed = await authz.checkMany(
docs.map((d) => ({
who: { type: "user", id: userId },
canThey: "view",
onWhat: { type: "document", id: d.id }
}))
);
const visible = docs.filter((_, i) => allowed[i]);
checkOrThrow is the throwing counterpart of check for single guards:
await authz.checkOrThrow({ who: user, canThey: "edit", onWhat: doc });
// throws NotAuthorizedError instead of returning false
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 sort
const 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 type
const 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?".
const result = await authz.explain({
who: { type: "user", id: "alice" },
canThey: "edit",
onWhat: { type: "document", id: "doc1" }
});
// result.allowed === true
// result.via === {
// kind: "group", relation: "member", through: { type: "team", id: "eng" },
// via: { kind: "direct", relation: "editor" }
// }
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 grantable
const 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 = new AuthSystem({
schema,
storage: new InMemoryStorageAdapter(),
defaultGroupRelation: "member",
});
// 4. Typed sugar over the existing write APIs; catalog keeps empty roles listable
const roles = new RoleRegistry(authz, schema, {
catalog: new InMemoryRoleCatalog(),
});
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 UI
await roles.grantToRole(editorRole, "delete"); // add capability
await roles.revokeFromRole(editorRole, "delete"); // remove capability
// One read backing an "add role + click a cell to toggle" matrix UI
const 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: new Date("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).
Common Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Missing member: { type: "group" } |
addMember() throws |
Add group relation to schema |
Missing parent: { type: "hierarchy" } |
setParent() throws |
Add hierarchy relation to schema |
Missing hierarchyPropagation |
Parent permissions don't flow | Add propagation config |
Relation not in actionToRelations |
check() returns false |
Add relation to action's array |
| Checking wrong action | check() returns false |
Verify action name matches schema |
Field id used but type not in fieldLevelObjects |
Field check returns false (id treated literally) | Add the type to fieldLevelObjects |
| Standing + temporary grant on same triple | Second allow() overwrites the first's condition |
Use distinct relations (viewer vs temp_viewer) |
Omitting as with >1 group/hierarchy relation |
SchemaError on member/parent write |
Pass as: "<relation>" |
Empty base/field in field id (e.g. #salary) |
SchemaError on write |
Use non-empty base AND field |
References
Each pattern has detailed documentation:
- DIRECT-PERMISSIONS.md - Simple user-resource access
- GROUP-ACCESS.md - Teams, departments, nested groups
- HIERARCHY.md - Folders, projects, inheritance
- FIELD-LEVEL.md - PII, sensitive data protection
- TIME-LIMITED.md - Contractors, expiring access
- REVOCATION.md - Removing access patterns
- MULTI-TENANT.md - Tenant isolation strategies
- RUNTIME-ROLES.md - End-user custom roles, permissions matrix
Related Skills
- polizy-schema - Schema design
- polizy-troubleshooting - When things go wrong