Rust Axum Casbin Authorisation

Rust Axum Casbin Authorisation

ยท

9 min read

I have finally managed to write my first rust library axum-casbin-auth which creates a middleware layer for the Axum web framework to authorise HTTP requests based on the Casbin framework.

Couple of introductions

  1. Axum - web application framework that focuses on ergonomics and modularity. Please go through Axum to familiarise certain concepts but the idea is pretty fundamental and can be applied to many frameworks with few changes.

  2. Casbin - An authorization library that supports access control models like ACL, RBAC, ABAC.

  3. Tower - a library of modular and reusable components for building robust networking clients and servers.

In this article, let us walk through the workings of the library and quick implementation to scratch the surface of what is possible.

Library

We will have two structs in order to integrate this as a middleware in the Axum framework. You can find more information on how the Axum middleware works here.

  • Imports
use std::{convert::Infallible, sync::Arc};

use axum::{
    body::{self, boxed, Body, BoxBody},
    http::{Request, StatusCode},
    response::Response,
};
use casbin::{CoreApi, Enforcer};
use futures::future::BoxFuture;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tower::{Layer, Service};
  • CasbinAuthLayer

We use Arc from std and RwLock from tokio to wrap Enforcer as it is not thread-safe and designed for a synchronous programming model. The Enforcer is the module from casbin which takes in a configuration, and policy and allows us to authorise the requests which we will see below in a short while.

#[derive(Clone)]
pub struct CasbinAuthLayer {
    enforcer: Arc<RwLock<Enforcer>>,
}

impl CasbinAuthLayer {
    pub fn new(enforcer: Arc<RwLock<Enforcer>>) -> Self {
        Self { enforcer }
    }
}
  • CasbinAuthMiddleware

A middleware is a service that intercepts a request, performs some operation and passes to the next inner function or rejects the request if needed. You can imagine as layers of an onion where the outermost layer gets the request first and goes internally until the last layer and sends the response to the client. Please refer to the above Axum middleware link as there is a good explanation of how the middleware ordering works in Axum.

#[derive(Clone)]
pub struct CasbinAuthMiddleware<S> {
    inner: S,
    enforcer: Arc<RwLock<Enforcer>>,
}
  • Auth claims

We need a subject (primary property that denotes a user/entity like email, username, etc...) for which we define policies in casbin and authorise against. At the moment we create a struct CasbinAuthClaims to have the subject alone but it can be extended to additional properties in the future.

#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct CasbinAuthClaims {
    pub subject: String,
}

impl CasbinAuthClaims {
    pub fn new(subject: String) -> Self {
        Self { subject }
    }
}
  • Layer Implementation

Now that we have created the concrete types for our layer and middleware, let us satisfy the contract in order to use the layer with Axum. The layer is a trait provided by the tower crate which has an associated type to denote the Service that this layer encompasses. In our case, the CasbinAuthLayer wraps on the CasbinAuthMiddleware service. CasbinAuthLayer can receive many input properties which can become a part of its internal state, which can then be passed to the inner service if needed.

impl<S> Layer<S> for CasbinAuthLayer {
    type Service = CasbinAuthMiddleware<S>;

    fn layer(&self, inner: S) -> Self::Service {
        CasbinAuthMiddleware {
            inner,
            enforcer: self.enforcer.clone(),
        }
    }
}
  • Service Implementation

This is the main block which implements the middleware logic. We implement the Service trait for our CasbinAuthMiddleware. There are a few associated types for which we need to provide type values:

  1. Response -> Response which is the HTTP response type from the Axum
  2. Error -> Infalliable - The error type for errors that can never happen. Since this enum has no variant, a value of this type can never actually exist. This can be useful for generic APIs that use Result and parameterize the error type, to indicate that the result is always Ok.
  3. Future -> This is the type that denotes what will the value that we will get in the future, as this is an asynchronous operation. HTTP requests do not have a definite end time and may take varying amounts of time to complete hence we use a future as output. You can refer to the future documentation to learn more about the concepts. Without going in-depth the main gist of the call function of the Service trait is responsible for the business logic of the authorisation middleware. Below are the logical steps we perform to identify if the request is authorized and can be allowed to pass to the next layer or not:

  4. Get the path of the request - like /, /hello, /api/admin, etc...

  5. Get the method of the request - GET | POST | PUT | DELETE etc...

  6. Try to get the CasbinAuthClaims from the request. This is the necessary information that is outside the scope of this library in order for us to authorise the request. We will see how it is injected in a short while.

  7. With all the information we then call the enforce_mut on the Casbin enforcer to validate based on the authorisation policies which we will see in a short while as well.

  8. If the rules match and are valid, we call the next layer like below and return the response

    ready_inner.call(request).await?.map(body::boxed);
    Ok(response)
    
  9. In other cases, we return the unauthorised response and terminate the request (Implicitly)
impl<S> Service<Request<Body>> for CasbinAuthMiddleware<S>
where
    S: Service<Request<Body>, Response = Response, Error = Infallible> + Clone + Send + 'static,
    S::Future: Send + 'static,
{
    type Response = Response<BoxBody>;

    type Error = Infallible;

    type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;

    fn poll_ready(
        &mut self,
        cx: &mut std::task::Context<'_>,
    ) -> std::task::Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx)
    }

    fn call(&mut self, mut request: Request<Body>) -> Self::Future {
        let cloned_enforcer = self.enforcer.clone();
        let not_ready_inner = self.inner.clone();
        let mut ready_inner = std::mem::replace(&mut self.inner, not_ready_inner);
        Box::pin(async move {
            let unauthorized_response: Response<BoxBody> = Response::builder()
                .status(StatusCode::FORBIDDEN)
                .body(boxed(Body::empty()))
                .unwrap();
            let path = request.uri().clone().to_string();
            let method = request.method().clone().to_string();
            let mut lock = cloned_enforcer.write().await;
            let option_vals = request
                .extensions()
                .get::<CasbinAuthClaims>()
                .map(|x| x.to_owned());
            if let Some(vals) = option_vals {
                match lock.enforce_mut(vec![vals.subject.clone(), path, method]) {
                    Ok(true) => {
                        drop(lock);
                        request.extensions_mut().insert(vals.subject);
                        let response: Response<BoxBody> =
                            ready_inner.call(request).await?.map(body::boxed);
                        Ok(response)
                    }
                    Ok(false) => {
                        drop(lock);
                        Ok(unauthorized_response)
                    }
                    Err(_) => {
                        drop(lock);
                        Ok(unauthorized_response)
                    }
                }
            } else {
                Ok(unauthorized_response)
            }
        })
    }
}

With this in place, the library is complete and let us see how it works in an Axum service.

Example

For this example, we will see how to implement Casbin RESTful model. The example is provided here

The [request_definition] specifies the format of properties that are extracted from the request to check for authorisation.

  1. sub -> Subject which can be email, username, etc
  2. obj -> Object - In this context, it is the request path /data/123
  3. act -> Action - In this context, it is GET | POST, etc...

The [policy_definition] specifies the format of properties by which the multiple rules/policies are added. You can have more than 1 policy definition.

The [policy_effect] is the definition of the policy effect. It defines whether the access request should be approved if multiple policy rules match the request. For example, one rule permits and the other denies.

The [matchers] is the definition for policy matchers. The matchers are expressions. It defines how the policy rules are evaluated against the request.

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = r.sub == p.sub && keyMatch(r.obj, p.obj) && regexMatch(r.act, p.act)
  • Casbin policies

The policy file can be static or dynamically stored in DBs as well. In this example, we will have a static policy.csv file which follows the policy_definition structure mentioned above.

p, admin@test.com, *, POST
p, user@test.com, /, GET

We have defined two policies of type p

  1. Subject = , Obj = *, Action = POST -> This means the subject is allowed to perform any POST operation

  2. Subject = , Obj = /, Action = GET -> This means the subject is allowed to perform only GET operation on the / path.

If the rule matches don't allow, then the request Is forbidden.

The below file contains the logic of signing and verifying the JWT token which contains the information of user email ( Subject ), and expiry information of the token. We implement the FromRequest trait of the Axum framework so that we can inject the decoded claims information into the request from the token sent from the client in the Authorisation header.

use axum::extract::{FromRequest, RequestParts, TypedHeader};

use axum::headers::{authorization::Bearer, Authorization};
use axum::response::IntoResponse;
use axum::{http::StatusCode, Json};
use axum_casbin_auth::CasbinAuthClaims;
use chrono::{Duration, Utc};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use serde_json::json;

#[derive(Debug)]
pub enum AuthError {
    InvalidToken,
}

pub type JwtResult<T> = std::result::Result<T, Box<dyn std::error::Error>>;

pub fn sign(email: String) -> JwtResult<String> {
    Ok(jsonwebtoken::encode(
        &Header::default(),
        &Claims::new(email),
        &EncodingKey::from_secret("SECRET".as_bytes()),
    )?)
}

pub fn verify(token: &str) -> JwtResult<Claims> {
    Ok(jsonwebtoken::decode(
        token,
        &DecodingKey::from_secret("SECRET".as_bytes()),
        &Validation::default(),
    )
    .map(|data| data.claims)?)
}

#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Claims {
    pub subject: String,
    pub exp: i64,
    pub iat: i64,
}

impl Claims {
    pub fn new(email: String) -> Self {
        let iat = Utc::now();
        let exp = iat + Duration::hours(24);
        Self {
            subject: email,
            iat: iat.timestamp(),
            exp: exp.timestamp(),
        }
    }
}

#[axum::async_trait]
impl<B> FromRequest<B> for Claims
where
    B: Send,
{
    type Rejection = AuthError;

    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        let TypedHeader(Authorization(bearer)) =
            TypedHeader::<Authorization<Bearer>>::from_request(req)
                .await
                .map_err(|e| {
                    println!("{:?}", e);
                    AuthError::InvalidToken
                })?;

        let token_data = verify(bearer.token()).map_err(|e| {
            println!("{:?}", e);
            AuthError::InvalidToken
        })?;
        req.extensions_mut().insert(token_data.clone());
        req.extensions_mut()
            .insert(CasbinAuthClaims::new(token_data.clone().subject));
        Ok(token_data)
    }
}

impl IntoResponse for AuthError {
    fn into_response(self) -> axum::response::Response {
        let (status, error_message) = match self {
            AuthError::InvalidToken => (StatusCode::BAD_REQUEST, "Invalid token"),
        };
        let body = Json(json!({
            "error": error_message,
        }));
        (status, body).into_response()
    }
}

The snippet below in FromRequest implementation above is the part that is enabling our library middleware logic to work correctly.

req.extensions_mut()
            .insert(CasbinAuthClaims::new(token_data.clone().subject));

Here is a sample server with two routes - / and /protected. I generate two tokens for two subjects - and . In a actual service, the login API will handle this.

use crate::auth::{sign, Claims};
use axum::{
    middleware::from_extractor,
    routing::{get, post},
    Router,
};
use axum_casbin_auth::{
    casbin::{CoreApi, Enforcer},
    CasbinAuthLayer,
};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::RwLock;

mod auth;

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

async fn protected() -> &'static str {
    "I AM PROTECTED! Only Authorised subjects can come here.
    If you get this response, then it means you are already authorised"
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let normal_user_token = sign(String::from("user@test.com")).unwrap();
    let admin_user_token = sign(String::from("admin@test.com")).unwrap();

    println!("Admin token: {}", admin_user_token);
    println!("Normal user token: {}", normal_user_token);

    let e = Enforcer::new("casbin/model.conf", "casbin/policy.csv").await?;
    let casbin_auth_enforcer = Arc::new(RwLock::new(e));

    let app = Router::new()
        .route("/", get(root))
        .route("/protected", post(protected))
        .layer(CasbinAuthLayer::new(casbin_auth_enforcer))
        .layer(from_extractor::<Claims>());

    let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
    Ok(())
}

You can run the example service and it will start a server in localhost port 8080

cargo run --bin restful
  • Admin token making a POST call to /protected route - Allowed

admin.png

  • User token making a POST call to /protected route - Denied

normal_forbidden.png

  • User token making a GET call to / route - Allowed

normal.png

Conclusion

We have successfully implemented a RESTful authorisation using axum-casbin-auth middleware library. This just scratches the surface of what is possible and hope you had fun as much as I had while learning and developing this library. I am open to any suggestions or comments or a random hi ๐Ÿ‘‹๐Ÿป. If you like this post and wanted to support me, you can do so by buying me a โ˜•๏ธ here.

Did you find this article valuable?

Support Omprakash Sridharan by becoming a sponsor. Any amount is appreciated!

ย