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