Authorization
Introduction#
Complementing its built-in authentication services, Pyrannic offers a structured and simple way to manage authorization checks, enabling you to easily validate user actions against specific resources. For instance, an authenticated user might still lack the permissions required to modify or remove specific database records or models within your application. To handle these validation requirements smoothly, Pyrannic delivers an organized and simple approach to managing authorization checks.
Pyrannic offers two main mechanisms for authorizing actions: abilities and policies. Abilities deliver a straightforward, closure-based method for authorization. In contrast, policies organize authorization logic around a specific model or resource. This documentation will first explain abilities before moving on to examine policies.
Abilities vs. Policies
Applications can use a combination of both abilities and policies. Abilities are best suited for actions that are not related to any specific model or resource (such as viewing an admin panel), while policies should be used to authorize actions for a particular model or resource.
Abilities#
Defining Abilities#
Abilities are closures designed to verify whether a user has permission to execute a specific action. They are typically configured inside the boot method of the ServiceProvider class of your choice (e.g. your AppServiceProvider) injecting the GateInterface. Every ability receives a user instance as its primary argument and can accept further parameters, such as a corresponding ORM model.
For instance, to check if a user is allowed to update a given Post model, an ability can be defined to evaluate whether the user's id matches the user_id associated with the post's author:
from pyrannic import ServiceProvider
from pyrannic.contracts import GateInterface
from pyrannic.ioc import Resolves
class AppServiceProvider(ServiceProvider):
def boot(self, gate: Resolves[GateInterface]) -> None:
gate.define_ability("update-post", lambda user, post: user.id == post.user_id)
In the above example, we used a lambda to define the ability, but you could use any other type of Callable, like a function or a class method.
Authorizing Actions#
When authorizing actions with abilities, use the allows or denies methods on the Gate interface. You do not need to manually pass the currently authenticated user, as Pyrannic automatically supplies the user to the ability closure. Typically, these authorization methods are called within your application's routers or services prior to executing an action that requires authorization:
from typing import Annotated
from app.http.requests.post import PostRequest
from app.http.resources.post import Post as PostResource
from app.repositories.posts import PostsRepository
from fastapi import APIRouter, Body
from pyrannic import ForbiddenException, ResourceNotFoundException
from pyrannic.contracts import GateInterface
from pyrannic.ioc import Resolves
router = APIRouter(tags=["Posts"], prefix="/posts")
@router.put(
"/{post_id}",
summary="Update a Post given its ID",
description="Endpoint to update a post.",
)
async def update(
post_id: int,
request: Annotated[PostRequest, Body()],
repository: Resolves[PostsRepository],
gate: Resolves[GateInterface],
) -> PostResource:
post = await repository.find(post_id)
if post is None:
raise ResourceNotFoundException(post_id)
if not await gate.allows("update-post", post):
raise ForbiddenException("User does not have permission to update this post.")
post.title = request.title
post.content = request.content
await repository.update(post)
return PostResource.from_model(post)
To check authorization for a user other than the one currently authenticated, call the forUser method provided by the Gate interface:
if await gate.for_user(user).allows("update-post", post):
# The user can update the post
if await gate.for_user(user).denies("update-post", post):
# The user can't update the post
To authorize multiple actions at a time, you can use the any or none methods:
if await gate.any(["update-post", "delete-post"], post):
# The user can update or delete the post
if await gate.none(["update-post", "delete-post"], post):
# The user can't update or delete the post
Authorizing or Throwing Exceptions#
To authorize an action and automatically raise a ForbiddenException when a user lacks permission, use the authorize method provided by the Gate interface. Pyrannic automatically converts ForbiddenException instances into a 403 HTTP response.
Provoding Additional Context#
The Gate methods for authorizing abilities (allows, denies, check, any, none, authorize, can, cannot) can receive extra arguments. These extra elements are passed as positional parameters or named parameters to the ability closure, and can be used for additional context when making authorization decisions:
def can_create_post(user, category, pinned):
return user.can_publish_to_group(category.group) and (not pinned or user.can_pin_posts())
gate.define_ability("create-post", can_create_post)
if await gate.check("create-post", category, pinned):
# The user can create the post
Gate Responses#
Work in Progress
This section is currently under development.
before and after callbacks#
Work in Progress
This feature is currently under development.
Inline Authorization#
Work in Progress
This feature is currently under development.
Policies#
Creating Policies#
Policies organize authorization logic around specific models or resources. In a blog application, for instance, a Post model would pair with a PostPolicy to govern user permissions, such as creating or editing posts.
To create a policy, just put an empty class in app/policies or app/models/policies:
And that is all! Now we will see how to write a full policy.
Registering Policies#
Auto-discover#
Pyrannic automatically discovers policies by default, provided that standard naming conventions are followed for both models and policies.
The models are placed in the app/models directory while the policies may be placed in the app/policies directory. In this situation, Pyrannic will check for policies in app/models/policies then app/policies. In addition, the policy name must match the model name and have a Policy suffix. So, a User model would correspond to a UserPolicy policy class.
In addition to this default behavior, you can define a custom logic for the policy discovery registering a callback via the guess_policy_names_using method from the Gate interface. You typically call this method within the boot method of your application's AppServiceProvider or other service provider of your choice:
from pyrannic import ServiceProvider
from pyrannic.contracts import GateInterface
from pyrannic.ioc import Resolves
from pyrannic.support import string
class AppServiceProvider(ServiceProvider):
def boot(self, gate: Resolves[GateInterface]) -> None:
gate.guess_policy_names_using(self.guess_policy_name)
def guess_policy_name(self, model_name: str) -> str | list[str]:
"""Return the full module path and class name of the policy for the given model."""
if model_name == "User":
return "app.my_awesome_policies.user.UserPolicy"
elif model_name == "Post":
return [
"app.my_awesome_policies.post.PostPolicy",
"app.my_awesome_policies.blog_post.BlogPostPolicy",
]
else:
return f"app.my_awesome_policies.{string.to_snake_case(model_name)}.{model_name}Policy"
Manually Registering#
You can use the define_policy method from the Gate interface to manually register policies along with their corresponding models within the boot method of your application's AppServiceProvider (or other service provider):
from app.models.post import Post
from app.policies.post import PostPolicy
from pyrannic import ServiceProvider
from pyrannic.contracts import GateInterface
from pyrannic.ioc import Resolves
class AppServiceProvider(ServiceProvider):
def boot(self, gate: Resolves[GateInterface]) -> None:
gate.define_policy(Post, PostPolicy)
Defining Policies#
Policy Methods#
After registering the policy class, you can define methods for each authorized action. For instance, an update method on the PostPolicy class can check whether a specific User is allowed to modify a given Post instance.
This update method will receive a User and a Post instance as parameters and returns a boolean value indicating authorization. In the following example, authorization is confirmed by verifying that the user's id matches the post's user_id:
from app.models import Post, User
class PostPolicy:
def update(self, user: User, post: Post) -> bool:
"""Determine if the given post can be updated by the user."""
return user.id == post.user_id
You can define as many additional methods on the policy as needed to handle different authorized actions. While you might add standard methods like view or delete for managing Post actions, feel free to name your policy methods whatever works best for you.
Dependencies in Policies
Because all policies are resolved through the Pyrannic service container, required dependencies can be type-hinted in the policy constructor for automatic injection.
Policy Responses#
Work in Progress
This section is currently under development.
Methods Without Models#
Certain policy methods require only an instance of the currently authenticated user. This scenario occurs most frequently when authorizing create actions. For instance, when building a blog, you might need to verify whether a user is permitted to create new posts. In such cases, your policy method should expect solely a user instance as its parameter:
def create(self, user: User) -> bool:
"""Determine if the given user can create posts."""
return user.role == "writer"
Guest Users#
Authorization checks within gates and policies return false by default whenever an incoming HTTP request originates from an unauthenticated user. To enable these checks to process unauthenticated requests instead, you can define the user argument with a None default value or specify a None or Optional type-hint:
from app.models import Post, User
class PostPolicy:
def update(self, user: User | None, post: Post) -> bool:
"""Determine if the given post can be updated by the user."""
return user is not None and user.id == post.user_id
Policy Filters#
If you need to grant a user permission for every action controlled by a policy, you can implement a before method. Because this method runs prior to any other policy checks, it allows you to approve access in advance. This approach is typically used to give application administrators unrestricted access:
from app.models import User
class PostPolicy:
def before(self, ability: str, user: User) -> bool | None:
if user.is_administrator:
return True
return None
Returning false from the before method will deny all authorization checks for a specific type of user.
Alternatively, returning None allows the authorization evaluation to proceed to the corresponding policy method.
before Method Logic
The before method of a policy class is executed only if a method matching the name of the ability being checked is defined within that class.
Authorizing Actions#
Using the User Model#
The User model provided with your Pyrannic application features two useful authorization methods: can and cannot. Both methods accept two arguments: the action you want to authorize and the associated model.
For instance, let's check if a user has permission to update a specific Post model using these methods. This could be implemented inside a router function:
from typing import Annotated
from app.http.requests.post import PostRequest
from app.http.resources.post import Post as PostResource
from app.models.user import User
from app.repositories.posts import PostsRepository
from fastapi import APIRouter, Body
from pyrannic import ForbiddenException, ResourceNotFoundException
from pyrannic.contracts import GuardInterface
from pyrannic.ioc import Resolves
router = APIRouter(tags=["Posts"], prefix="/posts")
@router.put(
"/{post_id}",
summary="Update a Post given its ID",
description="Endpoint to update a post.",
)
async def update(
post_id: int,
request: Annotated[PostRequest, Body()],
repository: Resolves[PostsRepository],
guard: Resolves[GuardInterface[User]],
) -> PostResource:
post = await repository.find(post_id)
if post is None:
raise ResourceNotFoundException(post_id)
if not await guard.user.can("update", post):
raise ForbiddenException("User does not have permission to update this post.")
post.title = request.title
post.content = request.content
await repository.update(post)
return PostResource.from_model(post)
When a policy is registered for the specified model, the can method automatically executes it and returns a boolean result. Otherwise, if no policy exists for that model, the method attempts to invoke a closure-based Gate corresponding to the action name.
Using the Gate Interface#
The authorize Method#
Beyond the methods available on the User model, actions can also be authorized using the authorize method on the Gate interface.
Similar to the can method, it requires the action name and the target model. Should authorization fail, the authorize method raises a ForbiddenException, which the Pyrannic exception handler automatically translates into an HTTP 403 response:
from typing import Annotated
from app.http.requests.post import PostRequest
from app.http.resources.post import Post as PostResource
from app.repositories.posts import PostsRepository
from fastapi import APIRouter, Body
from pyrannic import ResourceNotFoundException
from pyrannic.contracts import GateInterface
from pyrannic.ioc import Resolves
router = APIRouter(tags=["Posts"], prefix="/posts")
@router.put(
"/{post_id}",
summary="Update a Post given its ID",
description="Endpoint to update a post.",
)
async def update(
post_id: int,
request: Annotated[PostRequest, Body()],
repository: Resolves[PostsRepository],
gate: Resolves[GateInterface],
) -> PostResource:
post = await repository.find(post_id)
if post is None:
raise ResourceNotFoundException(post_id)
await gate.authorize("update", post)
post.title = request.title
post.content = request.content
await repository.update(post)
return PostResource.from_model(post)
The rest of Methods#
The remaining methods of the Gate interface that we alredy saw when using abilities (allows, denies, check, any, none, can, cannot) can be also used to authorize actions with policies:
if await gate.allows("update", post):
# The user can update the post
if await gate.denies("update", post):
# The user can't update the post
Using the Gate Facade#
Work in Progress
This section is currently under development and will be accessible very soon.
Actions without a Required Model#
Certain policy methods, such as create, do not require a specific model instance. When authorizing these actions, you must supply a model class type instead. This model class type is then evaluated to instantiate the appropriate policy to execute when authorizing the action:
from app.models.post import Post
if not await guard.user.can("create", Post):
raise ForbiddenException("User does not have permission to create a post.")
Provoding Additional Context#
When evaluating authorization with policies, you can provide extra arguments to the several authorization methods. These extra elements are passed as positional parameters or named parameters to the policy method to offer extra context for decision-making.
For instance, review this PostPolicy method signature featuring a supplementary category parameter:
from app.models import Post, User
class PostPolicy:
def update(self, user: User, post: Post, category: int) -> bool:
return user.id == post.user_id and user.can_update_category(category)
To check whether the authenticated user has permission to update a specific post, this policy method can be called as follows:
async def update(
post_id: int,
request: Annotated[PostRequest, Body()],
repository: Resolves[PostsRepository],
gate: Resolves[GateInterface],
) -> PostResource:
post = await repository.find(post_id)
if post is None:
raise ResourceNotFoundException(post_id)
await gate.authorize("update", post, request.category)
# The authenticated user can update the post
return PostResource.from_model(post)