ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用Rust实现RBAC+ACL混合权限管理系统

用Rust实现RBAC+ACL混合权限管理系统 权限系统这类东西说实话技术圈里已经聊得很多了什么 RBAC、ABAC、动态权限、细粒度鉴权……方案一抓一大把。但大部分实现都集中在 Java、Go 或者 Node.js 这些生态里用 Rust 来写一套完整权限管理系统的确实不算多。我最初的想法很简单Rust 的所有权系统天然适合做资源访问控制的数据流建模性能又足够硬核能不能把这两者结合搞一套既安全又高效的开源权限管理系统于是就有了这个项目。这套系统不是那种教学 Demo而是奔着能落地、能复用去的。它包含了完整的用户认证、角色管理、策略配置、鉴权判断还额外做了些发散性的设计比如进程内策略热更新、权限判断结果缓存、内存与数据库双模式存储等等。文章里我会把整体设计思路、核心数据模型、权限判断引擎的实现细节全部拆开来讲也会把实操过程中踩过的坑一并交代清楚。如果你正在用 Rust 写后端或者准备用 Rust 折腾一套带权限的服务这篇内容应该能给你省不少时间。1. 整体设计思路与方案选型1.1 为什么是 Rust而不是 Java 或 Go聊权限系统绕不开性能和安全这两个点。Java 有 Spring SecurityGo 有 casbin生态都很成熟。我选 Rust核心原因有两个。第一个原因是内存安全。权限系统处理的是敏感数据用户角色、资源标记、访问策略任何一处内存越界、悬垂指针都可能被利用成越权漏洞。Rust 的编译器在编译期就把这类问题堵死了。借用检查器确实有时候让人抓狂但用在权限这种场景里它是真的能救命。第二个原因是性能。权限判断往往处于请求链路的中间位置每次 API 调用都至少要过一遍鉴权逻辑判断快点整体延迟就低。Rust 没有 GC 停摆生成的二进制又小部署起来也轻量。不过光有性能和安全还不够开发效率也得考虑。Rust 有个特点写的时候很痛苦写完了很舒服。一旦你理解了所有权和生命周期这套逻辑代码写出来几乎一遍过重构的时候也特别有信心。这对权限系统这种逻辑密集型的项目来说是很大的红利。1.2 权限模型选型RBAC 为主ACL 为辅权限模型有很多种有简单的 ACL访问控制列表有经典的 RBAC基于角色的访问控制还有更复杂的 ABAC基于属性的访问控制。我在这个项目里采用了 RBAC 为主、ACL 为辅的混合方案。理由很简单RBAC 最容易理解也最容易落地。用户绑定角色角色绑定权限这个模型在绝大多数业务场景里都够用。但 RBAC 有个天生的短板它不够灵活。同一个角色里的用户在某些特定资源上可能需要有差异化的权限。这时候 ACL 就派上用场了。我在 RBAC 之上叠加了一层资源级的用户权限覆盖机制相当于给某个用户单独开小灶。这种混合设计能覆盖大部分现实场景又不至于把模型搞得太复杂。1.3 “发散创新”的几个关键设计标题里写了“发散创新”这个不是噱头实际设计里确实做了几个比较有意思的尝试。第一个是策略热更新。传统的权限系统改了角色权限之后往往需要重新登录或者等缓存过期才能生效。我在这套系统里做了一个版本号机制权限数据变更时递增版本号鉴权中间件检查到版本号变化会主动失效本地缓存实现秒级生效。后续如果做成分布式架构这个机制可以平滑接入消息总线。第二个是进程内存与数据库双模式存储。小型项目、嵌入式设备或者边缘计算场景可能根本没有数据库环境。我抽象了一层存储接口底层可以切换成内存存储或者 SQLite/PostgreSQL 存储上层逻辑完全不用改。这也算是 Rust 在嵌入式方向延伸的一个思考点。第三个是权限判断结果缓存。权限判断是高频操作如果每次都从数据库里把角色、权限全部拉一遍再逐条匹配性能损耗不可忽视。我在权限决策器里加了两级缓存一级缓存用户权限集一级缓存最终的鉴权结果。内存模式下性能几乎无损数据库模式下性能也有明显提升。1.4 项目整体架构拆解整个系统分成了几个模块各司其职认证模块负责用户登录、签发 JWT Token、刷新 Token。用户与角色管理模块负责用户、角色、权限的 CRUD。策略配置模块负责把权限数据组装成策略集合。决策引擎模块负责根据策略集合做出最终鉴权决策这是整套系统的核心。中间件模块负责在 HTTP 请求链路中嵌入鉴权逻辑。模块之间通过 trait 定义接口实现完全解耦。比如决策引擎接收一个上下文结构体中间件只管从请求头里解析 Token组装上下文然后丢给决策引擎拿到结果后决定放行还是拒绝。2. 核心数据模型与存储层设计2.1 核心表结构设计权限系统的数据模型是整套系统的基础。我先定义核心表结构然后在此基础上做数据访问抽象。存储层我支持两种模式内存模式用 DashMap 加锁实现数据库模式用 SQLite 起步后续可以无缝切换到 PostgreSQL。核心表设计如下用户表 (users)CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, status INTEGER NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP );角色表 (roles)CREATE TABLE roles ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, description TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP );权限表 (permissions)CREATE TABLE permissions ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, resource TEXT NOT NULL, action TEXT NOT NULL, description TEXT );权限表里有两个关键字段resource 和 action。resource 表示资源标识比如 “article”、“user”、“order”action 表示操作动作比如 “create”、“read”、“update”、“delete”。两者组合起来就是一条完整的权限声明。用户-角色关联表 (user_roles)CREATE TABLE user_roles ( user_id INTEGER NOT NULL, role_id INTEGER NOT NULL, PRIMARY KEY (user_id, role_id) );角色-权限关联表 (role_permissions)CREATE TABLE role_permissions ( role_id INTEGER NOT NULL, permission_id INTEGER NOT NULL, PRIMARY KEY (role_id, permission_id) );用户资源级权限覆盖表 (user_permissions)CREATE TABLE user_permissions ( user_id INTEGER NOT NULL, resource TEXT NOT NULL, action TEXT NOT NULL, effect INTEGER NOT NULL DEFAULT 1, PRIMARY KEY (user_id, resource, action) );这里的 user_permissions 表就是前面提到的 ACL 补充机制。effect 字段表示允许还是拒绝1 允许0 拒绝。它的优先级高于角色权限。五张表加上两个关联表构成了整个权限模型的基础。设计上遵循了 RBAC 标准模型的规范但又留出了 ACL 覆盖的扩展口。2.2 为什么选择 SQLite 起步权限系统是一个很通用的模块部署环境可能千差万别。小项目里单独为一个权限模块部署一套 MySQL 集群成本太高。SQLite 单文件、零配置的特性让它在这类场景下非常合适。我在存储层做了 trait 抽象定义了一组访问接口比如获取用户、获取角色、获取权限集等等。内存存储和 SQLite 存储分别实现了这组接口。这样设计有几个好处第一单元测试可以直接切内存模式跑得飞快第二嵌入式环境或者边缘设备里连 SQLite 都可以省掉直接用内存模式第三将来真要上生产级别的 PostgreSQL只需要再实现一遍 trait业务层零改动。2.3 Rust 的所有权与资源访问控制之间的对照这里插一段比较有启发的思考。Rust 的所有权系统本质上也是一种编译期的资源访问控制机制每个值在同一时刻只有一个所有者你可以选择借用它但借用规则由编译器检查。这和我们设计的运行时权限系统形成了一种巧妙的对照。权限系统里每个资源也有一个“所有者”就是资源的拥有者其他用户如果想访问这个资源需要借用一个“角色”通过角色获得相应的操作权限。Rust 在编译期通过所有权、借用检查、生命周期来保证内存安全我们的权限系统则通过角色绑定、策略匹配来保证业务安全。这个对照在写代码的时候给了我不少灵感。比如我可以用 Rust 的生命周期标注来约束权限上下文的存活范围确保请求处理过程中 Token 解析出来的用户身份不会被意外篡改。这些细节组合起来让整个系统的安全性又多了一层保障。2.4 存储层代码骨架我用一个 trait 定义存储接口然后分别实现内存版和 SQLite 版。// storage/mod.rs use async_trait::async_trait; pub struct User { pub id: i64, pub username: String, pub password_hash: String, } pub struct Role { pub id: i64, pub code: String, pub name: String, } pub struct Permission { pub id: i64, pub code: String, pub resource: String, pub action: String, } #[async_trait] pub trait Storage: Send Sync { async fn get_user_by_username(self, username: str) - OptionUser; async fn get_user_by_id(self, id: i64) - OptionUser; async fn get_roles_by_user_id(self, user_id: i64) - VecRole; async fn get_permissions_by_role_id(self, role_id: i64) - VecPermission; async fn get_user_specific_permissions(self, user_id: i64) - VecPermission; }实现 SQLite 版本时我用 sqlx 这个异步数据库库编译期就校验 SQL 语句的正确性这又是一个 Rust 生态带来的安全红利。// storage/sqlite.rs use sqlx::SqlitePool; pub struct SqliteStorage { pool: SqlitePool, } #[async_trait] impl Storage for SqliteStorage { async fn get_user_by_username(self, username: str) - OptionUser { sqlx::query_as::_, (i64, String, String)( SELECT id, username, password_hash FROM users WHERE username ? ) .bind(username) .fetch_optional(self.pool) .await .ok() .map(|(id, username, password_hash)| User { id, username, password_hash }) } // 其他方法实现类似 }这里有一个细节值得说一下sqlx 的 query_as 宏在编译期会检查 SQL 语法和结果集类型配合 PostgreSQL 可以做到完全编译期校验。SQLite 模式下部分校验能力会弱一些但基本类型安全还是能保证的。3. 认证鉴权全流程实现3.1 密码存储与用户登录密码存储是权限系统的第一道防线。明文存储这种事估计没人敢干了但用简单的 MD5 加盐这种方案放到今天也已经是过时做法。我用的是 argon2Rust 生态里有很成熟的实现。它的设计目标就是抵御 GPU 暴力破解内存占用高、计算耗时长攻击成本非常大。登录流程里有一个容易被忽略的安全细节用户不存在和密码错误应该返回相同的错误信息。这样设计是为了防止攻击者通过接口探测用户名是否存在也就是常说的用户枚举漏洞。pub async fn login(storage: dyn Storage, username: str, password: str) - ResultString, AuthError { let user storage.get_user_by_username(username).await; let user match user { Some(user) user, None { // 模拟一次哈希计算让时间消耗与用户存在时一致 argon2::verify_encoded( $argon2id$v19$m65536,t3,p4$...dummy..., password ).ok(); return Err(AuthError::InvalidCredentials); } }; let valid argon2::verify_encoded(user.password_hash, password) .map_err(|_| AuthError::InternalError)?; if valid { let token generate_jwt(user.id, user.username)?; Ok(token) } else { Err(AuthError::InvalidCredentials) } }3.2 JWT 签发与 Token 刷新登录成功之后系统签发 JWT。JWT 本身有个特点它是无状态的服务器不需要存储会话信息每次请求带过来验签通过即可。这对水平扩展非常友好。但它也有一个天然缺陷无法主动失效。我的方案是把 JWT 的有效期设置为较短时间比如 30 分钟同时签一个 7 天有效期的 Refresh Token。用户登录后拿 Access Token 访问接口Access Token 过期后拿 Refresh Token 去换新的 Access Token。Refresh Token 存储在服务端使用一个随机生成的会话 ID 来关联。pub fn generate_jwt(user_id: i64, username: str) - ResultString, AuthError { let header jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256); let claims Claims { sub: user_id.to_string(), username: username.to_string(), exp: (Utc::now() Duration::minutes(30)).timestamp() as usize, iat: Utc::now().timestamp() as usize, }; let token jsonwebtoken::encode(header, claims, EncodingKey::from_secret(SECRET.as_bytes()))?; Ok(token) }JWT 密钥的管理是另一个重点。生产环境里密钥必须通过环境变量或者密钥管理服务注入绝不能硬编码在代码里。我见过不少项目把 JWT 密钥写死在配置文件的默认值里一旦代码泄露或者被反编译整个系统的认证体系就彻底崩了。3.3 鉴权中间件的实现鉴权中间件在整个请求链路里处于核心位置。它要做三件事解析 Token、加载权限数据、调用决策引擎。pub async fn auth_middleware( state: ArcAppState, mut req: RequestBody, next: Next, ) - ResultResponseBody, AuthError { let token extract_token_from_header(req.headers())?; let claims verify_jwt(token)?; let context PermissionContext { user_id: claims.sub.parse().unwrap(), roles: state.storage.get_roles_by_user_id(claims.sub.parse().unwrap()).await, }; let decision state.decision_engine.check(context, current_resource, current_action).await; if decision.allowed { Ok(next.run(req).await) } else { Err(AuthError::Forbidden) } }这里有一个实践经验尽量不要在中间件里直接拿完整的权限列表而是传入用户 ID 和所需资源让决策引擎去判断。这样可以避免每次请求都加载大量的权限数据性能更好也更符合最小化数据暴露的原则。3.4 核心决策引擎实现决策引擎是整个权限系统的核心它接收一个上下文和一个目标权限请求输出允许或拒绝。判断逻辑是按优先级排列的必须固定顺序否则会出现逻辑混乱。第一优先级检查 ACL 用户级策略。如果该用户对当前资源有特殊策略直接以特殊策略的结果为准。第二优先级检查角色权限。遍历用户所有角色检测是否存在匹配的权限记录。第三优先级默认拒绝。pub async fn check( self, context: PermissionContext, resource: str, action: str, ) - Decision { // 一级缓存基于 user_id resource action let cache_key format!({}:{}:{}, context.user_id, resource, action); if let Some(decision) self.cache.get(cache_key) { return decision; } let decision self.check_inner(context, resource, action).await; self.cache.insert(cache_key, decision, Duration::from_secs(60)); decision } async fn check_inner( self, context: PermissionContext, resource: str, action: str, ) - Decision { // 1. ACL 覆盖优先级最高 let specific self.storage.get_user_specific_permissions(context.user_id).await; for perm in specific { if perm.resource resource perm.action action { return Decision::Allow; } } // 2. 角色权限匹配 for role in context.roles { let perms self.storage.get_permissions_by_role_id(role.id).await; for perm in perms { if perm.resource resource perm.action action { return Decision::Allow; } } } // 3. 默认拒绝 Decision::Deny }默认拒绝是一个非常重要的安全原则。没有明确授权就视为拒绝。这一点和防火墙的默认规则一致白名单思想在任何安全系统里都是第一原则。4. 工程化落地与部署实践4.1 项目结构组织代码组织上我按模块划分每个模块自成一层。src/ ├── main.rs ├── config.rs ├── auth/ │ ├── mod.rs │ ├── jwt.rs │ ├── password.rs │ └── middleware.rs ├── storage/ │ ├── mod.rs │ ├── memory.rs │ └── sqlite.rs ├── engine/ │ ├── mod.rs │ ├── decision.rs │ └── cache.rs ├── routes/ │ ├── mod.rs │ ├── auth_routes.rs │ ├── user_routes.rs │ └── role_routes.rs └── models/ ├── mod.rs └── context.rs这种组织方式的好处是边界清晰。存储层不关心鉴权逻辑引擎层不关心路由注册各模块之间通过 trait 通信替换和扩展都很方便。4.2 基于 axum 构建 API 服务Web 框架我选了 axum它是 tokio 生态下的官方推荐框架性能和类型安全都很出色。axum 的 extractor 机制在鉴权中间件这块非常好用我可以把请求身份直接注入到 handler 参数里。async fn create_role( State(state): StateArcAppState, identity: Identity, Json(payload): JsonCreateRoleRequest, ) - ResultJsonRoleResponse, ApiError { // 动态权限检查 let ctx PermissionContext::from(identity); let decision state.decision_engine.check(ctx, role, create).await; if !decision.allowed { return Err(ApiError::Forbidden); } // 业务逻辑 }这种写法的好处是权限判断放在了业务函数的开头意图非常清晰。而且因为决策引擎是纯异步的不会阻塞其他请求。4.3 权限数据的种子初始化系统首次启动时需要初始化一组默认数据至少包括一个超级管理员角色和一个初始管理员账号。这里有一个容易踩的坑初始密码一定不要用默认的 “admin123” 这类弱密码我写了一个初始化脚本启动时如果没有超级管理员就自动创建并为管理员生成一长串随机密码打印在日志里强制首次登录后修改。async fn seed_admin(storage: dyn Storage) - AnyResult() { let admin storage.get_user_by_username(admin).await; if admin.is_none() { let random_password generate_random_password(16); let hash hash_password(random_password).await?; storage.create_user(admin, hash).await?; let admin_role storage.create_role(super_admin, 超级管理员).await?; storage.assign_role_to_user(admin_role.id, admin_user_id).await?; log::info!(初始管理员密码: {}, random_password); } Ok(()) }4.4 打包、部署与性能调优Rust 项目编译出来就是单个二进制文件部署非常方便。我提供一个最小化的 DockerfileFROM rust:1.75 AS builder WORKDIR /app COPY . . RUN cargo build --release FROM debian:bookworm-slim COPY --frombuilder /app/target/release/permission-system /usr/local/bin/ EXPOSE 8080 CMD [permission-system]编译时加--release标志优化级别 LTO 全开二进制体积能控制在 10MB 左右。镜像最终只有几十 MB相比动辄上百 MB 的 Java 镜像部署成本控制得很好。性能调优方面有几个实际经验可以分享。数据库连接池不要开太大SQLite 模式下 5-10 个连接就足够了开多了反而会因为锁竞争拖慢速度。JWT 验签用的密钥要选择合适长度的随机字节HS256 至少 32 字节。缓存容量要限制防止用户量大的时候内存膨胀。5. 常见问题与排查技巧实录5.1 权限怎么改都不生效是怎么回事这是一个非常典型的问题。权限配置改完之后用户重新调用接口发现权限还是旧的那套。排查思路先确认是不是缓存没有失效。我的设计里有一个版本号机制每次权限变更会递增一个全局版本号缓存写入的时候同时记录版本号。读取缓存时先对比版本号不一致就重新加载。版本号实现可以用一个原子计数器static VERSION: AtomicU64 AtomicU64::new(0); pub fn bump_version() { VERSION.fetch_add(1, Ordering::SeqCst); } pub fn current_version() - u64 { VERSION.load(Ordering::SeqCst) }如果权限改了没生效先检查有没有调用 bump_version。另一个常见原因是权限策略配置错了比如资源名大小写不一致前端传的是 “Article”策略里写的是 “article”那当然匹配不上。这种情况需要在系统里加一条规范约定所有资源名统一小写、下划线分隔。5.2 用户能看到不属于自己的数据是不是出 bug 了这个问题的根源往往不在权限系统而在业务层。权限系统通常管的是“能不能访问这个接口”比如一个有 read 权限的用户可以调用“获取文章列表”的接口。但列表里每篇文章属于谁、当前用户能不能看这一篇这是数据级权限需要业务层再过滤。我做了一个简单的数据级过滤方案业务查询时把当前用户 ID 带进 SQL通过 user_id 字段过滤。这在多租户系统里尤其重要租户隔离的本质也是数据级权限。5.3 JWT 过期时间对不上、时区对不上怎么处理JWT 的 exp 字段是 Unix 时间戳理论上没有时区问题。但我在实际使用中遇到过几次问题都是调用方的系统时间不准。处理方式是在验签时允许一定的时钟偏移比如 30 秒let now Utc0::now().timestamp() as usize; let leeway 30; if claims.exp leeway now { return Err(AuthError::TokenExpired); }还有一个细节JWT 的 iat签发时间不要设成和服务器时间完全一致稍微留一两秒的余量否则在某些毫秒级时间精度差异下可能出现验签失败。5.4 编译期借用检查过不去怎么快速定位Rust 新手遇到借用检查错误是最痛苦的。我在写这个项目的时候特别是处理存储层和引擎层共享状态时经常碰上这类问题。有一个很实用的经验在一个县城数据库连接池跨多个 async 任务共享时直接用ArcMutex...虽然能解决问题但会把代码搞得非常丑。更优雅的做法是尽量让每个请求持有自己的上下文避免共享可变状态。比如鉴权上下文是一次性构建的拿到之后不会再修改用只读引用传递到处用就行。只有在真正需要跨请求共享的地方才用ArcMutex...或者RwLock。这条原则能避开 90% 的借用检查烦恼。5.5 常见问题速查表现象可能原因解决方法权限修改不生效缓存未失效或版本号未更新检查 bump_version 调用清理进程内缓存所有用户都无法访问默认拒绝策略配置错误检查角色权限是否匹配同一权限部分请求成功部分失败资源名大小写不一致统一使用小写下划线命名JWT 验证偶发失败系统时钟偏移验签时加 leeway 容忍请求延迟飙升数据库连接池被占满调整连接池大小加索引内存占用持续增长缓存未设置过期时间为缓存设置 TTL 并限制容量6. 开源与后续规划6.1 开源地址与使用方式项目已经在 GitHub 上开源仓库地址就不在这里贴了搜索项目名就能找到。如果你有需求直接拉代码下来就能跑。git clone https://github.com/yourname/permission-system cd permission-system cargo run --release启动之后默认监听 8080 端口初始化日志里会打印管理员账号密码。整体使用路径是用管理员登录创建角色创建权限绑定角色然后就能正常使用鉴权能力了。6.2 适合哪些场景这套系统最适合使用的场景有几类中小型 Web 项目的权限模块、内部管理后台的权限控制、边缘设备上的轻量级访问控制、以及 Rust 后端服务里需要内置权限能力的场景。如果项目的权限需求极其复杂比如多维度属性判断、策略动态编排那这套系统的 RBAC ACL 模型可能不够需要再往上叠加 ABAC 的能力。但如果你就是要一个够用、不折腾、性能和安全性都有保障的权限模块这套设计完全可以直接抄。6.3 未来的扩展方向后续有几个方向可以继续迭代。一是支持 ABAC 属性策略引擎把用户属性、资源属性、环境属性都引入策略判断覆盖更复杂的授权需求。二是引入 gRPC 接口让非 Rust 技术栈的服务也能通过远程调用使用这套权限能力。三是做一个可视化的权限管理前端目前管理接口都是 API配置权限需要写 curl 或者脚本不够直观。四是将存储层从 SQLite 扩展到 PostgreSQL利用 PG 的 Row Level Security 实现数据库层面的行级权限隔离。写在最后权限系统的核心从来不是代码写得多花哨而是模型设计足够稳、判断逻辑足够清晰、默认策略足够保守。Rust 在这几个方面都给了我很好的支撑编译器的严格检查帮我把很多潜在隐患挡在了上线之前。有一点我想重点强调任何权限系统它本身只是工具真正重要的是使用它的人怎么规划权限边界。你在配置角色和权限时是否遵循了最小权限原则是否定期审计权限分配这些才是决定系统安全性的关键。技术手段保证“能力上允许”管理手段保证“行为上正确”两者缺一不可。如果这个项目能让你在权限系统的设计上少走一些弯路那它开源的初衷就达到了。后续如果发现 bug 或者有好的改进思路欢迎提 issue 或者 PR。我最近也在研究怎么把策略引擎做成 WASM 插件让权限策略可以在运行时动态加载这样权限控制的能力边界就能进一步扩展了。
返回列表