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 |
|
|
|
|---|---|---|---|
|
|
|
|
|
|
the loaded row |
|
|
|
|
the validated request body |
|
|
the loaded row |
the validated request body |
|
|
the loaded row |
|
a |
its name, such as |
the |
the |
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 plainfastapi.HTTPException.Every user may create.
Every other action changes one row, and
objis that row. Only its author may change it. This rule also covers actions that you add later, such aspublish: 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 |
|---|---|
|
|
|
|
|
|
|
|
|
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_actionblock calls it with the block’s name:write_action(self.Action.PUBLISH, obj=post)callsauthorize("publish", obj=post, data=None). For a new object, such as the copy that a clone route creates,authorizegetsobj=None, as onPOST /; pass the request asdata=, so a create rule sees what is created. Ifauthorizeraises, the block does not run.A handler that the route calls authorizes its CRUD action, also under another path.
handle_update(...)callsauthorize("update", ...), andhandle_get_onecallsauthorize("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’sActionhere 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#
Scopes: which rows a caller can see, and the 404.
Request Lifecycle & Hooks: the steps of a request, and where
authorizeruns.Request Context: who is calling.
Errors: the exceptions to raise, and how errors look.