Access control

Policies grant actions, roles carry policies, and every request is checked against them. Deny by default, everywhere.

Access control is one mechanism used everywhere. A principal (a user, an agent, a scheduled job) holds roles; roles carry policies; a policy grants actions. Every request, every handler SDK call and every agent tool call is checked against the caller's policies, live. Nothing is allowed unless a policy allows it.

The examples on this page use the tables of a sample invoicing app (invoices, vendors, payments). Your app has only the tables you create, so put your own table and column names in their place. About the example app

This page is the model and the rules. Assigning roles covers giving people and agents their roles, and Protecting data covers redaction, locks and protected tables. The governance model explains why it is built this way.

Actions

An action is a key, resource:action:

invoices:read_list
invoices:create
orders:refund
org_invitations:orgInvitesCreate
  • Your tables each get five, as soon as the table exists: create, read, read_list, update and delete. Nobody holds them until a policy grants them.
  • Custom actions you add to a table carry their own key, which is what lets you grant orders:refund without granting orders:update. See Custom actions.
  • Authoric's own features (sign-in, invitations, storage, agents, scheduled jobs) have keys of their own. The console's policy editor groups them into named capabilities, such as "Org invites" or "Two-factor authentication", so you switch on a feature rather than ticking eight keys.

When a request is refused, the 403 message names the action: Access denied: 'invoices:create' — no policy bound to you allows it. Grant that key, or a capability containing it.

Policies

A policy is a named set of rules, keyed by action:

{
  "version": "1.0",
  "defaultEffect": "deny",
  "actions": {
    "invoices:read_list": [{ "effect": "allow" }],
    "invoices:read": [{ "effect": "allow" }],
    "invoices:update": [{ "effect": "allow", "when": ["isOwner"] }],
    "invoices:delete": [{ "effect": "deny" }]
  }
}

In the console you build policies under App access › Policies: capability switches for Authoric's features, five checkboxes for each of your tables, a switch per handler or tool, and an advanced editor for individual rules.

An app's policies, each listing how many actions it grants and whether it is active
A policy is a named set of grants. What a principal can do is the union of the policies on its roles, and nothing more.

How rules resolve

When several rules match one action, in order:

  1. An explicit deny you wrote on that exact action wins, whatever its priority. "Grant the whole capability, except delete" works the way it reads. (A * wildcard deny does not get this power, so one broad rule cannot lock you out of fixing it.)
  2. A rule naming the exact action beats a * wildcard.
  3. A more specific binding beats a broader one: one person, then a role, then the organization, then everyone.
  4. Lower priority numbers win, then deny beats allow on a tie.

Conditions

A rule's when list must all pass for the rule to apply:

ConditionPasses when
isSelf, notSelfThe record is (or is not) the caller's own account
isOwnerThe record's owner_id, user_id or created_by is the caller
hasRole:<key>The caller holds that role
argMax:<field>=<n>, argMin:<field>=<n>A tool call's numeric argument is at most / at least n
argEquals:<field>=<v>, argIn:<field>=<a>|<b>, argNotIn:…, argPresent:<field>A tool call's argument matches

isSelf and isOwner test the one record a request addresses, so they apply to read, update and delete of a single record, never to lists or to actions that address no record. Organization isolation needs no condition at all: inside the app it is already applied to every query (see Organizations).

The argument conditions are how "may refund" becomes "may refund up to $50":

{
  "actions": {
    "mcp_stripe:create_refund": [{ "effect": "allow", "when": ["argMax:amount_cents=5000"] }]
  }
}

They read the arguments of an agent tool call, an MCP tool call, or the same call made from a handler. They fail closed: a missing or unreadable argument does not satisfy the condition, so omitting amount_cents cannot skip the ceiling.

Roles

Policies are bound to roles, and roles to principals. A principal never holds a policy directly (with one exception, below), so "what can this person do" is answered in one place, and removing a policy from a role removes it from everyone holding the role at once.

Users, agents and scheduled jobs are all given roles the same way. An agent's tools are derived from the policies on its roles, so granting an agent an ability is the same act as granting a person one.

An app's roles listed with the policies bound to each
Roles are the only thing principals hold, so revoking a policy from a role revokes it everywhere at once.

What a new app grants out of the box

Deny is the default everywhere. A newly provisioned app grants only this:

WhoHoldsCan
Anyone, signed in or notThe seeded auth grantsSign up, sign in by every method, answer a 2FA challenge, refresh, sign out, read /auth/methods
Every member of an organizationOrg Member Baseline (bound to each person, not a role)Switch and leave organizations, handle invitations addressed to them, manage their own sign-in methods, 2FA and sessions
user roleOrg Member Read, Org Member Self-Edit, App Product PurchaseRead users in their organization, edit their own account, buy your products
admin roleOrg Admin Management, Org Invite and AcceptManage the organization's users, invitations and members, rename it, set its 2FA requirement
superuser roleThe above, plus Org OwnerAlso create roles in the organization and assign them, within what they hold

Someone who signs up holds user and superuser in the organization they created. Someone who joins by invitation holds no role, only the baseline.

Nobody can read your tables until you say so

No seeded role grants anything on the tables you create, or on your handlers, endpoints, agents or scheduled jobs. Those are your app's structure. Grant your users exactly the table actions they need by putting them in a policy on the user role (or a role of your own). Allow All exists as a seeded policy but is bound to nothing; binding it hands over your app's structure as well as its data.

Seeded policies are wiring: they cannot be edited or unbound (423 POLICY_IS_SYSTEM). Seeded roles cannot be deleted (ROLE_PROTECTED). Make your own policy and bind it instead.

Errors to branch on

StatusCodeMeaning
403(none)No policy bound to the caller allows the action. The message names it.
403GRANT_EXCEEDS_HOLDERSomeone tried to grant a role or policy carrying actions they do not hold. The response names them.
403POLICY_ROLE_SCOPE_MISMATCHAn organization's own policy can only be bound to that organization's roles.
403STAFF_ONLYThe action belongs to Authoric's own staff. Nothing you can grant reaches it.
423POLICY_IS_SYSTEMA seeded policy cannot be edited or unbound.
423TABLE_LOCKEDThe table is locked against this operation. See Protecting data.

What's next

On this page