Skip to main content
Pylon’s RBAC layer keeps roles in AuthContext.roles and the active tenant in AuthContext.tenant_id. Policies reference them through auth.hasRole(...) and auth.tenantId. More complex authorization stays in policy expressions or server functions. This page covers the data model, the policy syntax, and the patterns for typical multi-tenant apps.

Roles

AuthContext.roles is a string array. Organization roles must be built-ins or declared in the app manifest:
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 is no auth.roles array binding in the policy DSL. Roles are reachable only through those two functions.)

Special roles

Organization owners and admins receive only their exact org role in roles. The framework org-management routes authorize those built-ins separately. Custom roles have no inheritance: a reviewer does not satisfy member or admin. An admin context with an active org is a special case. Its writes still bypass every policy. Its reads do not. Pylon scopes an admin read to the active org, and the read policy runs. So the admin sees only that org’s rows, like a member. An admin context with no active org (an operator, PYLON_ADMIN_TOKEN, or Studio) bypasses reads too.

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). There is 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 through 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 and write but never remove.

Where policies run

Policies enforce on every entity-level operation: CRUD via /api/entities/*, sync push, and query reads. They do not run inside server functions. Once you are in a mutation or action handler, you have direct DB access via ctx.db. Use functions when a policy is too complex to express as an expression.

Reading roles in functions

Inside a mutation / query / action, ctx.auth exposes:
The handler ctx.auth carries no hasRole helper and no email. roles is useful for branching and rendering, but authorization should use the ctx.requireMember helper. It looks up the membership row and fails closed:
Functions bypass policies, so a mutation or action with a forgotten membership check is an IDOR. The manifest declares which role strings are valid. Assignments live on the membership table, which is what requireMember reads.

Granting roles

Role assignments are strings on the membership table. Prefer the built-in PUT /api/auth/orgs/:orgId/members/:userId endpoint, which validates them against built-ins plus auth.orgRoles. A custom mutation should apply the same allowlist:

Extending beyond roles

For finer-grained access, build a permission table and check it in policies with exists(Permission where todoId == existing.id and userId == auth.userId). Keep complex rules in a server function when they no longer read clearly as policy expressions.

Admin token

PYLON_ADMIN_TOKEN is set in the environment. A request that passes it as Authorization: Bearer <token> resolves to AuthContext::admin(): isAdmin: true, and every hasRole(...) returns true. This context has no active org, so it bypasses every policy, including reads. 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.