| name | polizy-schema |
| description | Schema design guide for polizy authorization. Use when defining relations, actions, action mappings, hierarchy propagation, or modifying authorization models. Covers direct, group, and hierarchy relation types. |
| license | MIT |
| metadata | {"author":"bratsos","version":"0.5.0","repository":"https://github.com/bratsos/polizy"} |
Polizy Schema Design
The schema is the heart of polizy. It defines your authorization model: what relationships exist and what actions they enable.
When to Apply
- User says "design permissions schema" or "define authorization model"
- User asks "what relations do I need for X"
- User says "add new relation" or "add new action"
- User is confused about relation types (direct vs group vs hierarchy)
- User wants to modify their existing schema
- User asks about
defineSchema or actionToRelations
Priority Table
| Priority | Decision | Impact |
|---|
| Critical | Choose correct relation types | Wrong type = broken inheritance |
| Critical | Map all actions to defined relations | Dangling reference = defineSchema throws SchemaError at startup |
| Critical | Declare fieldLevelObjects if you use # field ids | Omitted = doc1#field checks return false (secure default) |
| Important | Configure hierarchyPropagation | Without it, no parent→child inheritance |
| Important | Name multiple group/hierarchy relations clearly | Write APIs need as to disambiguate |
| Important | Use semantic names | Clarity for future maintainers |
| Optional | Keep schema minimal | Start simple, expand as needed |
Schema Structure
import { defineSchema } from "polizy";
const schema = defineSchema({
subjectTypes: ["user", "team"],
objectTypes: ["document", "folder", "team"],
relations: {
owner: { type: "direct" },
editor: { type: "direct" },
viewer: { type: "direct" },
member: { type: "group" },
parent: { type: "hierarchy" },
},
actionToRelations: {
delete: ["owner"],
edit: ["owner", "editor"],
view: ["owner", "editor", "viewer"],
},
hierarchyPropagation: {
view: ["view"],
edit: ["edit"],
},
fieldLevelObjects: ["document"],
});
0.3.0 — defineSchema throws. If an action maps to an undefined relation,
or hierarchyPropagation references an undefined action, defineSchema throws
a SchemaError at definition time (it no longer console.warns and continues).
This catches dangling references the moment your app boots.
Relation Types Quick Reference
| Type | Purpose | Example | Use When |
|---|
direct | User → Resource | alice is owner of doc1 | Specific user needs specific resource access |
group | User → Group membership | alice is member of engineering | Team-based access, organizational structure |
hierarchy | Resource → Parent resource | doc1's parent is folder1 | Folder/file, project/task, inherited permissions |
See RELATION-TYPES.md for detailed explanations.
Common Schema Patterns
Basic Document Access
const schema = defineSchema({
relations: {
owner: { type: "direct" },
editor: { type: "direct" },
viewer: { type: "direct" },
},
actionToRelations: {
delete: ["owner"],
edit: ["owner", "editor"],
view: ["owner", "editor", "viewer"],
},
});
Team-Based Access
const schema = defineSchema({
relations: {
owner: { type: "direct" },
editor: { type: "direct" },
viewer: { type: "direct" },
member: { type: "group" },
},
actionToRelations: {
manage: ["owner"],
edit: ["owner", "editor"],
view: ["owner", "editor", "viewer"],
},
});
Hierarchical Resources (Folders/Files)
const schema = defineSchema({
relations: {
owner: { type: "direct" },
editor: { type: "direct" },
viewer: { type: "direct" },
parent: { type: "hierarchy" },
},
actionToRelations: {
delete: ["owner"],
edit: ["owner", "editor"],
view: ["owner", "editor", "viewer"],
},
hierarchyPropagation: {
view: ["view"],
edit: ["edit"],
},
});
Full-Featured Schema
const schema = defineSchema({
relations: {
owner: { type: "direct" },
editor: { type: "direct" },
viewer: { type: "direct" },
commenter: { type: "direct" },
member: { type: "group" },
parent: { type: "hierarchy" },
},
actionToRelations: {
delete: ["owner"],
transfer: ["owner"],
edit: ["owner", "editor"],
comment: ["owner", "editor", "commenter"],
view: ["owner", "editor", "viewer", "commenter"],
},
hierarchyPropagation: {
view: ["view"],
edit: ["edit"],
comment: ["comment"],
},
});
Decision Guide: Which Relation Type?
Need to grant access to a specific user on a specific resource?
→ Use "direct" relation (owner, editor, viewer)
Need users to inherit access from a team/department?
→ Use "group" relation (member)
→ Add users to groups with addMember()
→ Grant group access with allow()
Need child resources to inherit parent permissions?
→ Use "hierarchy" relation (parent)
→ Set parent with setParent()
→ Configure hierarchyPropagation
Multiple Group / Hierarchy Relations (0.3.0)
A schema can now declare more than one group relation and/or more than
one hierarchy relation. check() traverses all of them. This lets you model
distinct membership/containment axes — e.g. team membership and org
membership, or a document's folder parent and its owning org.
const schema = defineSchema({
relations: {
owner: { type: "direct" },
viewer: { type: "direct" },
member: { type: "group" },
orgMember: { type: "group" },
folderParent: { type: "hierarchy" },
orgParent: { type: "hierarchy" },
},
actionToRelations: {
view: ["owner", "viewer", "member", "orgMember"],
edit: ["owner"],
},
hierarchyPropagation: {
view: ["view"],
},
});
Disambiguating writes with as. When the schema has more than one relation
of a kind, the write APIs can't guess which to use, so you pass as. With
exactly one (the common case) as is inferred. Omitting it when it's ambiguous
throws a SchemaError.
await authz.addMember({ member: user, group: team, as: "member" });
await authz.addMember({ member: user, group: org, as: "orgMember" });
await authz.setParent({ child: doc, parent: folder, as: "folderParent" });
await authz.setParent({ child: folder, parent: org, as: "orgParent" });
await authz.removeMember({ member: user, group: team, as: "member" });
as is type-checked against your declared relation names — passing a relation
that isn't of the right kind (or doesn't exist) is a compile-time error and, at
runtime, a SchemaError.
Runtime Custom Roles (withRoleScaffold)
0.5.0 lets end users define their own named roles at runtime — without a
schema change — as long as those roles bundle the existing, type-safe action
vocabulary (the common "permissions matrix": new role columns, fixed permission
rows). withRoleScaffold merges a generic role scaffold into your schema while
preserving its literal types. It adds, exactly once:
- a
role object type,
- a reserved
assignee group relation (user --assignee--> role),
- one
cap_<action> direct relation per grantable action, appended to that
action's actionToRelations.
Defaults: roleType: "role", assigneeRelation: "assignee", capPrefix: "cap_"
(all overridable). It throws SchemaError if the assignee relation name
collides, a cap_<action> relation collides, or a grantable action isn't already
present in actionToRelations.
A custom role resolves on the unchanged engine as ordinary tuples —
user --assignee(group)--> role --cap_<action>(direct)--> resource — and you
check it with the ordinary check(). There is no new verb and no new storage
table for the roles themselves (the optional PolizyRole catalog is metadata
only; the engine never reads it).
Grantable actions stay literal-typed: GrantableAction<S> is the compile-time
union of scaffolded actions, so defineRole({ can }), grantToRole(role, action),
and check({ canThey }) reject typos at compile time. Only the role name is a
runtime string.
import { defineSchema, withRoleScaffold, AuthSystem } from "polizy";
const base = defineSchema({
objectTypes: ["document", "workspace", "role"],
relations: {
owner: { type: "direct" },
editor: { type: "direct" },
viewer: { type: "direct" },
member: { type: "group" },
parent: { type: "hierarchy" },
},
actionToRelations: {
delete: ["owner"],
edit: ["owner", "editor"],
view: ["owner", "editor", "viewer"],
},
hierarchyPropagation: { view: ["view"], edit: ["edit"] },
});
const schema = withRoleScaffold(base, {
grantable: ["edit", "view", "delete"],
});
const authz = new AuthSystem({
schema,
storage,
defaultGroupRelation: "member",
});
New AuthSystem config (0.5.0, all backward compatible)
defaultGroupRelation? — the relation addMember/removeMember use when no
as is given and the schema declares more than one group relation. The
scaffold's assignee relation is auto-excluded from group inference, so a
schema that previously had exactly one group relation keeps inferring it after
opting into the scaffold (this is why the scaffold is non-breaking). If you also
add your own extra group relations, set defaultGroupRelation to keep existing
addMember calls working without as.
defaultHierarchyRelation? — same idea for setParent/removeParent when
there is more than one hierarchy relation.
nonSubjectTypes? — object types that must NOT surface in listSubjects
unless explicitly requested via ofType. The scaffold's role type is added
automatically, so role objects never leak as subjects.
Passing a defaultGroupRelation/defaultHierarchyRelation that isn't a relation
of that kind throws SchemaError at construction.
Wildcard membership now propagates through groups
Assigning everyone(type) to a group/role (e.g. assigning every user to a role)
now grants every subject of that type through group recursion — honored in
check(), explain(), and listAccessibleObjects. (Previously wildcard members
were silently ignored during group traversal.)
The ergonomic, typed RoleRegistry (defineRole / grantToRole / assignRole /
permissionMatrix, etc.) and the catalog stores live in polizy-patterns — see
RUNTIME-ROLES.md for the full
end-user-custom-roles recipe.
Field-Level Objects (0.3.0)
Field-level identifiers let an object id carry a field after the separator
(default #): document:doc1#summary. A grant on the base object (doc1)
authorizes its fields (doc1#summary) — via direct, group, and hierarchy
paths — while a grant on a specific field stays scoped to that field.
In 0.3.0 this is opt-in: only types listed in fieldLevelObjects split on
the separator. Types not listed never split, so ids that naturally contain #
can't accidentally leak access (the secure default).
const schema = defineSchema({
objectTypes: ["document", "folder"],
relations: {
owner: { type: "direct" },
viewer: { type: "direct" },
parent: { type: "hierarchy" },
},
actionToRelations: {
view: ["owner", "viewer"],
edit: ["owner"],
},
hierarchyPropagation: { view: ["view"] },
fieldLevelObjects: ["document"],
fieldSeparator: "#",
});
- Omit
fieldLevelObjects to disable field ids entirely.
- Field ids are validated on write — an empty base or empty field throws.
- See polizy-patterns for field-level recipes.
Conditions: Time Windows + Attribute Predicates (ABAC)
Conditions are attached to tuples at grant time (not in the schema), but
schema authors should know the shape because conditions decide whether a
matching tuple actually grants access. A tuple grants access only while its
condition is valid: within the optional time window AND with every attribute
predicate satisfied by the per-check context. Evaluation is fail-closed (a
missing context value or type mismatch fails the predicate).
type Condition = {
validSince?: Date;
validUntil?: Date;
attributes?: AttributePredicate[];
};
type AttributePredicate = {
attribute: string;
operator: "eq" | "ne" | "in" | "nin" | "gt" | "gte" | "lt" | "lte";
value: JsonScalar | JsonScalar[];
};
await authz.allow({
who: user, toBe: "viewer", onWhat: doc,
when: { validUntil: new Date(Date.now() + 3_600_000) },
});
await authz.allow({
who: user, toBe: "viewer", onWhat: doc,
when: { attributes: [{ attribute: "department", operator: "eq", value: "engineering" }] },
});
await authz.check({ who: user, canThey: "view", onWhat: doc,
context: { department: "engineering" } });
Schema-design implication: allow() is idempotent on
(subject, relation, object), so a re-grant updates the condition instead of
creating a second tuple. A temporary and a standing grant that differ only by
condition therefore can't coexist on the same triple — model "temporary +
standing" as distinct relations (e.g. viewer standing, temp_viewer
time-boxed). See polizy-patterns for the recipe.
Common Mistakes
| Mistake | Symptom | Fix |
|---|
| Action references undefined relation | defineSchema throws SchemaError at startup | Define the relation, or remove it from the action's array |
hierarchyPropagation references undefined action | defineSchema throws SchemaError at startup | Use only actions that exist in actionToRelations |
| Action not listed in actionToRelations | check() returns false (unknown action denies) | Add the action and map it to relations |
No member: { type: "group" } | addMember() throws SchemaError | Add a group relation to schema |
No parent: { type: "hierarchy" } | setParent() throws SchemaError | Add a hierarchy relation to schema |
>1 group/hierarchy relation, no as | addMember()/setParent() throws SchemaError | Pass as: "..." to disambiguate |
Opted into the scaffold + own extra group relation, addMember throws "multiple group relations" | addMember() can't infer the group | Set defaultGroupRelation: "member" on AuthSystem (or pass as) |
Scaffolding an action not in actionToRelations | withRoleScaffold throws SchemaError | Only list grantable actions that already exist in actionToRelations |
Using # ids without fieldLevelObjects | doc1#field checks return false | Add the type to fieldLevelObjects |
| Missing hierarchyPropagation | Parent permissions don't flow to children | Add hierarchyPropagation config |
| Using generic names ("access") | Can't distinguish read/write | Use semantic names (viewer, editor) |
Schema Evolution
When adding to an existing schema:
const schemaV1 = defineSchema({
relations: {
owner: { type: "direct" },
viewer: { type: "direct" },
},
actionToRelations: {
edit: ["owner"],
view: ["owner", "viewer"],
},
});
const schemaV2 = defineSchema({
relations: {
owner: { type: "direct" },
editor: { type: "direct" },
viewer: { type: "direct" },
},
actionToRelations: {
edit: ["owner", "editor"],
view: ["owner", "editor", "viewer"],
},
});
const schemaV3 = defineSchema({
relations: {
owner: { type: "direct" },
editor: { type: "direct" },
viewer: { type: "direct" },
member: { type: "group" },
},
actionToRelations: {
edit: ["owner", "editor"],
view: ["owner", "editor", "viewer"],
},
});
Important: Existing tuples remain valid when you add new relations/actions. No migration needed.
Related Skills
References