一、四角色模型:比 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?三个原因:

  1. JWT 序列化:enum 的 serde 序列化/反序列化在跨端(后端签发、前端解析)时容易出幺蛾子,String 零心智负担
  2. 扩展性:以后加个 viewer 只读角色,不需要改 enum 定义、不需要重新编译所有依赖 crate
  3. 数据库友好: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)
}

几点关键设计:

  1. 角色和 URL 前缀绑定/api/admin/* 只能由 role == "admin" 访问,/api/* 业务接口不能由 admin 访问——admin 的 JWT 里 merchant_id 是 "public",拿去查业务数据什么也查不到
  2. 双向检查:不仅防越权访问,也防 admin 误入商户数据通道
  3. 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 角色可以访问的接口”,反而增加了两个问题:

  1. 配置错误的风险:admin 不小心取消了 operator 的"查看客户"权限,所有商户的操作员突然看不到客户列表,排查方向完全跑偏
  2. 一致性维护:代码里的角色判断和 RBAC 配置表里的权限定义要保持同步,多了一个信息源,出了问题不知道信哪个

我的哲学是:在角色不超过 5 个、权限变更频率以月计的场景里,把角色逻辑直接写在代码里,比查配置表更可靠。

如果你有一个 50 角色的企业级 SaaS,每条数据还有行级权限,那 RBAC + ABAC 是必要之恶。但对我这个四角色的家政 CRM 来说,够用就是最好的架构。

六、总结

整个权限方案可以浓缩成四句话:

  1. 角色存字符串,JWT 里带角色,鉴权不查数据库
  2. 两条中间件各管各的——页面层只看有没有 token,API 层检查角色和路径是否匹配
  3. admin 和用户双表双入口物理隔离,互不串通
  4. 前端菜单按角色驱动渲染,但这只是体验优化,真正的防线在 API 中间件

没有用什么高级玩意儿——没有 RBAC 库、没有 Policy 引擎、没有 ABAC。HS256 + Cookie HttpOnly + 两层 Axum 中间件,加起来不到 200 行代码,把四角色权限安排得明明白白。

你的项目用的是哪种权限方案?有没有遇到过"设计过头"的情况?欢迎在评论区聊聊。


*项目开源在 GitHub,搜 Pico-CRM 就能找到完整代码。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐