跳转至主要内容

在 Rust 中用 Axum 实现 RBAC

本教程为一个用 Rust、Axum 和 casbin-rs 编写的 REST API 加上基于角色的访问控制(RBAC)。最后,三个角色(admin、editor、viewer)可以对不同路由使用不同的 HTTP 方法,规则放在一个策略文件中,修改时无需改动处理函数。

模型和策略与本教程的 Go、Node.js、Python 和 Java 版本相同。

1. 安装​

cargo new rbac && cd rbac
cargo add casbin axum serde_json
cargo add tokio --features full

本教程在 casbin 2.20 和 axum 0.8 上测试过。

2. 编写模型​

保存为项目根目录下的 model.conf:

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

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

[matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && regexMatch(r.act, p.act)
  • r = sub, obj, act:每个请求由用户、URL 路径和 HTTP 方法组成。
  • g = _, _:用户可以被分配到角色。
  • keyMatch2 匹配 /api/articles/:id 这样的路径,regexMatch 让一条规则可以列出多个方法。其他内置匹配函数见 函数。

3. 编写策略​

保存为 policy.csv:

p, admin, /api/*, (GET)|(POST)|(PUT)|(DELETE)
p, editor, /api/articles, (GET)|(POST)
p, editor, /api/articles/:id, (GET)|(PUT)
p, viewer, /api/articles, GET
p, viewer, /api/articles/:id, GET

g, alice, admin
g, bob, editor
g, carol, viewer

以 p 开头的行授予某个角色对某个路径和一组方法的访问权。以 g 开头的行把用户分配到角色。

4. 添加中间件​

enforcer 以 Arc\<RwLock\<Enforcer>> 的形式在请求之间共享:检查时获取读锁,可以并行执行;修改策略时获取写锁。中间件通过 route_layer 挂载,所以只对存在的路由运行,未知路径仍然返回 404。

src/main.rs
use std::sync::Arc;

use axum::{
extract::{OriginalUri, Path, Request, State},
http::StatusCode,
middleware::{self, Next},
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use casbin::{CoreApi, Enforcer};
use serde_json::{json, Value};
use tokio::sync::RwLock;

type SharedEnforcer = Arc<RwLock<Enforcer>>;

/// Asks Casbin whether the current user may call this route.
async fn authorize(State(enforcer): State<SharedEnforcer>, req: Request, next: Next) -> Response {
// Replace this with the user from your session or JWT.
let user = req
.headers()
.get("x-user")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
// Inside a nested router req.uri() has the /api prefix stripped, so use the original URI.
let path = match req.extensions().get::<OriginalUri>() {
Some(OriginalUri(uri)) => uri.path().to_string(),
None => req.uri().path().to_string(),
};
let method = req.method().as_str().to_string();

match enforcer.read().await.enforce((user, path, method)) {
Ok(true) => next.run(req).await,
Ok(false) => (StatusCode::FORBIDDEN, Json(json!({ "error": "forbidden" }))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() }))).into_response(),
}
}

async fn list_articles() -> Json<Value> {
Json(json!({ "articles": [] }))
}

async fn create_article() -> (StatusCode, Json<Value>) {
(StatusCode::CREATED, Json(json!({ "created": true })))
}

async fn update_article(Path(id): Path<u32>) -> Json<Value> {
Json(json!({ "updated": id }))
}

async fn delete_article(Path(id): Path<u32>) -> Json<Value> {
Json(json!({ "deleted": id }))
}

#[tokio::main]
async fn main() {
let enforcer = Enforcer::new("model.conf", "policy.csv").await.unwrap();
let enforcer: SharedEnforcer = Arc::new(RwLock::new(enforcer));

let api = Router::new()
.route("/articles", get(list_articles).post(create_article))
.route("/articles/{id}", axum::routing::put(update_article).delete(delete_article))
.route_layer(middleware::from_fn_with_state(enforcer.clone(), authorize));

let app = Router::new().nest("/api", api).with_state(enforcer);

let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
警告

在用 nest 添加的路由器中,req.uri() 会去掉前缀:对 /api/articles 的请求到达时变成 /articles,匹配不到任何策略规则。所以中间件读取的是 OriginalUri。如果你的中间件拒绝了所有请求,检查一下传给 Casbin 的是哪个路径。

备注

示例用 X-User 请求头保持简短。 Casbin 只负责授权;实际应用中,用户名来自你的认证层,例如经过校验的 JWT。

5. 试一试​

cargo run
curl -i -X POST   -H "X-User: bob"   http://localhost:8080/api/articles     # 201, editors can create
curl -i -X DELETE -H "X-User: bob" http://localhost:8080/api/articles/1 # 403, editors cannot delete
curl -i -X DELETE -H "X-User: alice" http://localhost:8080/api/articles/1 # 200, admins can
curl -i -X POST -H "X-User: carol" http://localhost:8080/api/articles # 403, viewers are read-only

6. 在运行时修改角色​

角色和权限都是数据。在服务器运行时获取写锁即可修改:

use casbin::{MgmtApi, RbacApi};

let mut e = enforcer.write().await;
e.add_role_for_user("dave", "editor", None).await?; // dave becomes an editor
e.delete_role_for_user("bob", "editor", None).await?; // bob is no longer an editor
e.add_policy(vec!["editor".into(), "/api/comments".into(), "POST".into()]).await?;

e.get_roles_for_user("alice", None); // ["admin"]

参数 None 是域;使用 带域的 RBAC 时传入 Some("tenant1")。

下一步​

  • 把策略存到数据库中。 用 sqlx-adapter 或 diesel-adapter 等适配器代替 policy.csv(见 适配器)。这样通过 API 做的策略修改会自动保存。
  • 现成的中间件。 axum-casbin 和 Actix 插件都列在 中间件 中。
  • 基于属性的规则。 把角色与 ABAC 结合,例如“编辑只能修改自己的文章”。选择访问控制模型 中给出了结合两者的模型。
  • gRPC 服务。 同样的思路也适用于 gRPC:见 gRPC 授权,它用的是 Go,但可以直接改写成 tonic 拦截器。
  • 在浏览器中试验。 把模型和策略粘贴到 在线编辑器,无需运行代码就能测试请求。

Comments are powered by giscus and GitHub Discussions. They load from giscus.app and GitHub only after you click the button below.