权限模型全解析(上):别再写 if user.role == "admin" 了,ACL、RBAC四层与ABAC一次讲透

小 A 给后台系统做权限管理,第一版直接用「用户—权限」两张表:用户表里存个 role 字段,代码里到处写 if user.role == "admin"。上线三个月,角色越加越多:运营、客服、财务、外协……每加一个角色,就得改一堆 if 判断。更糟的是「运营组长能看本组数据、运营专员只能看自己数据」这种需求,role 字段根本表达不了。

小 A 这才意识到,权限不是「加个字段」的事,而是一套要专门设计的模型——RBAC、ABAC 这些名词背后,是几十年沉淀下来的权限管理方法论。

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

注:本文为上篇,下篇讲工程落地(五张表、按钮级权限、多租户隔离、PBAC 策略引擎)。全文代码以 FastAPI + SQLAlchemy 为主,关键处附 Django 写法对比。


一、引言:小 A 的三次翻车

第一版:硬编码角色判断

1
2
3
4
# 第一版:角色写死在代码里
if user.role == "admin":
# 执行管理操作
...

问题:角色一多就要改代码,且「运营组长能看本组数据」这种需求根本表达不了。上线三个月,每次加角色都要发版,开发疲于奔命。

第二版:用户—权限点直接关联

1
2
3
4
# 第二版:每个用户手动挂几十个权限点
if "user:delete" in user.permissions:
# 删除用户
...

问题:每个新员工入职,要手动勾选几十个权限点;员工换岗,要逐个调整。维护成本爆炸。有一次管理员给新员工漏勾了关键权限,导致新人三天无法工作——哈哈,就是这么皮。

第三版:引入角色(RBAC)

把权限点打包成角色,用户关联角色而非直接关联权限点。新员工选角色即可,换岗换角色。这是大多数系统的标配。

但很快又遇到新问题:

  • 「财务只能看自己部门的财务数据」——权限点 finance:view 不够,还要限定「本部门」
  • 「外协人员只能在工作时间访问」——还要限定「时间」
  • 「敏感操作需要二次审批」——权限不只是「能不能」,还有「在什么条件下能」
  • 「财务制单和审核不能是同一人」——要表达角色互斥

这些场景,单纯的角色模型表达不了,需要属性鉴权(ABAC)和角色约束(RBAC2)。

小 A 这才明白:权限不是一步到位的,而是一套从简单到复杂的模型演进。本文就把这套演进讲透——从 ACL 到 RBAC0/1/2/3,再到 ABAC。


二、概念定义:先把话说清楚

2.1 什么是权限模型

定义:权限模型是描述「主体(Subject)对客体(Object)执行动作(Action)」的授权关系的抽象框架,定义了权限如何组织、分配、校验。

通俗讲:权限模型回答的就是一句话——「谁能对什么东西做什么」。它规定了「谁」(用户/角色)、「什么东西」(资源)、「做什么」(读/写/删)这三者怎么关联、怎么判断。

你可以把权限模型想象成餐厅的点菜规则:谁(顾客)可以点(动作)什么菜(资源),由菜单和套餐结构(权限模型)决定,而不是每次点菜都把厨师叫出来问一遍。

2.2 三个核心要素

要素 说明 例子
主体(Subject) 发起操作的人/服务 用户、服务账号
客体/资源(Object/Resource) 被操作的对象 订单、用户、菜单、按钮
动作(Action) 对资源的操作 查看、新增、修改、删除

权限的本质是一句三元组:主体 是否能 对客体 执行 动作。

在 Python 代码里,这个三元组最直白的表达就是一个函数:

1
2
3
def can(user: User, action: str, resource: str) -> bool:
"""权限三元组:谁(subject) 能对 什么(object) 做什么(action)"""
return (user, action, resource) in load_grants()

后面所有模型,本质上都是在回答「这个函数怎么实现才优雅、可维护」。

2.3 主流权限模型一览

模型 全称 核心思想 适用
ACL Access Control List 直接给资源列「谁能访问」清单 文件系统、简单资源
RBAC Role-Based Access Control 用户关联角色,角色关联权限 绝大多数业务系统
ABAC Attribute-Based Access Control 基于主体/客体/环境属性动态判断 复杂条件、数据级权限
PBAC Policy-Based Access Control 基于策略引擎统一决策 复杂企业、合规场景

2.4 认证 vs 授权

权限模型管的是「授权」(AuthZ),与「认证」(AuthN)不同:

维度 认证(AuthN) 授权(AuthZ)
解决问题 你是谁 你能做什么
典型问题 登录、身份验证 权限校验、资源访问控制
常见手段 账号密码、SSO、OIDC、JWT RBAC、ABAC、ACL

关系:认证是授权的前提——先知道「你是谁」,才能判断「你能做什么」。

用 FastAPI 的话说,认证是「拿到 token 解析出 user」,授权是「拿着 user 去查他能不能干这件事」:

1
2
3
4
5
6
7
8
9
10
11
12
# 认证:你是谁?—— 从 token 解出当前用户
async def current_user(token: str = Depends(oauth2_scheme)) -> User:
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
return await user_repo.get(payload["sub"])

# 授权:你能做什么?—— 校验权限点
def require_permission(code: str):
async def checker(user: User = Depends(current_user)):
if code not in await permission_service.load(user.id):
raise HTTPException(status_code=403, detail=f"无权限:{code}")
return user
return checker

本文聚焦授权(权限模型),认证部分可参考本博客的 JWT / OAuth2 相关文章。


三、阐述背景:权限模型是怎么一步步演进来的

每一种新模型的诞生,都是前一种模型遇到了瓶颈。

3.1 演进时间线

graph LR
    A["ACL<br/>访问控制列表"] --> B["RBAC0<br/>基础三层"]
    B --> C["RBAC1<br/>角色继承"]
    C --> D["RBAC2<br/>角色约束"]
    D --> E["RBAC3<br/>继承+约束"]
    E --> F["ABAC<br/>属性鉴权"]
    F --> G["PBAC<br/>策略引擎"]
    style A fill:#ffcdd2,stroke:#c62828
    style B fill:#c8e6c9,stroke:#2e7d32
    style C fill:#c8e6c9,stroke:#2e7d32
    style D fill:#c8e6c9,stroke:#2e7d32
    style E fill:#c8e6c9,stroke:#2e7d32
    style F fill:#bbdefb,stroke:#1565c0
    style G fill:#ffe0b2,stroke:#ef6c00

3.2 每一代解决的痛点

演进 解决的痛点 例子
ACL → RBAC ACL 用户直接关联资源,人员一多就难维护 文件系统给每个用户设权限 vs 用角色批量授权
RBAC0 → RBAC1 角色不能继承,重复定义 「运营」和「运营组长」权限大量重叠,需继承
RBAC1 → RBAC2 无法表达职责分离 财务「制单」和「审核」不能是同一人
RBAC → ABAC 无法表达条件性权限 「只能看本部门数据」、「只能工作时间访问」
ABAC → PBAC 规则散落各处难维护 规则集中到策略引擎,统一管理审计

3.3 为什么 RBAC 是主流

绝大多数业务系统选 RBAC,因为:

  1. 契合组织结构:企业本身就是按角色(岗位)划分职责的,RBAC 映射自然
  2. 批量管理高效:角色对应岗位,人员变动只换角色,权限自动调整
  3. 简单够用:80% 的权限需求用「有没有某权限点」就能表达
  4. 成熟生态:Python 这边有 django-guardian、Casbin(pycasbin)、FastAPI 生态的各类 require 依赖,都内置 RBAC 支持

而 ABAC、PBAC 是在 RBAC 基础上的补充——RBAC 管「能不能调这个接口」,ABAC 管「能看哪些数据」,PBAC 管「复杂条件下统一决策」。

选型口诀:先 RBAC,数据范围不够上 ABAC,规则多到管不住上 PBAC。别一上来就搞策略引擎,那是杀鸡用牛刀。


四、类比:写字楼的门禁体系

把权限模型类比成写字楼的门禁管理:

现实世界 权限模型对应
给每个人单独配每扇门的钥匙 ACL(每人直接关联每扇门)
给员工发「部门工牌」,凭工牌进相应楼层 RBAC(角色 = 工牌)
经理工牌自动包含员工工牌的权限 RBAC1(角色继承)
财务室的「制单」卡和「审核」卡不能同人持有 RBAC2(职责分离约束)
进机房还要刷指纹 + 只能工作日进 ABAC(多重属性条件)
整套门禁规则在中央系统统一配置 PBAC(策略引擎)

几个关键点:

  • ACL 是「门找钥匙」:每扇门维护一张「谁能进」的清单,人一多清单爆炸
  • RBAC 是「人找工牌」:人持工牌,工牌对应能进的门,批量管理
  • ABAC 是「现场判断」:不只是看工牌,还看时间、地点、指纹等属性,动态决策
  • PBAC 是「中央调度」:所有门禁规则在中央系统配置,各门只管「问中央」

记不住的时候就想门禁:钥匙(ACL)→ 工牌(RBAC)→ 工牌+指纹+时间(ABAC)→ 中央大脑(PBAC)。


五、正反例子:眼见为实

5.1 反例:硬编码角色判断

1
2
3
4
5
6
7
# ❌ 反例:角色与代码耦合
def get_workspace(user: User) -> str:
if user.role == "admin":
return "manage"
elif user.role == "operator":
return "operate"
# 每加一个角色,都要回来改这段代码……

问题:

  1. 角色与代码耦合,新增角色要改代码、重新发版
  2. 一个用户只能一个角色(role 字段单值),无法支持「既是运营又是客服」
  3. 「运营组长能看本组数据」表达不了

5.2 反例:用户直接关联权限点

1
2
3
4
5
6
# ❌ 反例:每个用户手动勾选几十个权限点
user_permissions 表:user_id, permission
1, user:view
1, user:edit
1, order:view
...每个用户几十行

问题:新员工入职要手动勾几十个权限点,换岗要逐个调整,维护成本爆炸。且无法批量管理——所有运营人员都要加 order:view 时,要逐个加。

5.3 正例:RBAC 标准三层

graph LR
    U1["用户:小王"] --> R1["角色:运营专员"]
    U2["用户:小李"] --> R1
    U2 --> R2["角色:客服"]
    R1 --> P1["权限:order:view"]
    R1 --> P2["权限:order:edit"]
    R2 --> P3["权限:ticket:reply"]
    style U1 fill:#e1f5fe
    style U2 fill:#e1f5fe
    style R1 fill:#fff9c4
    style R2 fill:#fff9c4
    style P1 fill:#f3e5f5
    style P2 fill:#f3e5f5
    style P3 fill:#f3e5f5

用户关联角色,角色关联权限点。新员工选「运营角色」即获全部运营权限;所有运营要加新权限点,改角色即可,自动对所有运营生效。

用 Python 表达,就是三层多对多关系:

1
2
3
4
5
6
7
8
9
10
# 正例:用户 → 角色 → 权限,三层解耦
user.roles # 用户拥有哪些角色(多对多)
role.permissions # 角色拥有哪些权限点(多对多)

# 判断权限变成沿着链条走一遍
def has_permission(user: User, code: str) -> bool:
return any(
code in role.permissions
for role in user.roles
)

5.4 正例:ABAC 处理条件权限

1
2
3
4
5
6
# 「只能看本部门订单」——RBAC 表达不了,用 ABAC
@app.get("/orders")
async def list_orders(user: User = Depends(current_user)):
# 框架自动注入 "where dept_id = {user.dept_id}" 过滤
stmt = select(Order).where(Order.dept_id == user.dept_id)
return (await db.execute(stmt)).scalars().all()

权限不只是「能不能 order:view」,还要「只能看本部门的」——这要靠**属性(部门)**动态判断,是 ABAC 的范畴。

Django 对比:Django 自带的 Permission 只到「模型+动作」粒度,数据范围(本部门/本人)同样要自己扩展——通常在 QuerySet.for_user() 里做,思路完全一致。


六、核心一:ACL 详解

6.1 ACL 的原理

ACL(Access Control List,访问控制列表)是最朴素的权限模型:每个资源维护一张「谁能对它做什么」的清单。

1
2
3
4
5
资源:/file/报告.docx
ACL:
- user:1 → read, write
- user:2 → read
- group:finance → read, write

翻译成人话:这份报告,1 号用户能看能改,2 号用户只能看,财务组全员能看能改。

6.2 ACL 的适用与局限

优点 缺点
直观,易于理解 资源多时,每资源维护清单爆炸
细粒度,可精确到单个资源 人员变动要逐资源调整
适合资源少、相对固定 无法批量授权

适用场景:

  • 文件系统(Linux 文件 owner/group/other 权限)
  • 云资源 IAM(每个资源单独配策略)
  • 对象存储(OSS Bucket Policy)

这些「资源少、需精细到单个资源」的场景 ACL 更合适。

不适用:企业后台系统(用户多、资源多、人员常变动),ACL 维护成本爆炸,需用 RBAC。

一个直观的心算:公司有 500 个员工、2000 张报表。用 ACL,最多 100 万条记录,每天有人入职离职都要动一批;用 RBAC,500 个员工 × 平均 2 个角色 = 1000 条关联,报表只挂给角色,改一次全生效。这就是差距。

6.3 ACL 与 RBAC 的本质区别

graph TB
    subgraph ACL["ACL:资源找用户(resource → user)"]
        direction LR
        RA["报表A"] --> U1["小王"]
        RA --> U2["小李"]
        RB["报表B"] --> U2
        RC["报表C"] --> U3["小张"]
    end
    subgraph RBAC["RBAC:用户找资源(user → role → resource)"]
        direction LR
        U4["小王"] --> R1["运营角色"] --> RD["运营可见的报表"]
        U5["小李"] --> R1
        U5 --> R2["客服角色"] --> RE["客服可见的报表"]
    end
    style RA fill:#ffcdd2
    style RB fill:#ffcdd2
    style RC fill:#ffcdd2
    style R1 fill:#c8e6c9
    style R2 fill:#c8e6c9
    style RD fill:#bbdefb
    style RE fill:#bbdefb
  • ACL 是「资源找用户」——每个资源列能访问的用户
  • RBAC 是「用户找资源」——用户持角色,角色对应资源权限

视角转换的意义:企业人员变动频繁、角色相对固定,「用户找资源」更符合管理习惯。人走了换角色,资源侧一行不用改。


七、核心二:RBAC 四层模型详解

RBAC 标准分四层(NIST RBAC,INCITS 359-2004),逐层增强。

7.1 RBAC0:基础三层

用户—角色—权限三层多对多。这是最常用的形态,80% 的系统到这层就够。

核心数据结构:

  • 用户表(sys_user)
  • 角色表(sys_role)
  • 权限表(sys_permission)
  • 用户-角色关联表(sys_user_role)
  • 角色-权限关联表(sys_role_permission)
1
2
3
4
5
6
7
8
9
# RBAC0 的本质:两个多对多映射
user_roles: dict[int, set[int]] = {user_id: {role_ids...}}
role_perms: dict[int, set[int]] = {role_id: {perm_ids...}}

def has_perm(user_id: int, perm_id: int) -> bool:
return any(
perm_id in role_perms.get(r, set())
for r in user_roles.get(user_id, set())
)

下篇 9.1 节会给出完整的五张表 SQL,这里先建立直觉。

7.2 RBAC1:角色继承

子角色继承父角色的所有权限,可额外加权限。

价值:避免重复定义——「组长」自动包含「专员」权限,再加管理权限,不用把专员的权限点再抄一遍。

实现:角色表加 parent_id,加载权限时递归把父角色权限合并。

1
2
3
4
5
6
7
8
9
10
11
12
def expand_with_parents(role_ids: list[int], find_parent) -> set[int]:
"""递归展开父角色(RBAC1 继承)

find_parent(role_id) -> parent_id | None
"""
all_ids: set[int] = set(role_ids) # set.add 已存在返回 False,天然防环
for role_id in role_ids:
parent_id = find_parent(role_id)
while parent_id is not None and parent_id not in all_ids:
all_ids.add(parent_id)
parent_id = find_parent(parent_id)
return all_ids

注意事项:

  • 继承要避免环(A 继承 B,B 又继承 A)——上面代码用 set.add 的返回值天然挡住已存在节点,环不会死循环
  • 多继承会复杂化,多数系统只支持单继承(一个角色一个父角色)
  • 继承链不宜过深,否则权限溯源困难(「小王为什么能删订单?」要往上翻五层)

7.3 RBAC2:角色约束

最常见的是职责分离(SoD, Separation of Duties):

约束 例子
静态职责分离 「制单」和「审核」角色不能同一人持有
动态职责分离 同一人可持有,但同一业务流程中不能同时扮演两个角色
基数约束 一个用户最多 3 个角色
先决条件 必须先有「运营专员」才能授「运营组长」

静态职责分离(分配角色时拦截):

1
2
3
4
5
6
7
8
9
10
11
class RoleConflictError(BusinessError):
"""角色职责冲突"""

def assign_role(user_id: int, new_role_id: int) -> None:
"""分配角色时检查职责分离约束(RBAC2 静态 SoD)"""
existing_roles = set(user_role_repo.find_role_ids(user_id))
conflicting = set(role_conflict_repo.find_conflicts(new_role_id))
# 集合有交集 = 撞车了
if not existing_roles.isdisjoint(conflicting):
raise RoleConflictError("角色职责冲突,不能同时持有")
user_role_repo.insert(user_id, new_role_id)

动态职责分离(业务执行时拦截):

1
2
3
4
5
6
def approve_order(order_id: int, user_id: int) -> None:
"""业务执行时检查:同一流程中不能同人扮演两个角色"""
order = order_repo.get(order_id)
if order.creator_id == user_id:
raise BusinessError("制单人和审核人不能是同一人")
# 执行审核……

静态 vs 动态的区别:

类型 拦截时机 灵活性 实现复杂度
静态 SoD 角色分配时 低(同人不能持有互斥角色) 简单
动态 SoD 业务执行时 高(可持有,但流程中不能同人) 复杂

通俗理解:静态 SoD 是「两张卡不能办在同一人名下」;动态 SoD 是「卡都能办,但今天刷卡进财务室时,就不能再刷卡进审核室」。

7.4 RBAC3:RBAC1 + RBAC2

同时支持继承和约束。实际企业级 RBAC 都是 RBAC3。

graph TB
    RBAC3["RBAC3:企业级标配<br/>继承 + 约束"]
    RBAC3 --> RBAC1["RBAC1:角色继承<br/>parent_id 递归合并"]
    RBAC3 --> RBAC2["RBAC2:角色约束<br/>静态/动态职责分离"]
    RBAC1 --> RBAC0["RBAC0:基础三层<br/>用户-角色-权限多对多"]
    RBAC2 --> RBAC0
    style RBAC3 fill:#c8e6c9,stroke:#2e7d32
    style RBAC1 fill:#e8f5e9
    style RBAC2 fill:#e8f5e9
    style RBAC0 fill:#f1f8e9

7.5 RBAC 的局限性

RBAC 表达「能不能」很自然,但以下场景力不从心:

场景 RBAC 表达 问题
只看本部门数据 ❌ 权限点不包含「部门」维度
只看自己创建的 ❌ 权限点不包含「所有者」维度
工作时间才能访问 ❌ 权限点不包含「时间」维度
密级以上才看 ❌ 权限点不包含「密级」维度

这些「条件性权限」需要 ABAC。

换个说法:RBAC 是开关(开/关二选一),ABAC 是旋钮(按条件连续调节)。要「本部门的」「工作时间的」「密级低于我的」,开关不够用。


八、核心三:ABAC 属性鉴权详解

8.1 ABAC 的四类属性

属性类型 例子
主体属性 部门、职级、用户组、Clearance 级别
客体属性 资源归属部门、密级、所有者、创建时间
操作属性 读/写/删
环境属性 当前时间、IP、设备、地理位置

判断逻辑:主体属性 × 客体属性 × 环境属性 → 是否允许。

1
2
3
4
5
6
7
def allow(subject: Subject, obj: Resource, env: Environment) -> bool:
"""ABAC 判断:三类属性一起参与决策"""
return (
subject.dept_id == obj.dept_id # 规则1:同部门
and env.hour in range(9, 18) # 规则2:工作时间
and subject.clearance >= obj.level # 规则3:密级够
)

8.2 典型 ABAC 场景

场景 规则
只看本部门数据 主体.dept == 客体.dept
只看自己创建的 主体.uid == 客体.creator
工作时间才能访问 环境.time ∈ 9:00-18:00
密级以上才看 主体.clearance >= 客体.level
特定 IP 网段 环境.ip ∈ 内网段
非工作时间需 MFA 环境.time ∈ 非工作时间 AND 主体.MFA 通过

8.3 ABAC 的实现方式

ABAC 在工程上有三种落地方式:

方式 实现 优缺点
装饰器 + 依赖注入 @data_scope 装饰器 + 中间件注入条件 简单,适合数据级权限
规则引擎 simpleeval / Lark 表达式求值 灵活,规则可配置
策略引擎 OPA / Cedar 统一决策 集中管理,适合复杂合规

绝大多数系统用第一种(装饰器 + 依赖注入)就够了,复杂场景才上规则/策略引擎。

FastAPI 天生适合第一种:Depends 就是现成的「切面」。Django 对应的位置是中间件(middleware)或自定义 QuerySet 方法。

8.4 数据级权限实现

ABAC 最常见的落地是数据级权限——同一个 order:view 权限,不同人看到的数据范围不同。

数据范围定义:

1
2
3
4
5
6
7
from enum import Enum

class Scope(str, Enum):
ALL = "all" # 全部数据
DEPT = "dept" # 本部门
DEPT_AND_CHILD = "dept_and_child" # 本部门及下属部门
SELF = "self" # 仅自己

装饰器定义(对应 Java 的注解):

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
from contextvars import ContextVar
from functools import wraps

# 用 ContextVar 代替 Java 的 ThreadLocal,把数据范围条件传给后续查询
data_scope_ctx: ContextVar[str | None] = ContextVar("data_scope", default=None)

def data_scope(
scope: Scope = Scope.SELF,
dept_field: str = "dept_id",
creator_field: str = "create_by",
):
"""声明式数据权限:给路由函数打标,查询时自动注入过滤条件"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
user = kwargs["user"] # 由 Depends(current_user) 注入
token = data_scope_ctx.set(
build_condition(user, scope, dept_field, creator_field)
)
try:
return await func(*args, **kwargs)
finally:
data_scope_ctx.reset(token) # 无论成败都要清理
return wrapper
return decorator

def build_condition(user: User, scope: Scope, dept_field: str, creator_field: str) -> str | None:
if scope == Scope.ALL:
return None # 不加条件
if scope == Scope.SELF:
return f"{creator_field} = {user.uid}"
if scope == Scope.DEPT:
return f"{dept_field} = {user.dept_id}"
# Scope.DEPT_AND_CHILD
ids = ",".join(str(i) for i in get_dept_and_child_ids(user.dept_id))
return f"{dept_field} IN ({ids})"

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from sqlalchemy import event
from sqlalchemy.orm import Session

@event.listens_for(Session, "before_cursor_execute")
def inject_data_scope(conn, cursor, statement, parameters, context, executemany):
"""拦截 SQL,追加数据范围条件(简化演示,生产请用 ORM 层过滤)"""
condition = data_scope_ctx.get()
if condition is None:
return # 无数据范围限制
# 拦截原 SQL,在 WHERE 后追加数据范围条件
if "WHERE" in statement.upper():
statement += " AND " + condition
else:
statement += " WHERE " + condition
return statement, parameters

业务接口只需装饰器:

1
2
3
4
5
@app.get("/order/list")
@data_scope(Scope.DEPT)
async def list_orders(user: User = Depends(current_user)):
# SQL 自动加 "where dept_id = {user.dept_id}"
return await order_repo.select_all()

这就是「同一接口、不同人不同数据范围」的实现——RBAC 管「能不能调这个接口」,ABAC 管「能看哪些数据」。

注意:上面的字符串拼接只是演示原理,生产环境有 SQL 注入风险。正确做法是用 SQLAlchemy 的 with_loader_criteria 在 ORM 层注入参数化条件,而不是改写 SQL 文本:

1
2
3
execute_state.statement = execute_state.statement.options(
with_loader_criteria(Order, lambda cls: cls.dept_id == user.dept_id)
)

下篇 11.2 节讲多租户时会给出更完整的写法。

Django 对比:等价写法是在自定义 Manager/QuerySet 上提供 for_user(user) 方法,或用 django-guardian 的对象级权限;没有 AOP,但「装饰器/中间件 + 统一注入」的思路一致。

8.5 行级 + 列级:不只限定「看哪些行」,还要限定「看得见哪些列」

8.4 解决的是行级权限(row-level):同样一个 /order/list,财务只返回本部门的行、运营只返回自己创建的行。但真实需求往往再进一层——

小 A 的新需求:财务专员能看到本部门订单的单号、金额、状态,但银行卡号、成本价这两列只有财务主管可见;审计人员能看全公司所有订单,但只能看单号和状态两列,金额一律不给看。

这就是列级权限(column-level):行你有资格看,但每行里只有部分字段有资格看。它和行级是两个正交的维度,必须同时生效:

维度 控制什么 实现位置 本文章节
行级(row) 返回哪些记录 SQL WHERE(@data_scope) 8.4
列级(column) 每条记录里露出哪些字段 SELECT 投影 / 响应序列化 8.5

两个维度组合出的典型矩阵(注意行范围和可见列不是一回事——审计行范围最广,列却最少):

角色 行范围 可见列 敏感值处理
财务主管 本部门及子部门 全部列 银行卡明文
财务专员 本部门 除 bank_card、cost 外 银行卡不返回
运营 本部门 order_no、status、amount —
审计 全部(ALL) 仅 order_no、status 无金额列

列级权限的三个实现层次:

层次 做法 适用
数据库层 视图(VIEW)只暴露安全列 / PostgreSQL 列级 GRANT 列组合少且固定
ORM 层 查询时只 select() 允许的列 单表查询,性能最优
序列化层(推荐) 返回前按角色裁剪字段 跨表 join、组合多,最灵活

实现(序列化层投影 + 敏感值脱敏):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 列级权限:角色 → 可见列;None 表示不限制(全部列)
COLUMN_SCOPE: dict[str, set[str] | None] = {
"finance_admin": None, # 财务主管:全部列
"finance": {"order_no", "amount", "status",
"dept_id", "paid_at"}, # 财务专员:无卡号、无成本
"operator": {"order_no", "dept_id", "status"}, # 运营:核心几列
"auditor": {"order_no", "status"}, # 审计:只看单号和状态
}

# 需要脱敏的列:值可见但打码(列在、值隐去)
MASKED_COLUMNS = {"bank_card": lambda v: v[:4] + "****" + v[-4:] if v else v}

def project_columns(row: dict, user: User) -> dict:
"""按角色投影列——在后端裁剪,而不是前端 v-if 隐藏"""
allowed = COLUMN_SCOPE.get(user.role)
out = {k: v for k, v in row.items()
if allowed is None or k in allowed}
# 可见但需脱敏的列
for col, mask_fn in MASKED_COLUMNS.items():
if col in out:
out[col] = mask_fn(out[col])
return out

行级 + 列级组合的完整接口:

1
2
3
4
5
6
@app.get("/order/list")
@data_scope(Scope.DEPT) # ① 行级:WHERE 只留本部门(8.4)
async def list_orders(user: User = Depends(current_user)):
rows = await order_repo.select_all_as_dicts()
# ② 列级:按角色裁剪字段 + 脱敏(8.5)
return [project_columns(r, user) for r in rows]

请求进来到出去,数据被两道闸依次过滤:

graph LR
    A["请求<br/>GET /order/list"] --> B["① 行级过滤<br/>WHERE dept_id = 我的部门"]
    B --> C["② 列级投影<br/>按角色裁剪字段 + 脱敏"]
    C --> D["响应<br/>只剩有资格看的数据"]
    style B fill:#c8e6c9,stroke:#2e7d32
    style C fill:#bbdefb,stroke:#1565c0

Django / DRF 对比:等价做法是在 Serializer 里按角色动态删字段——

1
2
3
4
5
6
7
8
9
10
11
12
13
class OrderSerializer(serializers.ModelSerializer):
class Meta:
model = Order
fields = "__all__"

def get_fields(self):
fields = super().get_fields()
allowed = COLUMN_SCOPE.get(self.context["user"].role)
if allowed is not None: # None = 不裁剪
for name in list(fields):
if name not in allowed:
del fields[name]
return fields

安全提醒(和下篇 16.1 呼应):列级权限必须在后端强制。前端把银行卡列 v-if="hasPerm" 隐藏只是视觉效果——攻击者打开浏览器开发者工具看接口原始响应,字段一个不少。前端管显示,后端管安全,列级和接口级是同一条铁律。


对比专栏:Casbin 如何用一个模型文件表达 ACL / RBAC / ABAC

前面六、七、八三章把 ACL、RBAC、ABAC 分开讲,仿佛是三套互不相干的系统。开源策略库 Casbin(Python 绑定为 pycasbin)用事实证明:它们可以是同一套引擎的不同配置。

Casbin 把权限判断拆成两半:

  • 模型(model.conf):定义「怎么判」——请求长什么样、策略长什么样、有没有角色关系、匹配公式是什么
  • 策略(policy.csv 或数据库表):定义「判什么」——具体的授权数据

判断永远只有一句话:结果 = matchers(请求, 策略)。换权限模型 = 改 matchers 公式,业务代码一行不动。

从 ACL 到 RBAC,只差一个 g()

ACL(对应第六章)——策略行直接写用户,matcher 全等比较:

1
2
3
4
5
6
7
8
9
10
11
12
# acl_model.conf
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
1
2
3
4
# acl_policy.csv —— 每条策略就是 ACL 清单里的一行
p, alice, /report/1.docx, read
p, alice, /report/1.docx, write
p, bob, /report/1.docx, read

RBAC(对应第七章)——追加 [role_definition] 一段,matcher 里把「用户全等」换成「角色函数」:

1
2
3
4
5
6
# rbac_model.conf:相对 acl 只多了 role_definition 和 g()
[role_definition]
g = _, _

[matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && (r.act == p.act || p.act == "*")
1
2
3
4
5
# rbac_policy.csv —— 策略写角色,g() 负责「谁是什么角色」
p, operator, /order/*, GET
p, admin, /user/*, *
g, alice, admin
g, bob, operator
  • g(r.sub, p.sub) 就是「用户 → 角色」的归属判断,p.sub 存的不再是用户而是角色
  • RBAC1 的角色继承也只需多几行 g:g, alice, group_leader + g, group_leader, operator,g() 自动求传递闭包
  • keyMatch2 让 /order/* 通配一组接口(接口级权限,对应下篇第十章)

ABAC:matcher 里直接写属性表达式

属性鉴权(对应 8.1~8.5)在 Casbin 里不是新机制,只是 matcher 换成了属性比较——请求对象携带属性,公式现场计算:

1
2
3
# abac_model.conf:角色归属 + 部门属性,两个条件同时成立才放行
[matchers]
m = g(r.sub.name, p.sub) && r.obj.dept == r.sub.dept && r.act == p.act
1
2
p, operator, order:view, GET
g, alice, operator
1
2
3
4
5
6
7
8
9
import casbin

e = casbin.Enforcer("abac_model.conf", "abac_policy.csv")

alice = User(name="alice", dept="d100") # 主体属性:部门 d100

e.enforce(alice, Order(dept="d100"), "GET") # True —— 同部门,放行
e.enforce(alice, Order(dept="d200"), "GET") # False —— 跨部门,拒绝
e.enforce(User("eve", "d100"), Order("d100"), "GET") # False —— 根本没角色

三个结果全部符合预期:有角色 AND 同部门 才能看——这正是上篇 8.1 「主体属性 × 客体属性 → 决策」的公式化表达。

实现细节(踩坑记录):pycasbin 的 g() 角色函数按原始值比较,sub 传自定义对象时角色匹配会失效。所以混合模型里角色用 r.sub.name(取到字符串)、属性用 r.sub.dept(取到属性值),各取所长——上面的 matcher 就是验证过的可用写法。

本文模型 ↔ Casbin 表达 对照表

本文模型 Casbin 的表达 与上一模型的差异
ACL(六章) r.sub == p.sub && ... 全等比较 基准
RBAC0(7.1) g = _, _ + g(r.sub, p.sub) 策略改写角色,g() 关联用户
RBAC1(7.2) 多行 g 形成角色链 继承 = g() 的传递闭包
RBAC2(7.3) ⚠️ 非原生,职责分离需业务层把关 Casbin 只管「能不能」,不管「能不能同时」
ABAC(八章) matcher 写属性表达式,sub/obj 携带属性 判断从「查表」变「算式」
接口通配(下篇十章) keyMatch2(r.obj, p.obj) 路径模式匹配
多租户(下篇十一章) 策略加域列 / g = _, _, _ 域模型 见下篇 11 章

这张表就是 Casbin 视角下的「权限模型全家福」:模型文件的自由度 = 权限模型的表达能力,而 RBAC2 这类「组合约束」依然要回到业务代码(上篇 7.3 的 assign_role / approve_order)——引擎负责判断,约束负责时机,各司其职。

三分钟上手 pycasbin

1
pip install casbin
1
2
3
4
5
6
7
8
9
10
11
import casbin

e = casbin.Enforcer("rbac_model.conf", "rbac_policy.csv")

# 一次调用完成「查角色 → 匹配权限 → 路径通配」整条链路
e.enforce("alice", "/user/create", "POST") # True —— admin 角色 + /user/* + *
e.enforce("bob", "/user/list", "GET") # False —— operator 无用户模块权限
e.enforce("bob", "/order/list", "GET") # True

# 授权变更后热加载策略(对应下篇 9.5 的缓存失效)
e.load_policy()

Casbin 是进程内的嵌入式引擎(函数调用级延迟),适合中小系统直接用;当下篇十二章的规则多到需要独立服务、跨系统统一决策时,再升级到 OPA 这类外置策略引擎。两者的取舍在下篇展开。


未完待续……

下篇(权限系统落地实战)将讲解:RBAC 五张表结构、权限编码规范、权限加载与缓存、按钮级/菜单级权限、多租户隔离、PBAC 策略引擎、分场景选型、pytest 单元测试与常见误区。