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"
}