laye 0.1.0

A framework-agnostic role and permission based access control library
Documentation

Crates.io docs.rs License: MIT

laye

A framework-agnostic role- and permission-based access control (RBAC) library for Rust.

Build composable AccessPolicy rules, implement Principal on your auth type, and wire the provided middleware into actix-web or axum (tower). Authentication — decoding tokens, querying the database — is entirely outside laye's scope.

Feature flags

Feature What it adds
actix-web laye::actix module: PolicyMiddlewareFactory, AuthPrincipal, MaybeAuthPrincipal
tower laye::tower_middleware module: AccessControlLayer
# Core only
laye = "0.1"

# With actix-web support
laye = { version = "0.1", features = ["actix-web"] }

# With axum / tower support
laye = { version = "0.1", features = ["tower"] }

How it works

  1. Implement Principal on your auth info struct (decoded JWT payload, session data, etc.).
  2. Build an AccessPolicy with require_all (AND) or require_any (OR), chaining AccessRules and nested sub-policies.
  3. Insert your principal into request extensions from your own upstream auth middleware — before laye runs.
  4. Apply a laye middleware / layer. It reads P from extensions and evaluates the policy, returning 401 Unauthorized when no principal is found or 403 Forbidden when the principal fails the policy.

Quick start

Implement Principal

use laye::Principal;

#[derive(Clone)]
struct MyUser {
    roles: Vec<String>,
    permissions: Vec<String>,
    authenticated: bool,
}

impl Principal for MyUser {
    fn roles(&self) -> &[String] { &self.roles }
    fn permissions(&self) -> &[String] { &self.permissions }
    fn is_authenticated(&self) -> bool { self.authenticated }
}

Build and evaluate a policy

use laye::{AccessPolicy, AccessRule, LayeCheckResult};

// Require authenticated AND (role "admin" OR role "editor")
let policy = AccessPolicy::require_all()
    .add_rule(AccessRule::Authenticated)
    .add_policy(
        AccessPolicy::require_any()
            .add_rule(AccessRule::Role("admin".into()))
            .add_rule(AccessRule::Role("editor".into())),
    );

let editor = MyUser { roles: vec!["editor".into()], permissions: vec![], authenticated: true };
let viewer = MyUser { roles: vec!["viewer".into()], permissions: vec![], authenticated: true };

assert_eq!(policy.check(Some(&editor)), LayeCheckResult::Authorized);
assert_eq!(policy.check(Some(&viewer)), LayeCheckResult::Forbidden);
assert_eq!(policy.check(None),          LayeCheckResult::Unauthorized);

actix-web integration

laye = { version = "0.1", features = ["actix-web"] }
use actix_web::{web, App, HttpServer, HttpMessage, HttpResponse};
use laye::{AccessPolicy, AccessRule, Principal};
use laye::actix::AuthPrincipal;

#[derive(Clone)]
struct MyUser {
    id: u64,
    roles: Vec<String>,
    permissions: Vec<String>,
}

impl Principal for MyUser {
    fn roles(&self) -> &[String] { &self.roles }
    fn permissions(&self) -> &[String] { &self.permissions }
    fn is_authenticated(&self) -> bool { true }
}

async fn admin_handler(user: AuthPrincipal<MyUser>) -> HttpResponse {
    HttpResponse::Ok().body(format!("id={}", user.0.id))
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let admin_policy = AccessPolicy::require_all()
        .add_rule(AccessRule::Authenticated)
        .add_rule(AccessRule::Role("admin".into()));

    HttpServer::new(move || {
        App::new()
            // Step 1: Your auth middleware — decode the token and insert the principal.
            // Without this step every request returns 401, regardless of headers.
            .wrap_fn(|req, srv| {
                req.extensions_mut().insert(MyUser {
                    id: 1,
                    roles: vec!["admin".to_string()],
                    permissions: vec![],
                });
                srv.call(req)
            })
            // Step 2: laye checks the policy against the inserted principal.
            .service(
                web::scope("/admin")
                    .wrap(admin_policy.clone().into_actix_middleware::<MyUser>())
                    .route("", web::get().to(admin_handler)),
            )
            .route("/public", web::get().to(|| async { HttpResponse::Ok().body("public") }))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

axum / tower integration

laye = { version = "0.1", features = ["tower"] }
use axum::{Router, routing::get, middleware, response::IntoResponse};
use laye::{AccessPolicy, AccessRule, Principal};

#[derive(Clone)]
struct MyUser {
    id: u64,
    roles: Vec<String>,
    permissions: Vec<String>,
}

impl Principal for MyUser {
    fn roles(&self) -> &[String] { &self.roles }
    fn permissions(&self) -> &[String] { &self.permissions }
    fn is_authenticated(&self) -> bool { true }
}

// Step 1: Your auth middleware — decode the token and insert the principal.
// Without this step every request to a guarded route returns 401.
async fn load_user(mut req: axum::extract::Request, next: middleware::Next) -> impl IntoResponse {
    req.extensions_mut().insert(MyUser {
        id: 1,
        roles: vec!["admin".to_string()],
        permissions: vec![],
    });
    next.run(req).await
}

async fn admin_handler() -> &'static str { "Hello, admin!" }

#[tokio::main]
async fn main() {
    let policy = AccessPolicy::require_all()
        .add_rule(AccessRule::Authenticated)
        .add_rule(AccessRule::Role("admin".into()));

    let app = Router::new()
        .route(
            "/admin",
            // Step 2: laye checks the policy against the inserted principal.
            get(admin_handler).layer(policy.into_tower_layer::<MyUser>()),
        )
        .route("/public", get(|| async { "public" }))
        .layer(middleware::from_fn(load_user));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

License

MIT — see LICENSE.