权限系统落地实战(下):RBAC五张表、按钮级权限、多租户隔离与PBAC策略引擎

上篇(权限模型全解析(上):别再写 if user.role == "admin" 了,ACL、RBAC四层与ABAC一次讲透)讲透了权限模型的理论:从 ACL 到 RBAC0/1/2/3,再到 ABAC 属性鉴权。但懂模型不等于能落地——表怎么设计?按钮怎么控?多租户怎么隔离?缓存怎么失效? 本文是下篇,全部用 Python(FastAPI 为主、Django 对比)给出工程级答案。

标签:RBAC、ABAC、ACL、权限模型、按钮级权限、数据级权限、多租户、PBAC

注:本文为下篇,建议先读上篇建立模型概念。


九、核心四:RBAC 工程实现

9.1 标准表结构

经典的 RBAC 五张表(+ 互斥表):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
-- 用户表
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY,
username VARCHAR(64),
dept_id BIGINT COMMENT '部门,用于数据权限',
tenant_id BIGINT COMMENT '租户,多租户隔离'
);

-- 角色表
CREATE TABLE sys_role (
id BIGINT PRIMARY KEY,
role_code VARCHAR(64) COMMENT '角色编码,如 admin/operator',
role_name VARCHAR(64),
parent_id BIGINT COMMENT '父角色,支持继承(RBAC1)',
status TINYINT COMMENT '0禁用 1启用',
tenant_id BIGINT COMMENT '租户'
);

-- 权限表
CREATE TABLE sys_permission (
id BIGINT PRIMARY KEY,
permission_code VARCHAR(128) COMMENT '权限编码,如 user:delete',
permission_name VARCHAR(64),
type TINYINT COMMENT '1菜单 2按钮 3接口',
parent_id BIGINT COMMENT '父权限,菜单树结构'
);

-- 用户-角色关联(多对多)
CREATE TABLE sys_user_role (
user_id BIGINT,
role_id BIGINT,
PRIMARY KEY (user_id, role_id)
);

-- 角色-权限关联(多对多)
CREATE TABLE sys_role_permission (
role_id BIGINT,
permission_id BIGINT,
PRIMARY KEY (role_id, permission_id)
);

-- 角色互斥表(RBAC2 职责分离)
CREATE TABLE sys_role_conflict (
role_id BIGINT,
conflict_role_id BIGINT,
PRIMARY KEY (role_id, conflict_role_id)
);

关键设计:

  1. 用户-角色、角色-权限都是多对多:一个用户可多角色,一个角色可多权限
  2. 角色 parent_id 支持 RBAC1 继承:子角色自动继承父角色权限
  3. 权限 type 区分菜单/按钮/接口:统一表管理,便于前端菜单和后端接口共用
  4. tenant_id 多租户隔离:每条数据带租户标识
  5. sys_role_conflict 角色互斥:RBAC2 职责分离

为什么不用「用户直接挂权限点」的两表方案?回看上篇 5.2:500 人 × 50 个权限点 = 25000 行手工维护记录;五张表方案里,人员变动只动 sys_user_role 一张表的几行。多出来的两张关联表,换来的是维护成本下降两个数量级。

9.2 权限编码规范

权限编码建议用 模块:资源:动作 三段式:

1
2
3
4
5
user:view       — 查看用户
user:create — 创建用户
user:delete — 删除用户
order:approve — 审批订单
finance:report — 财务报表

规范的好处:

  • 可读性强,一眼看出权限含义
  • 便于通配匹配(如 user:* 表示所有用户操作)
  • 便于按模块分配
1
2
3
4
5
def match(code: str, pattern: str) -> bool:
"""通配匹配:user:* 命中 user:delete"""
if pattern.endswith(":*"):
return code.startswith(pattern[:-1])
return code == pattern

9.3 权限加载服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import json
from datetime import timedelta

import redis.asyncio as aioredis

CACHE_TTL = timedelta(hours=1)

class PermissionService:
def __init__(self, db: AsyncSession, redis: aioredis.Redis):
self.db = db
self.redis = redis

async def load_permissions(self, user_id: int) -> set[str]:
"""加载用户所有权限编码(含继承的父角色权限)"""
# 1. 先查缓存
key = f"perm:user:{user_id}"
cached = await self.redis.get(key)
if cached:
return set(json.loads(cached))

# 2. 查用户所有角色
role_ids = await self.db.scalars(
select(UserRole.role_id).where(UserRole.user_id == user_id)
)
role_ids = list(role_ids)
if not role_ids:
return set()

# 3. 含继承:递归把父角色也加进来(RBAC1)
all_role_ids = await self.expand_with_parents(role_ids)

# 4. 查角色对应的所有权限
perm_ids = await self.db.scalars(
select(RolePermission.permission_id).where(
RolePermission.role_id.in_(all_role_ids)
)
)
perm_ids = list(perm_ids)
if not perm_ids:
return set()

# 5. 返回权限编码集合
perms = set(await self.db.scalars(
select(Permission.permission_code).where(Permission.id.in_(perm_ids))
))
await self.redis.set(key, json.dumps(list(perms)), ex=CACHE_TTL)
return perms

async def expand_with_parents(self, role_ids: list[int]) -> set[int]:
"""递归展开父角色(RBAC1 继承)"""
all_ids: set[int] = set(role_ids)
for role_id in role_ids:
parent_id = await self.find_parent(role_id)
while parent_id is not None and parent_id not in all_ids:
all_ids.add(parent_id)
parent_id = await self.find_parent(parent_id)
return all_ids

async def evict(self, user_id: int) -> None:
"""权限变更时清除该用户缓存"""
await self.redis.delete(f"perm:user:{user_id}")

async def evict_by_role(self, role_id: int) -> None:
"""角色权限变更时,清除所有持有该角色的用户缓存"""
user_ids = await self.db.scalars(
select(UserRole.user_id).where(UserRole.role_id == role_id)
)
for uid in user_ids:
await self.evict(uid)

Django 对比:Django 自带 user.get_all_permissions() 走的是 Permission 表 + _perm_cache(进程内缓存)。上表方案相当于把 Django 内置机制换成了「Redis 集中式缓存 + 自定义角色体系」,适合需要自定义角色/租户的场景。

9.4 权限校验:装饰器 + 依赖注入

对应 Java 的「注解 + AOP」,Python 的等价物是装饰器,FastAPI 再配上 Depends:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from fastapi import Depends, HTTPException, status

def require_permission(*codes: str, logical: str = "AND"):
"""权限校验依赖:require_permission('order:approve') 或多权限组合"""
async def checker(user: User = Depends(current_user)) -> User:
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "未登录")
perms = await permission_service.load_permissions(user.id)
ok = (
all(c in perms for c in codes) if logical == "AND"
else any(c in perms for c in codes)
)
if not ok:
raise HTTPException(
status.HTTP_403_FORBIDDEN, f"无权限:{':'.join(codes)}"
)
return user
return checker

业务接口使用:

1
2
3
4
5
6
@app.delete("/user/{id}")
async def delete_user(
id: int,
_: User = Depends(require_permission("user:delete")),
):
await user_service.delete(id)

Django 对比:Django 内置等价物是 @permission_required("app.codename") 或 PermissionRequiredMixin,拿的是 Django 自带的权限点体系;要「模块:资源:动作」三段式编码就得自定义,写法与上面一致。

9.5 权限缓存失效策略

变更场景 失效操作
用户角色变更 清该用户缓存
角色权限变更 清所有持有该角色的用户缓存
用户登录/登出 重新加载

最易漏的:角色权限变更时,不能只清单个用户——要找到所有持有该角色的用户,全部失效。改了角色权限却没清缓存,导致权限变更不生效,这是常见 bug。


十、核心五:按钮级与接口级权限

前端「按钮可见性」和后端「接口可达性」是两层,缺一不可。

10.1 前端按钮级

前端根据用户权限编码,控制按钮显示:

1
2
3
4
5
// 用户登录后,后端返回权限编码列表
// userPermissions = ["user:view", "user:edit"]; // 没有 user:delete

// 按钮上声明所需权限(Vue)
<button v-if="hasPermission('user:delete')">删除</button> // 不显示

10.2 后端接口级

1
2
3
4
5
6
@app.delete("/user/{id}")
async def delete_user(
id: int,
_: User = Depends(require_permission("user:delete")),
):
...

关键:前端按钮隐藏只是体验优化(不让用户点无效按钮),后端接口必须独立校验。只隐藏前端按钮、不校验后端接口,攻击者直接 curl 构造请求就能绕过。

铁律:前端权限管「显示」,后端权限管「安全」,两者都要做,以后端为准。

10.3 菜单级权限

菜单权限是按钮权限的延伸——根据用户权限决定显示哪些菜单项。后端通常返回菜单树(已按权限过滤),前端直接渲染:

1
2
3
4
5
@app.get("/menus")
async def my_menus(user: User = Depends(current_user)):
"""返回当前用户的菜单树(已过滤无权限项)"""
perms = await permission_service.load_permissions(user.id)
return menu_service.build_menu_tree(perms) # 只返回有权限的菜单

菜单树在 sys_permission 表里用 type=1 + parent_id 表达,过滤逻辑就是「节点及其子树中任一节点的权限编码命中 perms」。


十一、核心六:多租户权限隔离

SaaS 场景下,多租户权限隔离是硬需求——租户 A 的用户绝不能看到租户 B 的数据。

11.1 三种隔离方案

方案 做法 隔离强度 成本 适用
独立数据库 每租户一个库 最强 最高 大客户、强合规
共享库独立 schema 每租户一个 schema 中 中 中型租户
共享库共享表 + tenant_id 所有数据加 tenant_id 弱(靠代码保证) 最低 小租户、量大

11.2 共享表方案的权限设计

最常用的是共享表 + tenant_id,权限模型要全程带 tenant_id:

1
2
3
4
5
6
7
8
9
-- 所有表加 tenant_id
CREATE TABLE sys_user (
id BIGINT,
tenant_id BIGINT, -- 关键
username VARCHAR(64)
);

-- 所有查询必须带 tenant_id 条件
SELECT * FROM sys_user WHERE tenant_id = ? AND ...;

用 SQLAlchemy 事件自动注入 tenant_id(对应 Java 的 MyBatis 拦截器):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from contextvars import ContextVar

tenant_ctx: ContextVar[int | None] = ContextVar("tenant_id", default=None)

@event.listens_for(Session, "before_cursor_execute")
def inject_tenant_id(conn, cursor, statement, parameters, context, executemany):
"""拦截所有 SQL,自动加 tenant_id 条件"""
tenant_id = tenant_ctx.get()
if tenant_id is None:
return # 超级管理员等场景显式置空
# 生产环境请用参数化注入,这里简化演示
if "WHERE" in statement.upper():
statement += f" AND tenant_id = {tenant_id}"
else:
statement += f" WHERE tenant_id = {tenant_id}"
return statement, parameters

tenant_ctx 由中间件在每个请求开始时从 JWT 中解析并写入,请求结束时清理:

1
2
3
4
5
6
7
@app.middleware("http")
async def tenant_middleware(request: Request, call_next):
token = tenant_ctx.set(get_tenant_from_token(request))
try:
return await call_next(request)
finally:
tenant_ctx.reset(token)

权限规则:

  1. 用户-角色、角色-权限关联表都带 tenant_id
  2. 角色、权限在租户内定义,租户间不通用
  3. 查询时全程带 tenant_id,拦截器统一注入

11.3 最易出的 bug

租户越权:查询时漏带 tenant_id 条件,导致租户 A 查到租户 B 的数据。这是 SaaS 最严重的安全事故。

防护:

  • 用拦截器统一注入 tenant_id,业务代码不手写
  • 关键接口加测试:用租户 A 的 token 访问租户 B 的资源 id,必须返回 404 而非 200
  • 定期扫描代码,确保没有裸 SQL 漏带 tenant_id

11.4 超级管理员的特殊处理

超级管理员(平台运维)需要跨租户操作,不能用 tenant_id 隔离。通常做法:

  • 超管走单独的「平台管理后台」,与租户后台物理分离
  • 超管接口显式声明 @ignore_tenant(把 tenant_ctx 置 None),跳过租户隔离
  • 超管操作全程审计,避免滥用

十二、核心七:PBAC 策略引擎

复杂合规场景,RBAC + ABAC 仍不够,需要 PBAC 把规则集中到策略引擎。

12.1 为什么需要 PBAC

场景 痛点
权限规则极复杂 时间、地点、密级、审批流、上下文组合
规则散落各处 装饰器 + 代码 + 配置,难维护审计
规则频繁变更 改规则要重新发版
多系统权限统一 各系统权限规则不一致

12.2 PBAC 架构

graph LR
    A["业务系统<br/>FastAPI / Django"] -->|"构造上下文<br/>subject/action/resource/env"| B["策略引擎<br/>OPA / Cedar"]
    B -->|"allow / deny"| A
    C[("策略库<br/>Rego / 策略文件")] --> B
    D["审计日志"] --> B
    style A fill:#e1f5fe
    style B fill:#fff3e0
    style C fill:#f3e5f5

流程:

  1. 业务系统收到请求,构造「主体/客体/动作/环境」上下文
  2. 调用策略引擎(如 OPA)做决策
  3. 策略引擎按策略集计算,返回允许/拒绝
  4. 业务系统根据决策执行或拒绝

12.3 策略示例(OPA Rego 语法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 允许查看本部门订单
allow {
input.action == "view"
input.resource.type == "order"
input.subject.dept == input.resource.dept
}

# 工作时间才能访问
allow {
input.environment.hour >= 9
input.environment.hour <= 18
}

# 密级匹配
allow {
input.subject.clearance >= input.resource.level
}

Python 侧调用 OPA 只是一次 HTTP 请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import httpx

async def opa_allow(context: dict) -> bool:
"""调用 OPA 策略引擎决策"""
async with httpx.AsyncClient() as client:
resp = await client.post(
"http://opa:8181/v1/data/authz/allow",
json={"input": context},
)
return resp.json().get("result", False)

# 业务侧
if not await opa_allow({
"subject": {"dept": user.dept_id, "clearance": user.clearance},
"action": "view",
"resource": {"type": "order", "dept": order.dept_id, "level": order.level},
"environment": {"hour": datetime.now().hour},
}):
raise HTTPException(403, "策略拒绝")

优势:规则与业务代码解耦,规则变更不重新发版,集中审计。

Python 生态里也有轻量替代:Casbin(pip install casbin)用模型文件 + 策略文件表达 RBAC/ABAC,不需要独立部署 OPA 服务,中小场景更省事。上篇「对比专栏」已给出 Casbin 表达 ACL/RBAC/ABAC 的完整示例,这里补充它与 OPA 的工程选型对比:

维度 Casbin(pycasbin) OPA
形态 进程内库,pip install 即用 独立服务(Rego 语言)
延迟 函数调用级(微秒) HTTP 调用级(毫秒)
策略存放 policy 文件 / 数据库表 Rego 文件 / bundle
热更新 e.load_policy() 或 watcher bundle 推送
跨系统统一 弱(各服务各嵌一份) 强(多系统问同一引擎)
学习成本 低(INI 模型 + CSV 策略) 中(需学 Rego)

经验法则:单体或少数几个服务 → Casbin;多系统共用一套策略、强审计合规 → OPA。也可以 Casbin 起步,规模化后把策略迁到 OPA——matcher 公式和 Rego 规则的语义是同构的,迁移主要是翻译而非重构。

12.4 何时上 PBAC

场景 用 PBAC?
简单后台 RBAC ❌ 用装饰器 + Depends 够
中型 + 数据级权限 ❌ RBAC + ABAC 注解够
复杂合规、多系统统一 ✅ 上 PBAC
金融、政企、审计严 ✅ 上 PBAC

PBAC 不是替代 RBAC/ABAC,是用策略引擎统一承载这些模型。简单系统用 RBAC/ABAC 够了,复杂合规场景才上 PBAC。


十三、分场景讨论

13.1 简单后台

场景:用户少、角色固定(管理员/普通用户)。

方案:RBAC0 基础三层,甚至简化到「用户-角色」两表(角色对应固定权限点)。不必上继承、约束、ABAC。

13.2 中型企业系统

场景:部门多、角色多、有数据权限需求。

方案:RBAC3(继承 + 职责分离)+ 数据级 ABAC(@data_scope)。权限缓存 + 角色权限变更失效。

13.3 SaaS 多租户

场景:多租户,每租户独立角色体系。

方案:RBAC3 + 租户隔离(tenant_id 全程贯穿)+ 数据级 ABAC。租户内角色继承,租户间完全隔离。

13.4 复杂合规场景

场景:金融、政企,权限规则极复杂(时间、地点、密级、审批流)。

方案:PBAC 策略引擎——把权限规则抽到独立策略服务(如 OPA、Casbin),用规则引擎统一决策,业务系统调策略引擎。权限规则与业务代码彻底解耦。

13.5 开放平台

场景:对外开放 API,合作伙伴接入。

方案:RBAC(应用维度角色)+ OAuth2 scope(接口级授权)+ ABAC(数据级)。


十四、原理剖析:FastAPI 与 Django 的权限校验链

14.1 FastAPI 的依赖注入校验链

FastAPI 没有 Spring Security 那样的过滤器链,它的「链」是依赖注入的解析顺序:

1
2
3
4
5
6
7
请求 → ASGI Middleware(CORS / tenant_ctx 写入)
→ 路由匹配
→ 解析 Depends 依赖(递归展开)
├─ current_user:OAuth2PasswordBearer → JWT 解析 → user
└─ require_permission(...):load_permissions → 403 判断
→ 业务函数执行
→ 返回响应

关键组件:

组件 职责
Depends 依赖声明,等价于 AOP 的「切点」
OAuth2PasswordBearer 从 Authorization 头取 token
current_user 认证依赖,产出 User 对象
require_permission(...) 授权依赖,校验权限点
HTTPException(401/403) 认证失败 / 授权失败

依赖可以嵌套(require_permission 内部又 Depends(current_user)),框架会自动去重、按拓扑顺序执行——这就是 FastAPI 版的「权限校验链」。

14.2 Django 的权限校验链

Django 的权限校验走「中间件 + 后端(backend)」:

1
2
3
4
5
6
7
请求 → AuthenticationMiddleware(把 user 挂到 request)
→ View(PermissionRequiredMixin / @permission_required)
→ user.has_perm("app.codename")
→ ModelBackend.has_perm
→ 查 Permission / Group 表
→ 结果存入 user._perm_cache
→ 业务逻辑

关键类:

组件 职责
AuthenticationMiddleware 请求级注入 request.user
ModelBackend 默认权限后端,查 Permission 表
user.has_perm() 授权入口
_perm_cache 用户级权限缓存(进程内)

两者对比:FastAPI 是「依赖在路由上显式声明,校验发生在进入函数前」;Django 是「Mixin/装饰器声明,中间件负责把用户准备好」。思想一致:认证产出 user,授权消费 user。

14.3 权限缓存的热点问题

权限缓存有个隐藏问题——角色权限变更时的「批量失效」:

1
角色权限变更 → 查所有持有该角色的用户 → 逐个清缓存

若一个角色被 10 万用户持有,变更一次要清 10 万个缓存 key,可能造成 Redis 压力峰值。

优化方案:

  1. 版本号机制:角色带版本号,缓存 key 带版本号(perm:user:{uid}:{roleVersion}),角色变更时版本号 +1,老缓存自然过期,无需批量清
  2. 消息队列异步清:把批量清拆成消息,异步消费,避免同步阻塞
  3. 短 TTL:缓存 TTL 设短(如 5 分钟),容忍短暂不一致
1
2
3
4
5
async def load_permissions(self, user_id: int) -> set[str]:
role_version = await self.get_role_version() # 全局角色版本
key = f"perm:user:{user_id}:{role_version}"
# 缓存 key 带版本,角色变更时 version+1,老缓存自动失效
...

版本号机制的精髓:不去删旧缓存,而是让旧缓存「作废」——key 变了,旧 key 无人读取,靠 TTL 自然蒸发。把 O(用户数) 的删除操作变成 O(1) 的自增。


十五、单元测试(pytest)

15.1 权限校验测试

1
2
3
4
5
6
7
8
9
10
async def test_user_has_permission(permission_service):
# 用户 1 关联运营角色,运营角色有 order:view
perms = await permission_service.load_permissions(1)
assert "order:view" in perms
assert "user:delete" not in perms

async def test_user_without_role_has_no_permission(permission_service):
# 无角色的用户
perms = await permission_service.load_permissions(999)
assert perms == set()

15.2 角色继承测试(RBAC1)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async def test_role_inheritance(permission_service):
# 运营组长继承运营专员
# 运营专员有 order:view, order:edit
# 运营组长额外有 order:approve
# 用户 A 是运营组长
perms = await permission_service.load_permissions(user_a_id)
# 应包含父角色权限 + 自己的权限
assert "order:view" in perms # 继承自运营专员
assert "order:edit" in perms # 继承自运营专员
assert "order:approve" in perms # 运营组长自己的

async def test_role_inheritance_no_cycle(role_repo):
# 构造环:A→B→A,不应死循环
role_repo.set_parent(role_a, role_b)
role_repo.set_parent(role_b, role_a)
result = await permission_service.expand_with_parents([role_a])
assert role_b in result # 正常展开,且终止

15.3 职责分离测试(RBAC2)

1
2
3
4
5
6
7
8
9
10
11
12
def test_static_sod_conflict(assign_role):
# 用户已持有「制单」角色,再分配「审核」角色应被拒
assign_role(user_id, role_maker_id) # 先分配制单角色
with pytest.raises(RoleConflictError): # 审核角色应冲突
assign_role(user_id, role_checker_id)

def test_dynamic_sod_in_business(approve_order):
# 制单人不能审核自己的单
order = create_order(user_a_id) # A 制单
with pytest.raises(BusinessError):
approve_order(order.id, user_a_id) # A 不能审核自己的
approve_order(order.id, user_b_id) # B 可以审核

15.4 数据级权限测试

1
2
3
4
5
6
7
8
9
10
11
async def test_data_scope_self(client, user_a):
# 用户 A 只能看自己创建的订单
client.login_as(user_a)
orders = await (await client.get("/order/list")).json()
assert all(o["create_by"] == user_a.uid for o in orders)

async def test_data_scope_dept(client, user_c):
# 用户 C 是本部门范围
client.login_as(user_c)
orders = await (await client.get("/order/list")).json()
assert all(o["dept_id"] == user_c.dept_id for o in orders)

15.5 多租户隔离测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async def test_tenant_isolation(client, tenant_a, tenant_b):
# 租户 A 的用户不能看到租户 B 的数据
order_b = create_order_in_tenant(tenant_b)
client.login_as_tenant(tenant_a)
# 用租户 A 身份访问租户 B 的订单,应 404
resp = await client.get(f"/order/{order_b.id}")
assert resp.status_code == 404

async def test_cross_tenant_returns_404_not_403(client, tenant_a, tenant_b):
"""关键:跨租户访问返回 404 而非 403,不泄露资源存在"""
order_b = create_order_in_tenant(tenant_b)
client.login_as_tenant(tenant_a)
resp = await client.get(f"/order/{order_b.id}")
assert resp.status_code == 404, (
"应返回 404 而非 403,避免泄露资源存在"
)

十六、常见误区

16.1 误区:前端按钮隐藏就够了

误区:前端把无权按钮隐藏,用户点不到,安全了。

正解:前端隐藏只是体验优化,攻击者可绕过前端直接 curl 构造请求。后端接口必须独立校验权限。

16.2 误区:角色越细越好

误区:每个岗位一个角色,角色越细越灵活。

正解:角色太细会导致角色爆炸(几十上百个角色),管理反而困难。角色应对应「岗位或职责」,粒度适中,配合 ABAC 数据权限表达更细的需求。

16.3 误区:权限缓存永不失效

误区:权限缓存 TTL 设很长,减少查库。

正解:权限变更后缓存不失效,会导致权限变更不生效(撤销了权限但用户还能用)。必须有失效机制,角色权限变更要批量清相关用户缓存(或用版本号机制)。

16.4 误区:跨租户返回 403

误区:租户 A 用户访问租户 B 资源,返回 403(无权限)。

正解:应返回 404(不存在)。403 会泄露「资源存在但无权访问」,攻击者据此判断资源 id 是否有效;404 不泄露存在性。

16.5 误区:权限点硬编码在代码

误区:权限编码散落在各接口的装饰器里,没有统一管理。

正解:权限点应在 sys_permission 表统一管理,接口装饰器引用编码,便于审计和前端动态渲染。


十七、对比分析

17.1 权限模型对比

维度 ACL RBAC ABAC PBAC
核心思想 资源列清单 用户-角色-权限 属性动态判断 策略引擎决策
适合 文件系统 业务系统 数据级权限 复杂合规
灵活性 低 中 高 最高
实现复杂度 低 中 中 高
维护成本 高(资源多) 中 中 低(集中)
性能 高 高 中(动态判断) 低(调引擎)

17.2 数据级权限方案对比

方案 实现 优缺点
手写 SQL 条件 每个查询手动加 where 灵活但易漏,维护差
装饰器 + 事件注入 @data_scope + SQLAlchemy 事件 统一,业务无感知
视图 数据库视图过滤 数据库层,难动态
行级安全(RLS) PostgreSQL RLS 数据库原生,但锁死数据库

17.3 多租户隔离方案对比

方案 隔离强度 成本 灵活性
独立库 最强 最高 低(迁移难)
独立 schema 中 中 中
共享表 + tenant_id 弱(靠代码) 最低 高

十八、最佳实践

18.1 模型选型

  1. 默认 RBAC0:80% 系统够用,简单成熟
  2. 角色继承用 RBAC1:避免重复定义,单继承够用
  3. 财务/审批用 RBAC2:职责分离防舞弊
  4. 数据级权限用 ABAC:装饰器 + 事件注入
  5. 复杂合规才上 PBAC:策略引擎集中决策

18.2 工程实现

  1. 统一表结构:五张标准表 + 互斥表
  2. 权限编码规范:模块:资源:动作 三段式
  3. 装饰器 + Depends 校验:业务无感知
  4. 缓存 + 失效机制:版本号机制优化批量失效
  5. 事件注入条件:数据权限、租户隔离自动注入

18.3 安全要点

  1. 后端必校验:不信任前端
  2. 跨租户返 404:不泄露资源存在
  3. 权限缓存要失效:变更即清,避免权限不生效
  4. 超管分离:平台管理与租户后台物理分离
  5. 审计日志:权限变更、敏感操作全程审计

十九、总结——核心要点速查表

要点 说明
权限三元组 主体-客体-动作
RBAC0 基础三层 用户-角色-权限多对多
RBAC1 角色继承 parent_id 递归合并父角色权限
RBAC2 职责分离 静态(分配时拒)/动态(执行时拒)
RBAC3 = RBAC1 + RBAC2 企业级标配
ABAC 四属性 主体/客体/操作/环境
数据级权限 装饰器 + SQLAlchemy 事件注入 where
前后端权限 前端管显示,后端管安全,以后端为准
多租户隔离 tenant_id 全程贯穿,事件注入
跨租户返 404 不泄露资源存在
权限缓存 角色权限变更批量清,版本号机制优化
PBAC 策略引擎 复杂合规场景,规则与代码解耦

二十、面试自测与答案

# 题目 对应章节
Q1 ACL 和 RBAC 的区别?为什么 RBAC 更适合企业系统? 上篇 二、六
Q2 RBAC0/1/2/3 各是什么?角色继承解决什么问题? 上篇 七
Q3 什么是职责分离(SoD)?静态和动态职责分离的区别? 上篇 7.3
Q4 RBAC 表达不了的什么场景要用 ABAC?举三个例子。 上篇 八
Q5 数据级权限怎么实现?同一个接口不同人看不同数据的原理? 上篇 8.4
Q6 前端按钮级权限和后端接口级权限为什么要都做?只做前端行不行? 十
Q7 权限缓存怎么做?角色权限变更时如何失效?版本号机制是什么? 9.5、14.3
Q8 多租户 SaaS 的权限隔离有哪几种方案?共享表方案最易出什么 bug?跨租户应返回什么? 十一
Q9 为什么「制单」和「审核」角色不能同一人持有?这属于 RBAC 的哪一层? 上篇 7.3
Q10 复杂合规场景为什么要上 PBAC 策略引擎?与 RBAC/ABAC 的关系? 十二
Q11 权限编码为什么用「模块:资源:动作」三段式?有什么好处? 9.2
Q12 角色权限变更时批量清缓存有什么性能问题?如何优化? 14.3

参考资料

  1. NIST RBAC 标准 (INCITS 359-2004) — RBAC 四层模型定义
  2. OWASP Access Control Cheat Sheet — 访问控制最佳实践
  3. FastAPI - OAuth2 with Password and Bearer — FastAPI 认证授权
  4. Django - Permissions — Django 内置权限
  5. Casbin — 多语言策略库(含 Python 绑定)
  6. OPA (Open Policy Agent) / Cedar / Drools — 策略引擎