Guards¶
A Guard in Ellar is a way to add authentication and authorization checks to your application. It acts as middleware and runs before executing the route handler. If the guard returns false, the request is rejected and the execution is stopped.
Guards can be used to check for specific roles, permissions, or any other arbitrary condition. They can be easily applied to individual routes or groups of routes using @guard
decorator.
Unlike middleware, a guard have access to the ExecutionContext
which provides information for the route function to be executed and its controller.
Note
Guards are executed after all middleware
Authorization guard¶
Authorization is a great example of a guard because some routes should be available only to specific authenticated user and or users that sufficient permissions. Let's assume we have a AuthGard
which checks if a making a request is authenticated.
from ellar.common import GuardCanActivate, IExecutionContext
from ellar.di import injectable
@injectable()
class AuthGuard(GuardCanActivate):
async def can_activate(self,context: IExecutionContext) -> bool:
request = context.switch_to_http_connection().get_request()
return self.validate_request(request)
def validate_request(self, request) -> bool:
...
validate_request()
function, in the example above, can be simple or complex depending on the use case. The primary objective is to demonstrate the integration of guards into the request/response cycle. Every guard must inherit from GuardCanActivate
and override can_activate()
function. The can_activate()
function is required to return a boolean
value. The return value determines the next action:
- If the function returns
true
, the request will be processed. - If the function returns
false
, Ellar will reject the request.
Role-based authentication¶
Let's build a more functional guard that permits access only to users with a specific role. We'll start with a basic guard template, and build on it in the coming sections. For now, it allows all requests to proceed:
# project_name/cars/guards.py
from ellar.common import GuardCanActivate, IExecutionContext
from ellar.di import injectable
@injectable()
class RoleGuard(GuardCanActivate):
async def can_activate(self,context: IExecutionContext) -> bool:
return True
Applying guards¶
Guards can be controller-scoped
, method-scoped
, or global-scoped
. We apply guards to controllers or route function by using @Guards
. The @UseGuards
takes a single argument, or a comma-separated list of arguments of GuardCanActivate
types or instances.
import typing as t
def UseGuards(
*_guards: t.Type["GuardCanActivate"] | "GuardCanActivate"
) -> t.Callable:
...
Controller-scoped¶
We set up controller scoped guards on controller by using @UseGuards
decorator. For example:
# project_name/cars/controllers.py
from ellar.common import Controller, UseGuards
from .guards import RoleGuard
@Controller()
@UseGuards(RoleGuard)
class CarsController:
...
@UseGuards()
decorator at the method level. Method-scoped¶
We can also use @UseGuards()
on route-function when necessary.
# project_name/cars/controllers.py
from ellar.common import Controller, UseGuards, get
from .guards import RoleGuard
@Controller()
@UseGuards(RoleGuard)
class CarsController:
@UseGuards(RoleGuard())
@get('/guarded-route')
def guarded_route(self):
return "Passed Guard"
guarded_route
with @UseGuards(RoleGuard())
with an instance of RoleGuard
. When request execution for /guarded-route
, guarded_route
guard definition will be precedence over CarsController
guard definitions. Global-scope¶
Global guards are used across the whole application, for every controller and every route function but individual controller or route function @UseGuards
definition can override global
scoped guards.
Global guards can be applied at application level using use_global_guards
as shown below:
project_name/server.py | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
project_name/car/module.py
, we can register RoleGuard
in the module providers
parameter as a Global guards. See illustration below: project_name/car/module.py | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
Rounding up RoleGuard¶
Our RolesGuard
is working, but it's not very smart yet. Let's assume we want our RoleGuard
to manage user role permissions in a more general context employing the power of ExecutionContext
and custom metadata
. In CarController
, for example, could have different permission schemes for different routes. Some might be available only for an admin user, and others could be open for everyone.
from ellar.common import post, set_metadata
...
@post()
@set_metadata('roles', ['admin'])
async def create(self, payload:Body[CreateCarSerializer]):
self.repo.create_car(payload)
return 'This action adds a new car'
...
roles
metadata (roles is a key, while ['admin'] is a particular value) to the create()
method. While this works, it's not good practice to use @set_metadata()
directly in your routes. So we can refactor that code as shown below: # project_name/role_decorator.py
import typing
from ellar.common import set_metadata
def roles(*_roles: str) -> typing.Callable:
return set_metadata('roles', list(_roles))
This approach is much cleaner and more readable, and is strongly typed. Now that we have a custom @roles()
decorator, we can use it to decorate the create()
method.
...
@post()
@role('admin', 'staff')
async def create(self, payload:Body[CreateCarSerializer]):
self.repo.create_car(payload)
return 'This action adds a new car'
...
Let's now go back and tie this together with our RolesGuard
. Currently, it simply returns true
in all cases, allowing every request to proceed. We want to make the return value conditional based on the comparing the roles assigned to the current user
to the actual roles required by the current route being processed.
In order to access the route's function role(s)
(custom metadata), we'll use the Reflector
helper class, which is provided out of the box by the framework.
# project_name/apps/cars/guards.py
import typing as t
from ellar.di import injectable
from ellar.common import GuardCanActivate, IExecutionContext
from ellar.core.services import Reflector
@injectable()
class RoleGuard(GuardCanActivate):
def __init__(self, reflector: Reflector):
self.reflector = reflector
def match_roles(self, roles: t.List[str], user_roles: t.List[str]) -> bool:
for user_role in user_roles:
if user_role in roles:
return True
return False
async def can_activate(self, context: IExecutionContext) -> bool:
roles = self.reflector.get('roles', context.get_handler())
# request = context.switch_to_http_connection().get_request()
# check if user in request object has role
if not roles:
return True
request = context.switch_to_http_connection().get_request()
user = request.user
return self.match_roles(roles, user_roles=user.roles)
user
object exist in request object. When a user with insufficient privileges requests an endpoint, Ellar automatically returns the following response:
{
"detail": "Forbidden",
"status_code": 403
}
Note that behind the scenes, when a guard returns false
, the framework throws a HTTPException
with status code 403 . If you want to return a different error response, you should throw your own specific exception by override raise_exception
function as shown below:
from ellar.common import APIException, GuardCanActivate
from ellar.di import injectable
from ellar.core import Reflector
@injectable()
class RoleGuard(GuardCanActivate):
def __init__(self, reflector: Reflector):
self.reflector = reflector
def raise_exception(self) -> None:
raise APIException(detail="You don't have the permission to perform this action", status_code=403)
...
false
{
"detail": "You don't have the permission to perform this action"
}