权限设计规范
RBAC、数据权限
[Standard] 采用 RBAC0(用户-角色-权限) 基础模型,复杂场景可扩展 RBAC1(角色继承)或 RBAC2(角色约束)。
用户(User) ──多对多── 角色(Role) ──多对多── 权限(Permission) │ │ └── 可直接绑定少量权限(特例) └── 关联资源 + 动作[Requirement] 权限点是最小粒度,角色是权限的集合,用户通过角色获得权限。禁止直接给用户分配大量权限点(应通过角色)。
| 表 | 关键字段 | 说明 |
|---|---|---|
user | id, username | 用户 |
role | id, name, code | 角色 |
permission | id, code, resource, action, type | 权限点 |
user_role | user_id, role_id | 用户-角色关联 |
role_permission | role_id, permission_id | 角色-权限关联 |
[Requirement] 权限点编码格式:{资源}:{动作},全小写,动作用动词,如 user:read、order:create、payment:refund。
| 动作 | 含义 | HTTP 方法 |
|---|---|---|
| read | 查看 | GET |
| create | 新增 | POST |
| update | 修改 | PUT/PATCH |
| delete | 删除 | DELETE |
| export | 导出 | GET(特殊) |
| approve | 审批 | POST(特殊) |
[Standard] 角色按业务职责设计,数量适中(一般 10~50 个),避免”一人一角”导致角色爆炸。
[Caution] 禁止创建”超级管理员”角色给普通业务人员使用,超级管理员仅限运维/安全人员,且操作需审计。
[Standard] 在 RBAC 功能权限之外,增加数据权限控制用户能看到哪些数据,常见维度:
| 维度 | 说明 | 示例 |
|---|---|---|
| 本人 | 只能看自己创建的数据 | WHERE creator_id = current_user.id |
| 本部门 | 看本部门数据 | WHERE dept_id = current_user.dept_id |
| 本部门及下级 | 看本部门及子部门 | WHERE dept_id IN (子部门树) |
| 全部 | 看所有数据 | 无数据过滤 |
| 自定义 | 按特定规则 | WHERE region IN ('北京','上海') |
[Requirement] 数据权限在查询层注入过滤条件实现,禁止在返回结果后再过滤(性能差且容易遗漏)。
# 数据权限过滤示例(在 Repository 层统一处理)
def build_data_scope_filter(query, current_user):
scope = current_user.data_scope # all / dept / dept_and_sub / self
if scope == "all":
return query
elif scope == "self":
return query.filter(Order.creator_id == current_user.id)
elif scope == "dept":
return query.filter(Order.dept_id == current_user.dept_id)
elif scope == "dept_and_sub":
sub_dept_ids = get_sub_dept_ids(current_user.dept_id)
return query.filter(Order.dept_id.in_(sub_dept_ids))[Standard] 通过 MyBatis 拦截器 / SQLAlchemy 事件 / Repository 基类统一注入,避免每个查询手写。
用户请求 → 功能权限校验(能否访问该接口)→ 数据权限过滤(能看到哪些数据)→ 返回结果[Caution] 功能权限通过才能进入数据权限过滤,两者缺一不可。
[Requirement] 每个需要鉴权的接口必须声明所需权限点,在网关或 Controller 入口统一校验。
from functools import wraps
def require_permission(perm_code: str):
def decorator(func):
@wraps(func)
async def wrapper(*args, current_user, **kwargs):
if perm_code not in current_user.permissions:
raise PermissionDeniedError(f"缺少权限: {perm_code}")
return await func(*args, current_user=current_user, **kwargs)
return wrapper
return decorator
@require_permission("order:read")
@router.get("/orders/{order_id}")
async def get_order(order_id: int, current_user: User = Depends(get_current_user)):
...[Standard] 对敏感字段(如金额、手机号)做字段级权限控制,无权限的用户看不到该字段(返回 null 或不返回)。
def filter_fields(data: dict, current_user) -> dict:
if "amount:read" not in current_user.permissions:
data.pop("amount", None)
if "phone:read" not in current_user.permissions:
data["phone"] = mask_phone(data.get("phone", ""))
return data见上文「数据权限」章节。
[Standard] 用户权限列表在登录后缓存(Redis),TTL 30 分钟,权限变更时主动清除缓存。
[Caution] 权限缓存必须支持主动失效(用户角色变更、角色权限变更时清除对应用户缓存),否则权限变更不生效。
[Requirement] 以下操作必须记录审计日志:
- 角色创建、修改、删除
- 角色分配 / 回收权限
- 用户分配 / 回收角色
- 权限点变更
- 越权访问尝试
| 字段 | 说明 |
|---|---|
| operator_id | 操作者 ID |
| action | 操作类型(create_role / assign_permission / …) |
| target | 操作对象(角色 ID、用户 ID) |
| before | 变更前内容(JSON) |
| after | 变更后内容(JSON) |
| ip | 操作者 IP |
| created_at | 操作时间 |
def audit_permission_change(operator, action, target, before, after):
AuditLog.create(
operator_id=operator.id,
action=action,
target=target,
before=before,
after=after,
ip=operator.current_ip,
)[Standard] 对越权访问(PermissionDenied)做统计告警,同一用户短时间内多次越权可能是攻击行为。
[Default] 每季度做一次权限复核:检查角色权限是否合理、是否有离职人员仍有账号、是否有长期未使用的角色。
| 反模式 | 说明 | 风险 |
|---|---|---|
| 前端控制权限 | 只在前端隐藏按钮/菜单,后端不校验 | 攻击者直接调接口,权限形同虚设 |
| 硬编码权限判断 | 在业务代码中 if user.role == "admin" | 角色变更需改代码,无法动态配置 |
| 信任客户端传的用户 ID | 用前端传的 user_id 查数据 | 水平越权(必须从 Token 取) |
| 超级管理员直通 | if user.is_superuser: return 跳过所有校验 | 账号泄露即全量数据泄露 |
| 权限缓存不失效 | 角色变更后缓存不清 | 权限变更不生效,存在窗口期 |
| SQL 拼接权限条件 | f"WHERE dept_id = {user.dept_id}" | SQL 注入(应用 ORM 参数化) |
| 返回后过滤数据 | 查出全部再在内存过滤无权限数据 | 性能差,且分页/统计数据不准 |
| 一人一角 | 每个用户建一个专属角色 | 角色爆炸,无法管理 |
| 权限点粒度过粗 | 只有”管理”和”查看”两种 | 无法细粒度控制,过度授权 |
| 不审计权限变更 | 角色权限修改不留痕 | 无法追溯谁在何时改了权限 |