Skip to main content
Pylon’s RBAC layer is intentionally minimal: every AuthContext carries a roles: string[] array and (optionally) a tenant_id. Policies reference both via auth.hasRole(...) and auth.tenantId. There’s no role hierarchy, no permission grids, no @CanRead("Todo") decorators — just expressions over the auth context and the row. This page covers the data model, the policy syntax, and the patterns for typical multi-tenant apps.

Roles

AuthContext.roles is a string array. You decide the role names. Common choices:
Roles are typically stored on a per-user, per-tenant join table:
When the user calls /api/auth/select-org, the runtime loads the matching OrgMember.role into the session so policy auth.hasRole(...) / auth.hasAnyRole(...) checks see them on every request. (There’s no auth.roles array binding in the policy DSL — roles are only reachable through those two functions.)

Special roles

That’s it. No other role has built-in meaning. admin is granted automatically when a request authenticates with PYLON_ADMIN_TOKEN; it’s not something you give to user accounts.

Policy syntax

Policies live in pylon.manifest.json under policies:
match is the entity name. The other fields are boolean expressions over auth and data: Supported operators: ==, !=, <, <=, >, >=, &&, ||, !, parentheses, plus numeric literals and the now binding. Ordering compares numbers numerically and ISO-8601 strings chronologically (deny-safe otherwise). No arithmetic and no in/ends_with/starts_with. Role checks use the auth.hasRole(...) / auth.hasAnyRole(...) functions; cross-entity membership uses exists(Entity where field == <expr> [and ...]). String matching belongs in a function.

Common patterns

Row-scoped to author

Only the author can read or write their row:

Row-scoped to tenant

Multi-tenant apps where rows belong to an org and members of that org can access them:
The tenant_id flows in via the session — see Sessions → Multi-tenant.

Role-gated mutation

Read is open to all members; write requires elevation:

Public read, owner write

Common for blog posts / public profiles:

Soft “no one but admin”

Most internal/system tables:
"delete": "false" blocks delete entirely — admins can read/write but never remove.

Where policies run

Policies enforce on every entity-level operation: CRUD via /api/entities/*, sync push, query reads. They do not run inside server functions — once you’re in a mutation or action handler, you have direct DB access via ctx.db. Functions are the right place for “policy too complex to express as an expression” cases.

Reading roles in functions

Inside a mutation / query / action, ctx.auth exposes:
The handler ctx.auth carries no roles array, no hasRole, and no email — those are policy-expression / session concerns. For the admin case, ctx.auth.isAdmin is enough. To gate a function on org membership or a role, use the first-class ctx.requireMember helper — it looks up the membership row and fails closed:
This matters because functions bypass policies — a mutation/action with a forgotten membership check is an IDOR. Roles themselves “aren’t first-class data” (see below) — they’re strings on your membership table, which is exactly what requireMember reads.

Granting roles

Roles aren’t first-class data — they’re just strings on whatever join table you use. Typical “promote to admin” mutation:

Why no permission grid?

Pylon ships role-based access control, not attribute-based or capability-based. You could build a permission table and check it in policies with exists(Permission where todoId == existing.id and userId == auth.userId), but for 90% of apps “owner can write, member can read, admin bypasses” is enough — and policies stay readable. If you outgrow it, the policy expression language is intentionally narrow so you can move complex logic into a function without losing security guarantees.

Admin token

PYLON_ADMIN_TOKEN is set in the environment. Requests that pass it as Authorization: Bearer <token> resolve to AuthContext::admin()isAdmin: true, every hasRole(...) returns true, every policy is bypassed. This is for:
  • Migrations / backfills (pylon migrate apply)
  • Studio (the inspector)
  • CLI commands (pylon export, pylon backup)
  • Server-to-server calls between trusted services
Treat the admin token like a root password. Rotate it quarterly — see Token rotation.

Testing policies

Pylon runs policies in the same evaluator your tests can use:
Or assert via the HTTP layer in integration tests:

Recipes

  • Personal apps — single-user, no roles, no policies. Just auth.userId != null.
  • SaaS — tenant_id everywhere, roles on OrgMember, policies match data.orgId == auth.tenantId.
  • Forum / communityauth.hasRole('moderator') for soft-delete and ban actions.
  • Marketplaces — separate Buyer and Seller roles, policies check both.
  • Internal tools — single admin role, broad gates, audit log via the audit_log plugin.