laye 0.1.0

A framework-agnostic role and permission based access control library
Documentation
#![cfg(feature = "actix-web")]

use actix_web::dev::Service;
use actix_web::test::{call_service, init_service, TestRequest};
use actix_web::{web, App, HttpMessage, HttpResponse};

use laye::actix::{AuthPrincipal, MaybeAuthPrincipal};
use laye::principal::Principal;
use laye::{AccessPolicy, AccessRule};

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

impl TestUser {
    fn new(roles: &[&str]) -> Self {
        Self {
            roles: roles.iter().map(|s| s.to_string()).collect(),
            permissions: vec![],
            authenticated: true,
        }
    }

    fn with_permissions(roles: &[&str], permissions: &[&str]) -> Self {
        Self {
            roles: roles.iter().map(|s| s.to_string()).collect(),
            permissions: permissions.iter().map(|s| s.to_string()).collect(),
            authenticated: true,
        }
    }
}

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

#[actix_web::test]
async fn middleware_allows_request_when_policy_passes() {
    let policy = AccessPolicy::require_all().add_rule(AccessRule::Role("admin".into()));
    let user = TestUser::new(&["admin"]);
    let app = init_service(
        App::new()
            .wrap(policy.into_actix_middleware::<TestUser>())
            .wrap_fn(move |req, srv| {
                req.extensions_mut().insert(user.clone());
                srv.call(req)
            })
            .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
    )
    .await;
    let res = call_service(&app, TestRequest::get().uri("/").to_request()).await;
    assert_eq!(res.status(), 200, "admin should get 200");
}

#[actix_web::test]
async fn middleware_returns_403_when_role_missing() {
    let policy = AccessPolicy::require_all().add_rule(AccessRule::Role("admin".into()));
    let user = TestUser::new(&[]);
    let app = init_service(
        App::new()
            .wrap(policy.into_actix_middleware::<TestUser>())
            .wrap_fn(move |req, srv| {
                req.extensions_mut().insert(user.clone());
                srv.call(req)
            })
            .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
    )
    .await;
    let res = call_service(&app, TestRequest::get().uri("/").to_request()).await;
    assert_eq!(res.status(), 403, "missing role should get 403");
}

#[actix_web::test]
async fn middleware_returns_401_when_no_principal_in_extensions() {
    let policy = AccessPolicy::require_all().add_rule(AccessRule::Authenticated);
    let app = init_service(
        App::new()
            .wrap(policy.into_actix_middleware::<TestUser>())
            .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
    )
    .await;
    let res = call_service(&app, TestRequest::get().uri("/").to_request()).await;
    assert_eq!(res.status(), 401, "no principal should get 401");
}

#[actix_web::test]
async fn auth_principal_extractor_injects_principal_into_handler() {
    let policy = AccessPolicy::require_all().add_rule(AccessRule::Authenticated);
    let user = TestUser::new(&["user"]);
    let app = init_service(
        App::new()
            .wrap(policy.into_actix_middleware::<TestUser>())
            .wrap_fn(move |req, srv| {
                req.extensions_mut().insert(user.clone());
                srv.call(req)
            })
            .route(
                "/",
                web::get().to(|p: AuthPrincipal<TestUser>| async move {
                    assert!(p.0.has_role("user"), "extracted user should have 'user' role");
                    HttpResponse::Ok().finish()
                }),
            ),
    )
    .await;
    let res = call_service(&app, TestRequest::get().uri("/").to_request()).await;
    assert_eq!(res.status(), 200, "handler should receive principal and return 200");
}

#[actix_web::test]
async fn auth_principal_extractor_returns_401_when_missing() {
    let policy = AccessPolicy::require_all().add_rule(AccessRule::Guest);
    let app = init_service(
        App::new()
            .wrap(policy.into_actix_middleware::<TestUser>())
            .route(
                "/",
                web::get()
                    .to(|_: AuthPrincipal<TestUser>| async { HttpResponse::Ok().finish() }),
            ),
    )
    .await;
    let res = call_service(&app, TestRequest::get().uri("/").to_request()).await;
    assert_eq!(res.status(), 401, "missing principal in extractor should return 401");
}

#[actix_web::test]
async fn maybe_auth_principal_returns_some_when_present() {
    let policy = AccessPolicy::require_all().add_rule(AccessRule::Authenticated);
    let user = TestUser::new(&[]);
    let app = init_service(
        App::new()
            .wrap(policy.into_actix_middleware::<TestUser>())
            .wrap_fn(move |req, srv| {
                req.extensions_mut().insert(user.clone());
                srv.call(req)
            })
            .route(
                "/",
                web::get().to(|m: MaybeAuthPrincipal<TestUser>| async move {
                    assert!(m.0.is_some(), "MaybeAuthPrincipal should be Some when extension present");
                    HttpResponse::Ok().finish()
                }),
            ),
    )
    .await;
    let res = call_service(&app, TestRequest::get().uri("/").to_request()).await;
    assert_eq!(res.status(), 200, "should succeed");
}

#[actix_web::test]
async fn maybe_auth_principal_returns_none_when_absent() {
    let policy = AccessPolicy::require_all().add_rule(AccessRule::Guest);
    let app = init_service(
        App::new()
            .wrap(policy.into_actix_middleware::<TestUser>())
            .route(
                "/",
                web::get().to(|m: MaybeAuthPrincipal<TestUser>| async move {
                    assert!(m.0.is_none(), "MaybeAuthPrincipal should be None when no extension");
                    HttpResponse::Ok().finish()
                }),
            ),
    )
    .await;
    let res = call_service(&app, TestRequest::get().uri("/").to_request()).await;
    assert_eq!(res.status(), 200, "guest route with no principal should succeed");
}

#[actix_web::test]
async fn and_policy_blocks_when_one_condition_fails() {
    let policy = AccessPolicy::require_all()
        .add_rule(AccessRule::Authenticated)
        .add_rule(AccessRule::Role("admin".into()));
    let user = TestUser::new(&["user"]);
    let app = init_service(
        App::new()
            .wrap(policy.into_actix_middleware::<TestUser>())
            .wrap_fn(move |req, srv| {
                req.extensions_mut().insert(user.clone());
                srv.call(req)
            })
            .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
    )
    .await;
    let res = call_service(&app, TestRequest::get().uri("/").to_request()).await;
    assert_eq!(res.status(), 403, "AND policy should block when role condition fails");
}

#[actix_web::test]
async fn not_role_allows_user_without_banned_role() {
    let policy = AccessPolicy::require_all()
        .add_rule(AccessRule::Authenticated)
        .add_rule(AccessRule::NotRole("banned".into()));
    let user = TestUser::new(&["editor"]);
    let app = init_service(
        App::new()
            .wrap(policy.into_actix_middleware::<TestUser>())
            .wrap_fn(move |req, srv| {
                req.extensions_mut().insert(user.clone());
                srv.call(req)
            })
            .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
    )
    .await;
    let res = call_service(&app, TestRequest::get().uri("/").to_request()).await;
    assert_eq!(res.status(), 200, "user without banned role should get 200");
}

#[actix_web::test]
async fn not_role_blocks_user_with_banned_role() {
    let policy = AccessPolicy::require_all()
        .add_rule(AccessRule::Authenticated)
        .add_rule(AccessRule::NotRole("banned".into()));
    let user = TestUser::new(&["editor", "banned"]);
    let app = init_service(
        App::new()
            .wrap(policy.into_actix_middleware::<TestUser>())
            .wrap_fn(move |req, srv| {
                req.extensions_mut().insert(user.clone());
                srv.call(req)
            })
            .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
    )
    .await;
    let res = call_service(&app, TestRequest::get().uri("/").to_request()).await;
    assert_eq!(res.status(), 403, "user with banned role should get 403");
}

#[actix_web::test]
async fn not_permission_allows_user_without_restricted_permission() {
    let policy = AccessPolicy::require_all()
        .add_rule(AccessRule::Authenticated)
        .add_rule(AccessRule::NotPermission("delete".into()));
    let user = TestUser::with_permissions(&[], &["read"]);
    let app = init_service(
        App::new()
            .wrap(policy.into_actix_middleware::<TestUser>())
            .wrap_fn(move |req, srv| {
                req.extensions_mut().insert(user.clone());
                srv.call(req)
            })
            .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
    )
    .await;
    let res = call_service(&app, TestRequest::get().uri("/").to_request()).await;
    assert_eq!(res.status(), 200, "user without delete permission should get 200");
}

#[actix_web::test]
async fn not_permission_blocks_user_with_restricted_permission() {
    let policy = AccessPolicy::require_all()
        .add_rule(AccessRule::Authenticated)
        .add_rule(AccessRule::NotPermission("delete".into()));
    let user = TestUser::with_permissions(&[], &["read", "delete"]);
    let app = init_service(
        App::new()
            .wrap(policy.into_actix_middleware::<TestUser>())
            .wrap_fn(move |req, srv| {
                req.extensions_mut().insert(user.clone());
                srv.call(req)
            })
            .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
    )
    .await;
    let res = call_service(&app, TestRequest::get().uri("/").to_request()).await;
    assert_eq!(res.status(), 403, "user with delete permission should get 403");
}

#[actix_web::test]
async fn or_policy_allows_when_one_condition_passes() {
    let policy = AccessPolicy::require_any()
        .add_rule(AccessRule::Role("admin".into()))
        .add_rule(AccessRule::Role("editor".into()));
    let user = TestUser::new(&["editor"]);
    let app = init_service(
        App::new()
            .wrap(policy.into_actix_middleware::<TestUser>())
            .wrap_fn(move |req, srv| {
                req.extensions_mut().insert(user.clone());
                srv.call(req)
            })
            .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
    )
    .await;
    let res = call_service(&app, TestRequest::get().uri("/").to_request()).await;
    assert_eq!(res.status(), 200, "OR policy should allow when one role matches");
}