《Python编程实战》9.2 权限模型与多租户隔离

认证之后是授权:本节对比 ACL/RBAC/ABAC 三种模型,用角色到权限集合实现 RBAC 并接进 FastAPI 依赖,再讲透多租户的独立库、独立 schema 与行级 tenant_id 三种隔离方案,并写出能真跑的越权测试用例验证边界。

本节目标:把「认证出来的身份」变成「能做什么」——掌握 RBAC/ABAC 的取舍,用角色到权限集合实现权限判定并接进 FastAPI 依赖,理解多租户隔离的三种方案,写出能真跑的越权测试用例。
适用版本:Python 3.12+(实测 3.14.6);FastAPI 0.143.0、SQLAlchemy 2.1.4

9.2 权限模型与多租户隔离

9.1 把「你是谁」确定了,但那只是一个身份字符串。授权要回答三件事:主体(谁)对资源(哪个对象)做操作(读/写/删)是否被允许。这一节先把权限模型理清,再落到多租户这个最容易出越权事故的场景。

9.2.1 三种授权模型

模型判定依据优点代价适用
ACL每个资源挂一张「谁能访问」清单最精细资源一多清单爆炸少量共享文档
RBAC用户 → 角色 → 权限好管理、好审计粗粒度,难表达「仅本人」绝大多数后台系统
ABAC主体/资源/环境的属性组合最灵活,能表达任意规则策略复杂、性能与调试成本高合规敏感、细粒度

实践建议:从 RBAC 起步,只在 RBAC 表达不了的地方用 ABAC 补几条规则。一上来就上 ABAC 往往过度设计。看个 ABAC 规则的形状,就能感到它的表达力和复杂度:

def abac_can_export(subject: dict, resource: dict, env: dict) -> bool:
    return (
        subject["dept"] == resource["dept"]          # 同部门
        and env["hour"] in range(9, 18)              # 上班时段
        and subject["clearance"] >= resource["level"]  # 密级足够
    )

这类「主体属性 × 资源属性 × 环境属性」的组合,RBAC 无论如何拆角色都表达不干净;但规则一多就会互相干扰,所以 ABAC 只用来补 RBAC 的短板,不做主力。

9.2.2 RBAC 的数据模型:五张表

RBAC 落到数据库就是经典的五张表,理解它就知道权限系统的骨架:

users            (id, name, ...)
roles            (id, name)                       -- viewer / operator / admin
permissions      (id, code)                       -- order:read / order:write ...
user_roles       (user_id, role_id)               -- 多对多:一个用户多个角色
role_permissions (role_id, permission_id)         -- 多对多:一个角色多个权限

user_roles 与 role_permissions 两张关联表是多对多,这带来一个重要的工程好处:给角色加一个权限,所有持有该角色的用户立刻生效,不用逐个改用户。判定权限时,只需把「用户的角色」展开成「权限集合」做一次 in 判断——不必每次去 join 五张表,可以缓存(见 9.2.9)。

9.2.3 RBAC 判定:角色到权限集合

判定逻辑简单到出人意料——一张「角色 → 权限集合」的映射表,查集合即可:

ROLE_PERMS: dict[str, frozenset[str]] = {
    "viewer":   frozenset({"order:read"}),
    "operator": frozenset({"order:read", "order:write"}),
    "admin":    frozenset({"order:read", "order:write", "order:delete", "user:manage"}),
}

def has_permission(roles: list[str], perm: str) -> bool:
    return any(perm in ROLE_PERMS.get(r, frozenset()) for r in roles)

权限名用 资源:动作 命名(order:read、order:write),天然可分组、可通配。真跑判定:

权限判定:
  ['viewer'] 请求 order:read    -> True
  ['viewer'] 请求 order:delete  -> False
  ['operator'] 请求 order:write   -> True
  ['admin'] 请求 user:manage   -> True
  ['viewer', 'operator'] 请求 order:write   -> True

关键:用户可以有多个角色,任一角色命中该权限即通过(any)。ROLE_PERMS.get(r, frozenset()) 里的默认值很重要——遇到数据库里存了一个已下线的旧角色名,不能抛异常,应当视为「无此角色的任何权限」。

9.2.4 把权限判定接进 FastAPI 依赖

权限校验必须落在每个受保护端点上,最不容易漏的写法是把它做成一个依赖工厂 require(perm),端点声明自己需要的权限:

def require(perm: str):
    def checker(user: UserDep) -> dict:
        if not any(perm in ROLE_PERMS.get(r, frozenset()) for r in user["roles"]):
            raise HTTPException(status.HTTP_403_FORBIDDEN, f"缺少权限 {perm}")
        return user
    return checker

@app.delete("/orders/{order_id}")
def delete_order(order_id: int, user: Annotated[dict, Depends(require("order:delete"))]) -> dict:
    return {"deleted": order_id, "by": user["sub"]}

这样「端点需要什么权限」就写在签名里,代码审查时一眼可见,比散落在函数体里的 if 可靠得多。注意错误码:401 是「你没登录」,403 是「登录了但没权限」,两者不能混用。

9.2.5 多租户隔离的三种方案

多租户(multi-tenant)指一套系统服务多个客户(租户),且租户之间绝不能互相看到数据。隔离强度从高到低:

方案做法隔离强度成本适用
独立数据库每租户一个库/实例最强最高,运维与迁移翻倍大客户、强合规
独立 schema同一库,每租户一个 schema强中,迁移要逐 schema 跑中等规模、PG
共享表 + 行级 tenant_id所有行带租户列,查询强制过滤弱(靠代码纪律)最低,扩展最好大量小租户、SaaS 主流

SaaS 产品绝大多数走第三种:成本最低、扩容最顺,但把安全责任压到了「每一处查询都必须带 tenant_id 过滤」上——漏一处就是数据泄露。所以这一种的工程重点不是隔离本身,而是如何让「漏过滤」在代码结构上变得不可能。

9.2.6 行级隔离:让查询强制带 tenant_id

最稳的做法是把过滤收敛到一个仓库层,业务代码根本拿不到「不带租户的查询」入口:

from sqlalchemy import String, create_engine, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column

class Base(DeclarativeBase):
    pass

class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    tenant_id: Mapped[str] = mapped_column(String(32), index=True)   # 每张业务表都要有
    amount: Mapped[int] = mapped_column()

def list_orders_safe(session: Session, tenant_id: str) -> list[int]:
    stmt = select(Order.amount).where(Order.tenant_id == tenant_id)   # 强制注入
    return list(session.execute(stmt).scalars())

def list_orders_leaky(session: Session) -> list[int]:
    return list(session.execute(select(Order.amount)).scalars())      # 反面教材:漏过滤

真跑两个租户的数据:

acme 可见订单: [100, 200]
globex 可见订单: [999]
越权(无过滤)泄露: [100, 200, 999]
acme 查 globex 的 id=3: globex -> 必须在应用层拒绝

list_orders_leaky 就是真实事故的根:select(Order.amount) 没带 where,acme 的用户一眼看到 globex 的订单。tenant_id 必须建索引(每个查询都按它过滤,不建索引性能会塌)。

tenant_id 的来源必须是服务端从令牌解析出来的,绝不能信任请求里带的 tenant_id 参数——否则改个参数就能越权。永远从认证上下文取租户,不要从请求体取。

9.2.7 对象级授权:为什么越权返回 404 而不是 403

上面 s.get(Order, 3) 能取到 globex 的行,说明只按主键取对象是不够的——这正是 IDOR(不安全的直接对象引用)越权。正确做法是按主键 + 租户一起查,查不到就返回 404:

@app.get("/orders/{order_id}")
def get_order(order_id: int, user: UserDep) -> dict:
    tenant = user["tenant_id"]                              # 来自令牌,不来自请求
    row = DATA.get(tenant, {}).get(order_id)                # 先按租户取
    if row is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "订单不存在")
    return {"id": order_id, "tenant_id": tenant, "name": row}

为什么是 404 不是 403? 403 等于告诉攻击者「这个 ID 存在,只是不归你」,反而暴露了别的租户的资源存在性。统一返 404,让「不存在」和「不属于你」无法区分,这是防枚举的关键。

9.2.8 越权测试用例

越权漏洞不会自己冒出来,必须写成测试固化住。核心是构造「不同租户 + 不同角色」的矩阵,断言每条边界的期望状态码:

H = lambda t: {"Authorization": f"Bearer {t}"}
cases = [
    ("acme 读自己的 1",      c.get("/orders/1", headers=H("acme-viewer"))),
    ("acme 读 globex 的 3",  c.get("/orders/3", headers=H("acme-viewer"))),   # 期望 404
    ("globex 读自己的 3",    c.get("/orders/3", headers=H("globex-admin"))),
    ("viewer 删订单(无权限)", c.delete("/orders/1", headers=H("acme-viewer"))),  # 期望 403
    ("admin 删订单",         c.delete("/orders/1", headers=H("acme-admin"))),
    ("无令牌",               c.get("/orders/1")),                             # 期望 401
]

真跑结果:

acme 读自己的 1              -> 200 {'id': 1, 'tenant_id': 'acme', 'name': 'acme-order-1'}
acme 读 globex 的 3        -> 404 订单不存在
globex 读自己的 3            -> 200 {'id': 3, 'tenant_id': 'globex', 'name': 'globex-order-3'}
viewer 删订单(无权限)          -> 403 缺少权限 order:delete
admin 删订单                -> 200 {'deleted': 1, 'by': 'u2'}
无令牌                      -> 401 无效令牌

这份矩阵就是授权的回归网:任何一次「忘了过滤」「权限判断写反」都会让某一行变红。测试要点是每个租户都要有专属账号去尝试读别人的资源,只测「自己的能读」不算数。

9.2.9 权限缓存与失效

权限判定几乎每个请求都要做,但角色/权限变更极低频——这正是读多写少的缓存场景。把「用户 → 权限集合」算一次缓存起来,键用 perm:{user_id},值放权限集合,TTL 给几分钟:

def perms_of(redis, user_id: str, roles: list[str]) -> set[str]:
    key = f"perm:{user_id}"
    cached = redis.smembers(key)
    if cached:
        return {m.decode() for m in cached}
    perms = {p for r in roles for p in ROLE_PERMS.get(r, frozenset())}
    if perms:
        redis.sadd(key, *perms)
        redis.expire(key, 300)      # 5 分钟过期,兜住漏删
    return perms

缓存权限最大的风险是「权限被回收了但缓存还没过期」——被降权的用户仍能操作。两条防线:一是改角色时主动删掉相关用户的缓存键(redis.delete(f"perm:{user_id}"));二是给 TTL 兜底,即使漏删也会在几分钟内自动收敛。安全相关的缓存,TTL 宁可短,也不要长。

9.2.10 三个高频越权死角

  • 列表接口漏过滤:详情接口写了 tenant_id,列表接口 select(Order) 忘了写——最典型。所有返回集合的查询都要过一遍。
  • 关联查询绕过:join 时只在主表过滤,子表数据仍会带出别的租户。每一张参与 join 的表都要带租户条件。
  • 写操作的租户校验:更新/删除前必须先按「主键 + 租户」确认这条记录属于当前租户,不能只按主键 UPDATE。批量操作尤其危险,WHERE id IN (...) 后面必须跟 AND tenant_id = ?。

延伸阅读

小结

  • 认证≠授权:授权要同时确定「主体、资源、操作」,三者缺一不可。
  • 从 RBAC 起步(角色→权限集合,多角色任一命中即通过),只在 RBAC 表达不了处用 ABAC 补规则。
  • 权限判定做成 require(perm) 依赖,让「端点需要什么权限」写在签名里;401 是没登录、403 是没权限。
  • 多租户三方案:独立库最强最贵、独立 schema 居中、共享表行级 tenant_id 最主流——但安全全押在「每处查询都过滤」上。
  • tenant_id 必须从认证上下文取、建索引、每张 join 表都带;只按主键取对象就是 IDOR。
  • 越权一律返 404 而非 403(不泄露资源存在性),并用「跨租户 + 跨角色」矩阵把边界固化成测试;权限缓存要主动失效 + TTL 兜底。

身份和权限都到位后,还有一个更底层的问题:进来的数据本身可信吗?你依赖的那些第三方包安全吗? 下一节我们回到最前线的输入校验,并补上依赖供应链与漏洞加固这条常被忽视的防线。

阅读导航:上一节:认证与会话:JWT / OAuth2 · 下一节:输入校验、依赖供应链与漏洞加固 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时