Implementation patterns for polizy authorization. Use when implementing team access, folder inheritance, field-level permissions, temporary access, revocation, or any specific authorization scenario.
Installer avec Codex ou Claude Copiez ce prompt, collez-le dans Codex, Claude ou un autre assistant, puis laissez-le vérifier la page du skill et l'installer pour vous.
Une commande directe contourne le prompt de vérification. Examinez la source avant de l'exécuter.
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).