Authorization
Authentication answers who is making the request; authorization decides what they can do. In Corvis the tool for the second is the same as for the first: a guard that reads route metadata with Reflector and compares it with the user data left by the authentication guard.
Role-based access control
Define the roles and a metadata key. Since metadata is stored as JSON, any Serialize + Deserialize type works as a value:
use serde::{Deserialize, Serialize};
pub const ROLES_KEY: &str = "roles";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
User,
Admin,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JwtPayload {
pub sub: u32,
pub username: String,
pub roles: Vec<Role>,
}Mark routes with #[set_metadata]:
#[delete(":id")]
#[http_code(HttpStatus::NO_CONTENT)]
#[set_metadata(ROLES_KEY, [Role::Admin])]
fn remove(&self, #[param("id", ParseIntPipe)] id: u32) -> HttpResult<()> {
self.cats_service.remove(id)
}And write the guard:
#[injectable]
pub struct RolesGuard {
reflector: Arc<Reflector>,
}
#[async_trait]
impl CanActivate for RolesGuard {
async fn can_activate(&self, ctx: &ExecutionContext) -> Result<bool, HttpException> {
let required: Option<Vec<Role>> =
self.reflector.get_all_and_override(ROLES_KEY, &[ctx.get_handler(), ctx.get_class()]);
let Some(required) = required else { return Ok(true) };
let user = ctx.switch_to_http().get_request().user_as::<JwtPayload>();
Ok(user.map(|u| required.iter().any(|r| u.roles.contains(r))).unwrap_or(false))
}
}get_all_and_override looks for the key first on the handler and then on the class, and returns the first value it finds; this way a method can override what its controller declares. If the route declares no roles, the guard lets the request through.
When a guard returns Ok(false), the client receives:
{ "message": "Forbidden resource", "error": "Forbidden", "statusCode": 403 }If you want another status or message, return an error instead of false, for example Err(ForbiddenException::new("Admins only")).
Registration and order
The roles guard needs the user that the authentication guard attaches, so it must run after it. Guards registered with Provider::app_guard run in the order in which they appear in the modules:
#[module(
imports = [AuthModule, UsersModule, CatsModule],
providers = [
Provider::app_guard::<ThrottlerGuard>(),
Provider::app_guard::<AuthGuard>(),
Provider::app_guard::<RolesGuard>(),
],
)]
pub struct AppModule;You can also apply them only where needed, on the controller or on the method. In a list, they run from left to right:
#[post(":room/announcements")]
#[set_metadata(ROLES_KEY, [MODERATOR])]
#[use_guards(AuthGuard, RolesGuard)]
async fn announce(&self, #[param("room")] room: String, #[body] dto: AnnouncementDto, user: ChatUser) -> HttpResult<Value> {
// ...
}The full order among global, controller and method guards is described in Request lifecycle.
Grouping metadata in a decorator
To avoid repeating #[set_metadata(ROLES_KEY, ...)], group the metadata in a CustomDecorator and apply it with #[apply_decorators]:
use corvis::CustomDecorator;
fn admin_only() -> CustomDecorator {
CustomDecorator::new().set_metadata(ROLES_KEY, [Role::Admin])
}
#[delete(":id")]
#[apply_decorators(admin_only())]
fn remove(&self, #[param("id", ParseIntPipe)] id: u32) -> HttpResult<()> {
self.cats_service.remove(id)
}A CustomDecorator only carries metadata: it cannot register guards or interceptors. The guard is still applied with #[use_guards] or globally.
Per-resource rules
When the decision depends on the resource (for example, "only the author can edit their post"), the guard does not always have the necessary information. There are two approaches:
- Read the route parameter in the guard (
ctx.request().param("id")) and query a service injected into the guard itself. - Do the check in the service and return
ForbiddenExceptionfrom there.
pub async fn update(&self, id: u32, user: &JwtPayload, dto: UpdatePostDto) -> HttpResult<Post> {
let post = self.find_one(id).await?;
if post.author_id != user.sub && !user.roles.contains(&Role::Admin) {
return Err(ForbiddenException::new("You can only edit your own posts"));
}
// ...
}Authorization in WebSockets
Guards also work in a gateway's handlers. RolesGuard in examples/tertulia gets the user from the HTTP request or from the WebSocket client depending on ctx.get_type(), so the same rule protects a REST endpoint and a message:
#[subscribe_message("clear")]
#[set_metadata(ROLES_KEY, [MODERATOR])]
#[use_guards(RolesGuard, RoomMemberGuard)]
async fn clear(&self, #[message_body] dto: RoomDto, #[connected_socket] client: WsClient) -> HttpResult<()> {
// ...
}Global guards do not apply to gateways: declare them on the gateway or on each handler. See WebSocket guards.
What Corvis does not include
There is no policies or permissions module (CASL-style) and no rules language. Guards with Reflector cover the cases of roles and claims; for more complex rules, encapsulate them in an injectable service and call it from the guard.