Authorization#

Restly asks one method whether the caller may perform an action: authorize(action, obj=None, data=None). The default does nothing, so every action is allowed. Override it on a view, and raise fr.exc.Forbidden to refuse an action. The client then gets a 403.

Restly calls authorize for every action, reads included:

Route

action

obj

data

GET /

"get_many"

None

None

GET /{id}

"get_one"

the loaded row

None

POST /

"create"

None

the validated request body

PATCH /{id}

"update"

the loaded row

the validated request body

DELETE /{id}

"delete"

the loaded row

None

a write_action block

its name, such as "publish"

the obj it gets when that is a row from the database; None for a new object or no object

the data it gets

For get_one, update and delete, the handler first loads the row through the view’s scope, and then calls authorize with it. So authorize only gets rows that the caller can see. For get_many, it runs before the query and gets no row. On a write, it runs before the change is made. A read shows where authorize runs among the steps of a request.

A refusal from authorize is a 403: the caller can see the row, but may not do this. To hide a row, so that the caller gets a 404 as if the row did not exist, use a scope instead.

Restly does not check who the caller is. Your app does that with FastAPI’s security tools, and binds the caller with Request Context.

So a rule goes in one of two places. A rule about who the caller is, such as “the caller is logged in” or “the caller is an admin”, goes in a FastAPI dependency: on the app, on a router, or in the view’s dependencies. A rule that depends on the action, the row or the request body goes in authorize. A dependency runs before Restly loads the row, so it cannot see the row.

Allow or refuse an action#

The blog example lets everyone read, lets every user create, and lets only the author change a post or a comment:

class AuthoredView(fr.AsyncRestView):
    async def authorize(self, action, obj=None, data=None):
        if action in (fr.Action.GET_MANY, fr.Action.GET_ONE):
            return
        if Current.user_id() is None:
            raise fastapi.HTTPException(401, "Log in to write")
        if action == fr.Action.CREATE:
            return
        if obj is None or obj.author_id != Current.user_id():
            raise fr.exc.Forbidden("Only the author can change this")
  • A read returns at once. The scope already decides which posts each reader sees.

  • A write needs a user. Current.user_id() is the caller, bound for each request (Request Context). Without a user, the answer is a 401, from a plain fastapi.HTTPException.

  • Every user may create.

  • Every other action changes one row, and obj is that row. Only its author may change it. This rule also covers actions that you add later, such as publish: only the author can run them, until you write another rule.

PostView and CommentView both inherit from AuthoredView, so the rule is written once. Views shows more ways to share behavior between views. The full file is blog/main.py.

authorize is an async def on AsyncRestView and a plain def on RestView. To refuse, raise fr.exc.Forbidden or any other fastapi.HTTPException. Errors lists the exceptions that Restly provides.

Details

A rule can also read the request body in data. On an update, data.model_fields_set names the fields that the client sent. This view gets the caller from a class attribute dependency, which works as well as Current:

from typing import Annotated
from fastapi import Depends


@fr.include_view(app)
class InvoiceView(fr.AsyncRestView):
    prefix = "/invoices"
    model = Invoice
    schema = InvoiceSchema
    current_user: Annotated[User, Depends(get_current_user)]

    async def authorize(self, action, obj=None, data=None):
        if action in (fr.Action.GET_MANY, fr.Action.GET_ONE):
            return
        if not self.current_user.is_staff:
            raise fr.exc.Forbidden("Only staff can change invoices")
        if action == fr.Action.UPDATE and "amount" in data.model_fields_set:
            if not self.current_user.is_manager:
                raise fr.exc.Forbidden("Only a manager can change the amount")

Declare dependencies as class attributes explains current_user. A rule that needs the database can query self.session. In the SaaS example, a member may change the labels of a task only when the task is assigned to them (app/labels/views.py).

Action names and fr.Action#

Each view has a class Action that lists all its actions. fr.Action has the five CRUD actions:

Constant

Value

fr.Action.GET_MANY

"get_many"

fr.Action.GET_ONE

"get_one"

fr.Action.CREATE

"create"

fr.Action.UPDATE

"update"

fr.Action.DELETE

"delete"

A view without actions of its own has this class. A view that adds an action declares a subclass with the same name. The blog’s PostView adds publish:

class PostView(AuthoredView):
    prefix = "/posts"
    model = Post
    schema = PostSchema

    class Action(fr.Action):
        PUBLISH = "publish"

An Action class has only actions. An action has an UPPER_CASE name, and its value is that name in lower case: PUBLISH = "publish". Any other attribute raises RestlyConfigurationError when Python creates the class.

write_action takes only an action that the view’s Action declares. Another value raises ValueError. So the class shows every action that write_action and the CRUD routes pass to authorize and the hooks. Use the constants in your code, such as self.Action.PUBLISH in a route. Your editor completes them, and a type checker reports a typo.

The transaction hooks get the same name as their action argument: before_action_commit and after_action_commit. Reads do not call the hooks, so the hooks only see write actions. See Run code before or after the commit.

Details

A mixin or a base view with actions of its own declares its Action class in the same way. A view that adds more actions inherits from the Action class of each such base:

class TrashMixin:
    class Action(fr.Action):
        RESTORE = "restore"


class PostView(TrashMixin, AuthoredView):
    class Action(TrashMixin.Action):
        PUBLISH = "publish"

Restly checks this when it creates the view. When the view’s Action does not inherit from the Action of a base, it raises RestlyConfigurationError and names the base. This also catches a view without an Action of its own and with two bases that each have one. Python would take the Action of the first base and drop the other.

An authorize on a base view, such as the blog’s AuthoredView, runs for each view that inherits it. For a type checker, self.Action there has only the actions of the base. So compare an action of a subclass with its value, such as action == "publish", or put that rule on the subclass. Keep the rule strict for an action that it does not know, as the blog does.

Authorize a custom action#

A custom route reaches authorize in one of these ways:

  • A write_action block calls it with the block’s name: write_action(self.Action.PUBLISH, obj=post) calls authorize("publish", obj=post, data=None). For a new object, such as the copy that a clone route creates, authorize gets obj=None, as on POST /; pass the request as data=, so a create rule sees what is created. If authorize raises, the block does not run.

  • A handler that the route calls authorizes its CRUD action, also under another path. handle_update(...) calls authorize("update", ...), and handle_get_one calls authorize("get_one", ...).

  • The route calls self.authorize(...) itself. A route that builds its own query does this, as the count route in Use the scope in your own query does. This is a call to your own method, so Restly does not check the name. Use a name from the view’s Action here too.

The loads and the business methods, such as get_one and update, never call authorize. A custom route that only calls them checks nothing.

Write authorize so that an action name it does not know gets the strictest rule, not no rule. The blog example does this: every action that is not a read or a create needs the author. Then a new custom action is never open to everyone. Add a custom action route shows the routes themselves.

See also#