arcly-http 0.1.2

Enterprise-grade NestJS-inspired web framework on axum: zero-lock DI, declarative controllers, multi-tenant data routing, transactional outbox, ABAC, and a self-documenting OpenAPI surface
Documentation
//! Request guards — composable preconditions evaluated before a handler runs.
//!
//! Guards operate on [`RequestContext`] and return [`Error`] on rejection.
//! Apply manually at the top of a handler:
//!
//! ```ignore
//! BearerAuth::new("secret").check(&ctx)?;
//! ```

use crate::web::{Error, RequestContext};

// RateLimit moved to `resilience::rate_limit` — re-exported here so the
// long-standing `arcly_http::guards::RateLimit` import path keeps working.
pub use crate::resilience::rate_limit::RateLimit;

pub trait Guard: Send + Sync + 'static {
    fn check(&self, ctx: &RequestContext) -> Result<(), Error>;
}

// ─── Bearer-token auth ───────────────────────────────────────────────────
/// Minimal `Authorization: Bearer <token>` guard. Comparison is constant-time.
pub struct BearerAuth {
    expected: &'static str,
}

impl BearerAuth {
    pub const fn new(token: &'static str) -> Self {
        Self { expected: token }
    }
}

impl Guard for BearerAuth {
    fn check(&self, ctx: &RequestContext) -> Result<(), Error> {
        let h = ctx.header("authorization").ok_or(Error::Unauthorized)?;
        let token = h.strip_prefix("Bearer ").ok_or(Error::Unauthorized)?;
        if ct_eq(token.as_bytes(), self.expected.as_bytes()) {
            Ok(())
        } else {
            Err(Error::Unauthorized)
        }
    }
}

#[inline]
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut diff = 0u8;
    for i in 0..a.len() {
        diff |= a[i] ^ b[i];
    }
    diff == 0
}

// ─── JWT auth guard ───────────────────────────────────────────────────────────

/// Checks that the incoming request carried a valid JWT.
///
/// This guard is **zero-overhead**: it inspects `ctx.claims()` which was already
/// decoded by the HTTP boundary when the request arrived. No re-decoding is done.
///
/// Use the singleton `JWT_AUTH` so the guard needs no heap allocation:
/// ```ignore
/// JWT_AUTH.check(&ctx)?;
/// ```
pub struct JwtAuthGuard;

/// Ready-to-use singleton for `JwtAuthGuard`. Import and call:
/// ```ignore
/// use arcly_http::guards::{Guard, JWT_AUTH};
/// JWT_AUTH.check(&ctx)?;
/// ```
pub static JWT_AUTH: JwtAuthGuard = JwtAuthGuard;

impl Guard for JwtAuthGuard {
    fn check(&self, ctx: &RequestContext) -> Result<(), Error> {
        ctx.claims().map(|_| ()).ok_or(Error::Unauthorized)
    }
}

// ─── Role guard ───────────────────────────────────────────────────────────────

/// Checks that the authenticated principal's `"role"` claim matches a required value.
///
/// Build a `const`/`static` instance with `RoleGuard::require`:
/// ```ignore
/// static ADMIN: RoleGuard = RoleGuard::require("admin");
/// ADMIN.check(&ctx)?;  // 403 if role != "admin", 401 if no claims at all
/// ```
pub struct RoleGuard {
    pub role: &'static str,
}

impl RoleGuard {
    pub const fn require(role: &'static str) -> Self {
        Self { role }
    }
}

impl Guard for RoleGuard {
    fn check(&self, ctx: &RequestContext) -> Result<(), Error> {
        let claims = ctx.claims().ok_or(Error::Unauthorized)?;
        match claims.get("role").and_then(|v| v.as_str()) {
            Some(r) if r == self.role => Ok(()),
            Some(_) => Err(Error::Forbidden),
            None => Err(Error::Forbidden),
        }
    }
}

// ─── Session auth guard ───────────────────────────────────────────────────────

/// Passes if the request has a loaded server-side session.
///
/// Requires `SessionManager` to be provided in the DI container so the HTTP
/// boundary loads the session before the guard runs. Returns `Unauthorized`
/// if no session was loaded (cookie absent, tampered, or expired in the store).
pub struct SessionAuthGuard;

/// Ready-to-use singleton for `SessionAuthGuard`.
pub static SESSION_AUTH: SessionAuthGuard = SessionAuthGuard;

impl Guard for SessionAuthGuard {
    fn check(&self, ctx: &RequestContext) -> Result<(), Error> {
        ctx.session().map(|_| ()).ok_or(Error::Unauthorized)
    }
}