| name | polizy-patterns |
| description | Implementation patterns for polizy authorization. Use when implementing team access, folder inheritance, field-level permissions, temporary access, revocation, or any specific authorization scenario. |
| license | MIT |
| metadata | {"author":"bratsos","version":"0.5.0","repository":"https://github.com/bratsos/polizy"} |
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
0.3.0 quick notes used throughout these patterns
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.
Pattern 1: Direct Permissions
Grant specific user access to specific resource.
await authz.allow({
who: { type: "user", id: "alice" },
toBe: "owner",
onWhat: { type: "document", id: "doc1" }
});
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.
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" }
});
await authz.allow({
who: { type: "team", id: "engineering" },
toBe: "editor",
onWhat: { type: "project", id: "project1" }
});
const canAliceEdit = await authz.check({
who: { type: "user", id: "alice" },
canThey: "edit",
onWhat: { type: "project", id: "project1" }
});
Schema requirement:
relations: {
member: { type: "group" },
editor: { type: "direct" },
}
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.
Pattern 3: Folder/File Hierarchy
Inherit permissions from parent resources.
await authz.setParent({
child: { type: "document", id: "doc1" },
parent: { type: "folder", id: "folder1" }
});
await authz.allow({
who: { type: "user", id: "alice" },
toBe: "viewer",
onWhat: { type: "folder", id: "folder1" }
});
const canView = await authz.check({
who: { type: "user", id: "alice" },
canThey: "view",
onWhat: { type: "document", id: "doc1" }
});
Schema requirement:
relations: {
parent: { type: "hierarchy" },
viewer: { type: "direct" },
},
hierarchyPropagation: {
view: ["view"],
}
With exactly one hierarchy relation, setParent/removeParent infer it. With
more than one, pass as: "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"],
});
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.
await authz.allow({
who: { type: "user", id: "hr_manager" },
toBe: "viewer",
onWhat: { type: "profile", id: "emp123" }
});
await authz.allow({
who: { type: "user", id: "payroll" },
toBe: "viewer",
onWhat: { type: "profile", id: "emp123#salary" }
});
await authz.check({
who: { type: "user", id: "hr_manager" },
canThey: "view",
onWhat: { type: "profile", id: "emp123#salary" }
});
await authz.check({
who: { type: "user", id: "payroll" },
canThey: "view",
onWhat: { type: "profile", id: "emp123#salary" }
});
await authz.check({
who: { type: "user", id: "payroll" },
canThey: "view",
onWhat: { type: "profile", id: "emp123" }
});
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.
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)
}
});
await authz.allow({
who: { type: "user", id: "new_hire" },
toBe: "viewer",
onWhat: { type: "onboarding", id: "docs" },
when: {
validSince: new Date("2026-02-01")
}
});
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:
await authz.allow({ who: alice, toBe: "viewer", onWhat: project });
await authz.allow({ who: alice, toBe: "temp_editor", onWhat: project,
when: { validUntil: new Date(Date.now() + 86_400_000) } });
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.
await authz.disallowAllMatching({
who: { type: "user", id: "bob" },
was: "editor",
onWhat: { type: "document", id: "doc1" }
});
await authz.disallowAllMatching({
who: { type: "user", id: "bob" },
onWhat: { type: "document", id: "doc1" }
});
await authz.disallowAllMatching({
onWhat: { type: "document", id: "doc1" }
});
await authz.removeMember({
member: { type: "user", id: "alice" },
group: { type: "team", id: "engineering" }
});
await authz.removeMember({
member: { type: "user", id: "alice" },
group: { type: "org", id: "acme" },
as: "orgMember"
});
Pattern 7: Listing Accessible Objects
Find what a user can access.
const result = await authz.listAccessibleObjects({
who: { type: "user", id: "alice" },
ofType: "document"
});
const editableOnly = await authz.listAccessibleObjects({
who: { type: "user", id: "alice" },
ofType: "document",
canThey: "edit"
});
Pattern 8: Combining Patterns
Real apps often combine multiple patterns:
await authz.addMember({ member: alice, group: frontend });
await authz.addMember({ member: frontend, group: engineering });
await authz.setParent({ child: codeFile, parent: srcFolder });
await authz.setParent({ child: srcFolder, parent: projectRoot });
await authz.allow({ who: engineering, toBe: "editor", onWhat: projectRoot });
await authz.check({ who: alice, canThey: "edit", onWhat: codeFile });
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";
await authz.allow({
who: everyone("user"),
toBe: "viewer",
onWhat: { type: "document", id: "public-readme" }
});
await authz.check({
who: { type: "user", id: "random-visitor" },
canThey: "view",
onWhat: { type: "document", id: "public-readme" }
});
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).
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" }
});
await authz.check({
who: { type: "user", id: "alice" },
canThey: "view",
onWhat: { type: "document", id: "eng-doc" },
context: { department: "sales" }
});
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 });
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.
const subjects = await authz.listSubjects({
canThey: "view",
onWhat: { type: "document", id: "doc1" }
});
const users = await authz.listSubjects({
canThey: "view",
onWhat: { type: "document", id: "doc1" },
ofType: "user"
});
Pass context if any relevant grants use attribute conditions.
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" }
});
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";
const base = defineSchema({
relations: {
member: { type: "group" },
editor: { type: "direct" },
viewer: { type: "direct" },
},
actionToRelations: {
edit: ["editor"],
view: ["editor", "viewer"],
delete: ["editor"],
},
});
const schema = withRoleScaffold(base, {
grantable: ["edit", "view", "delete"],
});
const authz = new AuthSystem({
schema,
storage: new InMemoryStorageAdapter(),
defaultGroupRelation: "member",
});
const roles = new RoleRegistry(authz, schema, {
catalog: new InMemoryRoleCatalog(),
});
const tenant = { type: "workspace", id: "acme" };
const editorRole = await roles.defineRole({
tenant,
name: "content-editor",
label: "Content Editor",
can: ["edit", "view"],
});
await roles.assignRole({ type: "user", id: "alice" }, editorRole);
await roles.grantToRole(editorRole, "delete");
await roles.revokeFromRole(editorRole, "delete");
const matrix = await roles.permissionMatrix(tenant);
await authz.check({
who: { type: "user", id: "alice" },
canThey: "edit",
onWhat: { type: "document", id: "doc1" },
});
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).
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:
Related Skills