Skip to main content

Authorization

Sign-in establishes who is calling. Authorization decides what they may do. In Reboot you declare that once per servicer, as a rule for each of its methods, and Reboot enforces the rule before the method runs — rather than an if statement buried in every method body.

Every call is checked in two steps:

  1. Token verification turns the caller's credential into an Auth object with a user_id (and any other properties you put on it). With oauth= configured, Reboot does this for you; see Bearer tokens for doing it yourself.
  2. Authorizers decide, per method, whether that caller may proceed.

Defaults you get for free​

Before writing any authorizer, know what you already have:

  • User methods are callable by that user, and by your own application code. Nobody else.
  • Every other state type is callable only by your own application code. A call arriving from outside is denied.

For a great many applications that is the entire authorization model: users reach their own User, User methods reach everything else on their behalf, and no external caller can address your internal types directly.

Overriding the defaults​

Implementing authorizer() on a servicer replaces that type's default entirely; nothing from it carries over. If your own application code should still be able to call a method, allow that explicitly with is_app_internal. On a User servicer, likewise re-state the owner rule with state_id_is_user_id if you still want it:

return User.Authorizer(
profile=allow_if(any=[state_id_is_user_id, is_app_internal]),
# ...
)

Both callables are described under Built-in callables below.

Relaxed during development​

Under rbt dev run, a call from outside to a type that has no authorizer() is allowed rather than denied, so you can build the app before deciding who may call what. Each such call logs a warning:

*** TodoList.todos IS MISSING AUTHORIZATION *** Calls to this method are ONLY ALLOWED during development and will be DENIED in production. See https://docs.reboot.dev/users/authorization#relaxed-during-development for more details. Will silence this message for the next 1 minute.

Under rbt serve and on Reboot Cloud those calls are denied. Implementing authorizer() on the servicer silences the warning. The User default is not relaxed; it applies in development too.

Writing an authorizer​

Implement authorizer() on your servicer and give a rule per method:

class CounterServicer(Counter.Servicer):
"""Servicer for the Counter state machine."""

def authorizer(self) -> Authorizer:
return Counter.Authorizer(
# `create` is restricted to trusted app code and records
# the counter's owner; every other method is restricted to
# that owner (or, again, to trusted app code).
create=allow_if(all=[is_app_internal]),
get=allow_if(any=[_caller_is_owner, is_app_internal]),
increment=allow_if(any=[_caller_is_owner, is_app_internal]),
description=allow_if(any=[_caller_is_owner, is_app_internal]),
)

You can also return a single rule directly from authorizer(), which then applies to every method on the type:

from reboot.aio.auth.authorizers import allow_if

class AccountServicer(Account.Servicer):

def authorizer(self):
return allow_if(all=[is_admin])

...

Rules​

Rules come from reboot.aio.auth.authorizers (Python) or @reboot-dev/reboot (TypeScript).

deny()​

Denies every request, your own application's included. Use it to take a method out of service without removing it:

from reboot.aio.auth.authorizers import deny

class TodoListServicer(TodoList.Servicer):

def authorizer(self):
return TodoList.Authorizer(
# Not ready yet: nobody may call it.
share=deny(),
# ...
)

allow()​

Allows every request. Use it deliberately — it makes the method callable by anyone who can reach your backend, signed in or not:

from reboot.aio.auth.authorizers import allow

class TodoListServicer(TodoList.Servicer):

def authorizer(self):
return TodoList.Authorizer(
# Anyone may read a list.
todos=allow(),
# ...
)

allow_if()​

Takes a set of authorizer callables — the functions that make the actual decision. Each returns an Authorizer.Decision: Ok, PermissionDenied, or Unauthenticated.

Pass them via all to require that every callable decides Ok, or via any to allow the call when at least one does:

from reboot.aio.auth.authorizers import allow_if, is_app_internal

class TodoListServicer(TodoList.Servicer):

def authorizer(self):
return TodoList.Authorizer(
# Only your own application code may create a list.
create=allow_if(all=[is_app_internal]),
# The list's owner may add to it; so may your own code.
add=allow_if(any=[_caller_is_owner, is_app_internal]),
)

_caller_is_owner (callerIsOwner) is written in the next section.

Authorizer callables​

A callable receives the context, the instance's state, and the request, and returns a decision. Here is one that allows the owner of a Counter and nobody else:

def _caller_is_owner(
*,
context: ReaderContext,
state: Optional[Counter.State],
**kwargs,
):
"""Allow when the caller's `user_id` matches the Counter's recorded
`owner_id`. A not-yet-constructed Counter (`state is None`) falls
through to deny."""
if context.auth is None or not context.auth.user_id:
return Unauthenticated()
if state is not None and context.auth.user_id == state.owner_id:
return Ok()
return PermissionDenied()

Note the pattern: it reads the caller's identity off context.auth, compares it against durable state, and returns a decision. It does not raise, and it does not mutate anything.

Built-in callables​

Reboot ships the ones almost every application needs:

CallableDecides Ok when
is_app_internalThe call originates inside your own application rather than from a client.
state_id_is_user_idThe caller's user_id equals the instance's state ID — the rule behind the User default.
has_verified_tokenThe caller presented a token that verified. Says nothing about which user it is.
note

Verifying a token and running authorizers must not have effects, so they always receive a ReaderContext regardless of the kind of method being authorized.

A pattern that works​

The combination in the Counter example above generalizes well:

create=allow_if(all=[is_app_internal]),
get=allow_if(any=[_caller_is_owner, is_app_internal]),
  • Construction is application-internal only, so instances are only ever created through a User method that records who owns them.
  • Every other method allows the recorded owner, or trusted application code.

That gives you per-instance ownership without a permissions table, and it holds no matter which frontend the call arrives from — web, native, or an MCP client.

Next​