四个角色、两条中间件,我的 Rust 全栈权限方案够“土“但管用
一、四角色模型:比 RBAC 简单,够用就行
大家好,我是 Pico-CRM 的作者。
Pico-CRM 是一个家政行业的 SaaS 系统,服务多个家政公司。权限模型是所有 SaaS 绕不开的问题——谁能看什么、谁能改什么,搞复杂了维护成本爆炸,搞简单了又怕越权。
我的场景不算复杂,四类角色够用:
| 角色 | 能干嘛 | 不能干嘛 |
|---|---|---|
| admin | 管理商户、看平台统计、系统设置 | 访问具体业务数据(客户/订单) |
| merchant | 完整业务菜单:客户、订单、排班、服务目录 | 看平台级管理后台 |
| operator | 跟 merchant 一样,完整业务菜单 | 同上 |
| user | 只看客户列表,属于可被排班的一线人员 | 订单、排班、服务目录都看不到 |
提前声明:这个四角色设计是根据家政 CRM 的具体场景定的,不代表通用最佳实践。如果你的系统角色比这多、权限粒度比这细,可能需要 RBAC 或 ABAC。
角色不放枚举,放字符串
一个让我纠结过的设计选择:Rust 里角色到底用 enum 还是 String?最终选了 String。
// backend/src/domain/identity/user/model.rs
pub struct User {
pub uuid: String,
pub user_name: String,
pub password: String, // Argon2 哈希
pub role: String, // "admin" | "merchant" | "operator" | "user"
pub is_admin: Option<bool>, // 辅助标志,方便前端快速判断
pub status: Status,
pub merchant_uuid: Option<String>,
// ...
}
为什么不用 enum?三个原因:
- JWT 序列化:enum 的 serde 序列化/反序列化在跨端(后端签发、前端解析)时容易出幺蛾子,String 零心智负担
- 扩展性:以后加个
viewer只读角色,不需要改 enum 定义、不需要重新编译所有依赖 crate - 数据库友好:SeaORM 直接映射
VARCHAR,不用写自定义 FromRow
代价当然有:编译期没法穷举检查,写错 "adimn" 不会报错。但对四角色的场景来说,这点风险可控。
二、JWT 签发:登录时一次写入,之后只读不查库
2.1 Claims 里塞角色,别每次都查数据库
很多权限系统的做法是:请求来了 → 解析 JWT 拿到 user_id → 查数据库拿到角色和权限列表 → 做判断。每次请求多一次数据库查询。
我的做法是把角色信息直接放在 JWT Claims 里,签发时一次写入,后续请求只解析不查库:
// backend/src/domain/identity/auth/claims.rs
pub struct JwtClaims {
pub sub: String, // 用户 UUID
pub user_name: String,
pub merchant_id: String, // "public" 表示 admin
pub role: String, // 签发时从 User 模型写入
pub exp: i64, // 过期时间戳
}
JWT 签发逻辑也很直白:
// backend/src/infrastructure/auth/jwt_provider.rs
fn generate_jwt(&self, user_uuid: String, user_name: String, merchant_id: String, role: String) -> Result<String, String> {
let expiration = Utc::now() + Duration::hours(self.jwt_config.expiry_hours);
let claims = JwtClaims {
sub: user_uuid,
user_name,
merchant_id,
role, // 从数据库读出来的那一刻就写死
exp: expiration.timestamp(),
};
encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(self.jwt_config.secret.as_bytes()),
)
.map_err(|err| err.to_string())
}
这样做的好处是鉴权不开数据库连接。后续中间件解析 JWT 拿到 role 就能做判断,不用再跑 SQL。代价是如果管理员改了用户角色,要等旧 JWT 过期才生效——但在我的场景里,2 小时过期 + 角色变更低频,完全可以接受。
2.2 用户和管理员走两套登录流程
你可能会问:admin 和 merchant 怎么区分?答案是——分两个登录接口,走两套认证流程:
// 商户/员工登录 → POST /api/login
// JwtAuthProvider::authenticate():
// 1. 查 users 表
// 2. Argon2 验证密码
// 3. 如果 role == "admin" → 拒绝(admin 不能从商户入口登录)
// 4. 签发 JWT,merchant_id = 用户的 merchant_uuid
// 平台管理员登录 → POST /api/admin/login
// AdminAuthAppService::authenticate():
// 1. 查 admin_users 表(独立表)
// 2. 密码验证
// 3. set_admin(true) 保证角色一定是 "admin"
// 4. 签发 JWT,merchant_id = "public"
admin 和普通用户存在不同的表里,物理隔离。即使 users 表被注入了一条 role = "admin" 的记录,商户登录入口也会拒绝——第 3 步的检查直接把这条路堵死了。
2.3 Cookie 配置:HttpOnly + SameSite=Lax
JWT 签好了怎么传给前端?我放弃了 localStorage,直接用 Cookie:
// app/src/pages/login.rs
let session_cookie = Cookie::build(("user_session", token.to_string()))
.path("/")
.http_only(true) // JS 读不到,XSS 拿不走
.same_site(SameSite::Lax) // 防止 CSRF
.max_age(Duration::hours(2))
.build();
三个属性的考量:
- HttpOnly:前端即使被注入恶意脚本也拿不到 token
- SameSite=Lax:允许从外部链接点进来时携带 cookie(用户体验),但阻止跨站 POST 时携带
- max_age=2h:比 JWT 过期时间(24h)短,多一层兜底
三、双层中间件:页面重定向 + API 拦截,各干各的
权限校验的核心在中间件。我拆成了两条,职责完全不同:
3.1 页面层中间件:轻量级路由守卫,只做 303 重定向
// server/src/middlewares/auth_middleware.rs
pub async fn global_route_auth_middleware(
State(db): State<Database>,
req: Request<Body>,
next: Next,
) -> Result<Response<Body>, StatusCode> {
let path = req.uri().path().to_string();
// 白名单:未登录也能访问的页面
let whitelist = ["/", "/login", "/admin/login"];
if whitelist.contains(&path.as_str()) {
return Ok(next.run(req).await);
}
// 只检查 cookie 是否存在,不解析 JWT 内容
let jar = get_cookie_jar_from_req(&req);
match jar.get("user_session") {
Some(cookie) if !cookie.value().is_empty() => Ok(next.run(req).await),
_ => {
// 302 重定向:普通用户去 /login,admin 路径去 /admin/login
let login_url = if path.starts_with("/admin") {
"/admin/login"
} else {
"/login"
};
Ok(redirect_to_login(login_url))
}
}
}
这一层只做一件事:有没有 token。有没有权限、角色对不对,它不管。为什么?因为页面路由守卫要的是快——如果不做任何数据库查询就能判断"这人该不该被踢到登录页",就别做。实际上连 JWT 解析都没做,只读了 cookie 是否为空。
3.2 API 层中间件:完整校验,角色路径强制匹配
前端页面提示的菜单只防君子,真正的权限执行点在 API 中间件:
pub async fn global_api_auth_middleware(
State(db): State<Database>,
mut req: Request<Body>,
next: Next,
) -> Result<Response<Body>, StatusCode> {
let path = req.uri().path().to_string();
// 白名单:登录、注册、登出不需要鉴权
let whitelist = ["/api/logout", "/api/login", "/api/admin/login", "/api/register_merchant"];
if whitelist.iter().any(|&w| path.starts_with(w)) {
return Ok(next.run(req).await);
}
// 解析 JWT,拿角色
let token = get_cookie_from_req(&req, "user_session")?;
let claims = auth_provider.get_claims(&token)?;
// 核心校验:角色和路径必须匹配
let is_admin = claims.role == "admin";
let is_admin_path = path.starts_with("/api/admin") || path.starts_with("/admin");
let is_common_path = matches!(path.as_str(), "/api/logout" | "/api/get_user_info");
if is_admin && !is_admin_path && !is_common_path {
// admin 访问了商户 API → 拒绝
return Err(StatusCode::UNAUTHORIZED);
}
if !is_admin && is_admin_path {
// 商户用户访问了 admin API → 拒绝
return Err(StatusCode::UNAUTHORIZED);
}
// 注入上下文,后面 handler 直接用
req.extensions_mut().insert(TenantContext {
merchant_id: claims.merchant_id.clone(),
role: claims.role.clone(),
});
Ok(next.run(req).await)
}
几点关键设计:
- 角色和 URL 前缀绑定:
/api/admin/*只能由role == "admin"访问,/api/*业务接口不能由 admin 访问——admin 的 JWT 里 merchant_id 是"public",拿去查业务数据什么也查不到 - 双向检查:不仅防越权访问,也防 admin 误入商户数据通道
- TenantContext 注入:角色信息通过 Axum Extension 往下传,handler 里不需要再做鉴权检查
四、前端菜单驱动:不同角色看到不同的侧边栏
权限不仅在 API 层拦截,前端也会根据角色渲染不同的菜单:
// app/src/components/features/sidebar.rs
let is_admin = user.is_admin.unwrap_or(false) || user.role == "admin";
// 平台管理菜单 → 只有 admin 能看
let show_admin_menu = move || matches!(user_info(), Some((true, _)));
// 业务中心菜单 → merchant 和 operator 能看
let show_merchant_menu = move || {
matches!(user_info(), Some((false, ref role))
if role == "operator" || role == "merchant")
};
// 工作中心菜单 → 剩下的人(user 角色)能看
let show_staff_menu = move || {
matches!(user_info(), Some((false, ref role))
if role != "operator" && role != "merchant" && role != "admin")
};
三种菜单权限派生出的实际效果:
- admin:看到"平台管理"——商户管理、平台统计、系统设置
- merchant / operator:看到"业务中心"——客户管理、订单管理、排班管理、服务目录
- user:看到"工作中心"——只有一个客户列表,供一线服务人员查看客户信息
这里用的是 Leptos 的 RwSignal 响应式状态——user_info() 是一个从 API /api/get_user_info 拉回来的信号,登录后自动刷新,切换用户时菜单跟着变。
五、为什么不搞更"正规"的方案
说到权限系统,很多人第一反应是上 RBAC(角色-权限-资源)或者 ABAC(基于属性的访问控制)。Pico-CRM 没有做这些,为什么?
角色和接口是 1:1 绑定的,不需要中间抽象层。
在 Pico-CRM 的场景里,“operator 能不能删除订单"不是一个需要动态配置的东西——能就是能,不能就是不能。如果有一个 RBAC 后台,让 admin 去勾选"operator 角色可以访问的接口”,反而增加了两个问题:
- 配置错误的风险:admin 不小心取消了 operator 的"查看客户"权限,所有商户的操作员突然看不到客户列表,排查方向完全跑偏
- 一致性维护:代码里的角色判断和 RBAC 配置表里的权限定义要保持同步,多了一个信息源,出了问题不知道信哪个
我的哲学是:在角色不超过 5 个、权限变更频率以月计的场景里,把角色逻辑直接写在代码里,比查配置表更可靠。
如果你有一个 50 角色的企业级 SaaS,每条数据还有行级权限,那 RBAC + ABAC 是必要之恶。但对我这个四角色的家政 CRM 来说,够用就是最好的架构。
六、总结
整个权限方案可以浓缩成四句话:
- 角色存字符串,JWT 里带角色,鉴权不查数据库
- 两条中间件各管各的——页面层只看有没有 token,API 层检查角色和路径是否匹配
- admin 和用户双表双入口物理隔离,互不串通
- 前端菜单按角色驱动渲染,但这只是体验优化,真正的防线在 API 中间件
没有用什么高级玩意儿——没有 RBAC 库、没有 Policy 引擎、没有 ABAC。HS256 + Cookie HttpOnly + 两层 Axum 中间件,加起来不到 200 行代码,把四角色权限安排得明明白白。
你的项目用的是哪种权限方案?有没有遇到过"设计过头"的情况?欢迎在评论区聊聊。
*项目开源在 GitHub,搜 Pico-CRM 就能找到完整代码。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)