Guards and authorization
Allow or reject processing before the handler by using services from the delivery scope and an explicit failure class.
A guard decides whether an admitted message may reach its handler. It runs after all middleware entries and before resolving the handler owner and typed extractors. Its result is Result<(), QueueHandlerError>, rather than a Boolean.
A guard with a scoped policy#
This complete application prepares audit data in middleware, resolves OrderPolicy in a guard, and passes the same Scoped instance to the handler. The version check is a small application policy, not credential verification. Your own service evaluates verified identities and domain permissions for real authorization.
use std::sync::Arc;
use lilyrs::consumer::{Consumer, ConsumerError, Extensions, Injectable, ServiceTrait};
use lilyrs::queue::{
DeliveryContext, Json, Local, QueueDeliveryExchange, QueueGuard, QueueHandlerError,
QueueMiddleware, QueuePipelineComponentInitError, Service, async_trait, guard,
queue, queue_service,
};
use serde::Deserialize;
#[derive(Clone)]
struct DeliveryAudit { event_id: String }
struct Audit;
#[async_trait]
impl QueueMiddleware for Audit {
async fn new(_: Arc<Extensions>) -> Result<Self, QueuePipelineComponentInitError> {
Ok(Self)
}
async fn before_delivery(&self, exchange: &mut QueueDeliveryExchange<'_>)
-> Result<(), QueueHandlerError>
{
let event_id = exchange.context().event_id().into_inner().to_string();
exchange.insert_local(DeliveryAudit { event_id })?;
Ok(())
}
}
#[derive(Injectable)]
#[service(lifetime = "Scoped")]
struct OrderPolicy;
impl ServiceTrait for OrderPolicy {}
impl OrderPolicy {
fn accepts(&self, context: &DeliveryContext) -> bool {
context.schema_version().into_inner() == 1
}
}
struct OrderGuard;
#[async_trait]
impl QueueGuard for OrderGuard {
async fn new(_: Arc<Extensions>) -> Result<Self, QueuePipelineComponentInitError> {
Ok(Self)
}
async fn can_activate(&self, exchange: &mut QueueDeliveryExchange<'_>)
-> Result<(), QueueHandlerError>
{
let policy = exchange.service::<OrderPolicy>().await
.map_err(|error| QueueHandlerError::retryable_with_source("POLICY_UNAVAILABLE", error))?;
if policy.accepts(exchange.context()) { Ok(()) }
else { Err(QueueHandlerError::permanent("ORDER_NOT_ADMITTED")) }
}
}
#[derive(Deserialize)]
struct OrderCreated { order_id: String }
#[derive(Injectable)]
#[service(lifetime = "Singleton")]
struct OrderWorker;
impl ServiceTrait for OrderWorker {}
#[queue_service]
#[guard(OrderGuard)]
impl OrderWorker {
#[queue("orders.created", version = 1, content = "json")]
async fn created(&self, Service(_policy): Service<OrderPolicy>,
Local(audit): Local<DeliveryAudit>, Json(event): Json<OrderCreated>)
-> Result<(), QueueHandlerError>
{
if event.order_id.is_empty() {
return Err(QueueHandlerError::permanent("ORDER_ID_REQUIRED"));
}
println!("event={}, order={}", audit.event_id, event.order_id);
Ok(())
}
}
#[tokio::main]
async fn main() -> Result<(), ConsumerError> {
Consumer::builder().middleware::<Audit>().run().await
}Download the example. Use the orders.created configuration from your first application. The manifest needs lilyrs = { version = "0.1.0", features = ["consumer"] }, serde with derive, and tokio with macros and rt-multi-thread.
Choose one registration level#
| Registration | Scope |
|---|---|
.guard::<OrderGuard>() |
All selected handler plans |
#[guard(OrderGuard)] on the impl |
Handlers of that queue service |
#[guard(OrderGuard)] on a handler |
Only that handler |
Order is global → service → handler. The example registers its guard on the service; do not also add it to the builder. Repeating a concrete type in one effective plan, using one type as both guard and middleware, or exceeding 64 guards causes a startup error. Each marker takes one type; repeat the marker for multiple guards.
Identity, errors and rate limiting#
RabbitMQ credentials or an mTLS connection do not make a message’s user_id a verified identity. Application middleware can verify credentials or signatures and produce Local<VerifiedActor> for the guard. There is no built-in Consumer JWT/OIDC verifier.
Choose permanent for a lasting rejection and a justified retryable for temporary dependency problems. A Redis-backed limiter can also be an application guard; retryable rate-limit rejection consumes normal retry attempts and can eventually reach the DLQ. It does not create an HTTP 403/429 response or an automatic waiting queue. Error handling explains what follows.
Lifetimes and cancellation#
The constructor runs once per concrete type during Consumer startup. Do not retain Scoped or Transient services there; resolve them through exchange.service::<T>().await during the active delivery. Cancellation and deadline are available on the exchange; do not add a separate WebSocket cancellation parameter to the callback. Bind external calls to the shared delivery budget.
Sources for this page v0.1.0
This guide is based on the reviewed framework commit.