尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Flask多租户SaaS权限管理实战:从数据模型到越权防护
1. 从单机权限到多租户隔离这个需求到底难在哪做过 Flask 单体应用的人第一次接触多租户 SaaS 的权限管理大概率会经历一个想得太简单的阶段。我当初也是这么想的不就是给用户表加个tenant_id字段然后每个查询都带上这个条件吗真正动手之后才发现这件事的复杂度远超预期而且坑几乎全藏在细节里。先把场景说清楚。所谓多租户 SaaS就是一套应用实例同时服务多个互相隔离的客户组织也就是租户。每个租户有自己的用户、角色、数据彼此之间绝对不能串。这跟传统的单租户系统有本质区别传统系统里权限管理只需要回答这个用户能不能做这件事而多租户系统要回答两个问题——这个用户属于哪个租户以及在这个租户内他能不能做这件事。两个问题叠加权限模型的维度直接翻倍。为什么很多人第一版都会写崩因为大家习惯性地把租户隔离当成一个过滤条件来处理而不是当成一个架构约束。过滤条件是可以漏的你写一百个查询只要有一个忘了加tenant_id数据就串了。而架构约束是强制的漏不掉。这个认知差异决定了你的系统是能上线还是只能跑 Demo。这篇文章面向的是已经会用 Flask 写基本 CRUD、但对多租户权限还没有系统认知的开发者。我会从数据模型设计、租户上下文注入、RBAC 权限校验、跨租户越权防护这几个层面把一套可落地的方案拆开讲。中间会穿插我自己踩过的坑比如租户上下文在异步任务里丢失、超级管理员跨租户操作的边界处理、以及权限缓存导致的数据串号问题。这些是文档里不会写、但线上一定会遇到的东西。需要提前说明的是下面涉及的具体表结构、代码组织方式都是基于常见工程实践给出的参考方案你可以根据自己的业务规模调整。核心思路比具体实现更重要。2. 数据模型租户、用户、角色的三层关系怎么摆2.1 租户表不是简单的 id name很多人设计租户表的时候随手就写个id和name就完事了。实际跑起来会发现不够用。租户本身也需要状态管理试用中、正常、欠费停用、已注销。这些状态直接影响用户能不能登录、能不能调用接口。所以租户表至少要包含状态字段和过期时间。class Tenant(db.Model): __tablename__ tenant id db.Column(db.Integer, primary_keyTrue) name db.Column(db.String(128), nullableFalse) code db.Column(db.String(64), uniqueTrue, nullableFalse) # 租户唯一标识 status db.Column(db.String(16), defaultactive) # active/suspended/expired expired_at db.Column(db.DateTime, nullableTrue) created_at db.Column(db.DateTime, defaultdatetime.utcnow)这里code字段值得单独说一句。用自增id做租户标识在内部查询没问题但一旦涉及到对外暴露比如子域名、API 路径、邀请链接自增 id 既不安全也不好看。用一个独立的code做业务标识内部关联仍然用id这是比较稳妥的做法。2.2 用户与租户的关系一对多还是多对多这是第一个真正需要做决策的地方。一个用户能不能同时属于多个租户答案取决于你的业务形态。如果是 B 端工具类产品一个用户比如某公司的财务可能同时是多家公司的员工那就需要多对多中间加一张tenant_user关联表关联表里还可以存这个用户在该租户下的状态是否已加入、是否被禁用。如果是面向单一组织的系统一个用户只属于一个租户那直接在用户表加tenant_id就够了。我个人的建议是除非业务明确要求否则第一版用一对多。多对多会带来一系列连锁复杂度——登录时选租户、切换租户、每个租户下角色独立等等。这些复杂度是真实的不是可以忽略的。等业务真的需要了再迁移比一开始就过度设计要划算。一对多的用户表长这样class User(db.Model): __tablename__ user id db.Column(db.Integer, primary_keyTrue) tenant_id db.Column(db.Integer, db.ForeignKey(tenant.id), nullableFalse, indexTrue) username db.Column(db.String(64), nullableFalse) password_hash db.Column(db.String(256), nullableFalse) status db.Column(db.String(16), defaultactive) __table_args__ ( db.UniqueConstraint(tenant_id, username, nameuq_tenant_username), )注意那个联合唯一约束。用户名在全局不唯一但在租户内必须唯一。这意味着 A 租户可以有个叫admin的用户B 租户也可以有。如果你只给username加唯一索引第二个租户就建不了同名用户了这是个很隐蔽的坑。2.3 角色与权限RBAC 的经典三张表权限模型用 RBAC基于角色的访问控制是业界共识没必要自己发明轮子。核心是三张表角色表、权限表、角色权限关联表再加上用户角色关联表。class Role(db.Model): __tablename__ role id db.Column(db.Integer, primary_keyTrue) tenant_id db.Column(db.Integer, db.ForeignKey(tenant.id), nullableFalse, indexTrue) name db.Column(db.String(64), nullableFalse) is_system db.Column(db.Boolean, defaultFalse) # 系统内置角色不可删 class Permission(db.Model): __tablename__ permission id db.Column(db.Integer, primary_keyTrue) code db.Column(db.String(128), uniqueTrue, nullableFalse) # 如 user:create name db.Column(db.String(128), nullableFalse) class RolePermission(db.Model): __tablename__ role_permission role_id db.Column(db.Integer, db.ForeignKey(role.id), primary_keyTrue) permission_id db.Column(db.Integer, db.ForeignKey(permission.id), primary_keyTrue) class UserRole(db.Model): __tablename__ user_role user_id db.Column(db.Integer, db.ForeignKey(user.id), primary_keyTrue) role_id db.Column(db.Integer, db.ForeignKey(role.id), primary_keyTrue)这里有个关键设计点权限表是全局的角色表是租户级的。为什么因为权限点是由代码定义的比如创建用户删除订单这些能力全系统就这么多不会因为租户不同而不同。但角色是租户自己配置的A 租户可能把创建用户和删除用户打包成一个管理员角色B 租户可能拆成两个角色。角色归租户权限归系统这个边界要划清楚。2.4 为什么权限点要用字符串 code 而不是数字 id权限校验的代码里你肯定不想写if 3 in user_permissions这种魔法数字。用user:create这样的字符串 code代码可读性直接上一个台阶而且权限点定义和代码是强绑定的改起来也直观。代价是字符串比较比整数慢一点点但权限校验通常有缓存这点开销可以忽略。3. 租户上下文让每个请求都知道我在为谁服务3.1 为什么不能靠每个函数手动传 tenant_id最原始的做法是每个视图函数、每个 service 方法都接收一个tenant_id参数然后往下传。写三个接口还行写到三十个接口你就会发现总有那么一两个地方忘了传或者传错了。而且这种手动传递的方式一旦中间某层用了第三方库比如某个 ORM 钩子、某个装饰器参数就断了。正确的做法是引入租户上下文类似 Flask 的g对象或者contextvars让当前请求的租户信息在整个调用链里随时可取不需要显式传递。3.2 用 Flask 的 g 对象承载租户信息Flask 的g对象是请求级别的天然适合放租户上下文。在请求进入业务逻辑之前通过一个before_request钩子把租户解析出来塞进g。from flask import g, request app.before_request def load_tenant_context(): # 从 JWT、session 或请求头里解析租户标识 tenant_code resolve_tenant_from_request(request) if not tenant_code: g.tenant None return tenant Tenant.query.filter_by(codetenant_code, statusactive).first() if not tenant: abort(403, description租户不存在或已停用) g.tenant tenant g.tenant_id tenant.idresolve_tenant_from_request具体怎么实现取决于你的架构。常见的有三种从 JWT 的 payload 里取、从子域名里取、从请求头X-Tenant-Code里取。JWT 方式最省事因为登录时就把租户信息签进去了子域名方式对用户友好但需要配置泛域名解析请求头方式最灵活适合前后端分离。3.3 租户上下文在异步任务里会丢这是个大坑g对象只在请求生命周期内有效。一旦你把任务丢给 Celery 或者后台线程g.tenant_id就没了。我当初做异步导出功能时就栽在这上面——导出任务跑起来查数据时g.tenant_id是 None结果把所有租户的数据都导出来了。幸好是测试环境发现的。解决办法是在任务入队时把 tenant_id 作为显式参数传进去任务函数里再重新构建上下文。celery.task def export_data(tenant_id, user_id): # 异步任务里没有 g需要手动构建 with tenant_context(tenant_id): # 这里的查询会自动带上租户过滤 data Order.query.all() ...tenant_context可以是一个上下文管理器内部用contextvars存当前租户这样即使不在 Flask 请求里也能工作。这个设计比依赖g更健壮因为它不绑定 Flask 的请求生命周期。3.4 一个容易忽略的细节租户状态校验的时机租户被停用后已经登录的用户怎么办如果只在登录时校验租户状态那停用之后用户手里的 token 还能继续用直到过期。这显然不对。我的做法是在before_request里每次都校验租户状态。听起来每次请求都查一次数据库有点重但租户信息可以缓存比如用 Redis 缓存租户状态TTL 设个 60 秒。这样停用操作最多 60 秒后生效对绝大多数业务来说足够了。如果对实时性要求极高可以在停用时主动清除缓存。4. 权限校验从装饰器到数据级过滤4.1 权限装饰器的正确写法有了租户上下文和 RBAC 表权限校验就可以做成装饰器挂在需要保护的接口上。def require_permission(permission_code): def decorator(f): wraps(f) def wrapper(*args, **kwargs): user get_current_user() if not user: abort(401) if not user_has_permission(user, permission_code): abort(403, description权限不足) return f(*args, **kwargs) return wrapper return decorator app.route(/api/users, methods[POST]) require_permission(user:create) def create_user(): ...user_has_permission的实现要注意它需要查用户的所有角色再查角色对应的所有权限然后判断目标权限是否在其中。这个查询链比较长一定要加缓存。4.2 权限缓存的失效策略权限数据的特点是读多写极少。一个用户的权限可能几个月都不变但每个请求都要校验。所以缓存是必须的。缓存 key 用user_idvalue 是这个用户的权限 code 集合。什么时候失效用户角色变更时、角色权限变更时、用户被禁用时。这里有个坑如果你只失效单个用户的缓存那当某个角色的权限变了所有拥有这个角色的用户缓存都得失效。所以角色权限变更时要找出所有关联用户批量清缓存。def invalidate_role_cache(role_id): user_ids [ur.user_id for ur in UserRole.query.filter_by(role_idrole_id).all()] for uid in user_ids: redis.delete(fperm:user:{uid})如果用户量很大这个批量删除可能比较慢。可以考虑给缓存加一个版本号角色权限变更时递增版本号缓存 key 里带上版本号旧版本自然失效。这是更优雅的做法但实现稍复杂。4.3 数据级权限光有接口权限还不够接口权限解决的是能不能调用这个接口但解决不了能看哪些数据。比如两个租户的管理员都有order:list权限但 A 租户的管理员绝对不能看到 B 租户的订单。这就是数据级权限也叫行级隔离。实现方式有两种。一种是手动过滤每个查询都写filter_by(tenant_idg.tenant_id)。这种方式灵活但容易漏。另一种是自动过滤通过 SQLAlchemy 的事件钩子或者自定义 Query 类在查询构造时自动注入租户条件。from sqlalchemy.orm import Query class TenantQuery(Query): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # 自动注入租户过滤 if hasattr(g, tenant_id) and g.tenant_id: # 这里需要判断模型是否有 tenant_id 字段 ...说实话自动过滤的实现比较 tricky因为不是所有模型都有tenant_id比如全局的权限表就没有。我的建议是核心业务表用自动过滤全局表手动排除。或者更简单粗暴一点所有业务查询都走一个封装好的tenant_query(Model)函数强制带上租户条件。约定优于配置团队里所有人都遵守这个约定比搞复杂的自动机制更可靠。4.4 超级管理员的跨租户操作怎么处理系统通常需要一个超级管理员能跨租户操作比如查看所有租户、帮某个租户重置配置。这个角色的权限模型和普通用户不一样它不受租户隔离约束。处理方式是在用户表加一个is_super_admin标志或者在租户表里设一个特殊的系统租户。校验时先判断是不是超管是的话跳过租户过滤。但这里要特别小心超管的操作必须留审计日志。因为超管能跨租户一旦出问题影响面很大。每次超管跨租户操作都要记录操作人、目标租户、操作内容、时间。这不是可选项是必须项。5. 那些线上才会暴露的坑5.1 租户 ID 从请求参数里取等于把门敞开我见过一些实现租户标识是从 URL 参数或者请求体里取的比如/api/orders?tenant_id5。这是极其危险的。攻击者只要把tenant_id改成别的值就能访问其他租户的数据。租户标识必须来自可信来源JWT 签名、服务端 session、或者经过验证的子域名。绝对不能来自用户可以随意修改的请求参数。这个原则听起来简单但实际项目中因为图方便而违反的情况并不少见。5.2 关联查询时的租户泄漏假设你要查订单订单关联了用户用户关联了租户。如果你只对订单表做了租户过滤但通过order.user.tenant_id去访问用户信息而用户表没过滤就可能读到其他租户的用户。这种通过关联对象绕开过滤的情况很隐蔽。防御方法是所有涉及租户数据的模型查询入口都强制带租户条件不要依赖关联对象的租户字段。如果确实需要跨表查询用显式的 join 并带上租户条件。5.3 批量操作的租户校验批量删除、批量更新这类接口很容易出问题。比如批量删除订单接口收到一个 id 列表如果只校验了用户有删除权限但没校验这些订单都属于当前租户那用户传一个其他租户的订单 id 进来就删掉了别人的数据。正确做法是批量操作前先查出这些 id 对应的记录校验每一条的tenant_id是否等于当前租户。有一条不符就整体拒绝。这个校验不能省。5.4 权限变更后的 token 问题用户的权限变了但他手里的 JWT 还没过期里面可能还带着旧的权限信息。如果权限信息存在 JWT 里那变更后必须等 token 过期才生效这个延迟可能无法接受。所以我的建议是JWT 里只放用户 id 和租户 id不放具体权限。权限每次从缓存或数据库查。这样权限变更后清掉缓存就立即生效。JWT 只负责身份认证不负责权限授权职责分离更清晰。6. 一套可复用的权限校验流程把上面的东西串起来一个完整的请求处理流程是这样的请求进入before_request钩子解析租户标识校验租户状态把tenant_id存入上下文。解析用户身份从 JWT 或 session校验用户状态把user_id存入上下文。路由匹配后权限装饰器检查用户是否拥有所需权限走缓存。业务逻辑执行所有数据查询自动或手动带上租户过滤。涉及写操作时校验目标数据的租户归属。超管操作额外记录审计日志。这套流程的关键在于每一层都有明确的职责租户解析、身份认证、权限校验、数据过滤各司其职不混在一起。这样出问题时也容易定位是哪一层的问题。最后分享一个我在实际项目中总结的小技巧写一个租户隔离的测试用例集。针对每个业务接口都写一个用 A 租户的身份访问 B 租户数据的测试断言返回 403 或 404。这个测试集跑起来很快但能挡住绝大多数越权问题。每次加新接口就补一个对应的隔离测试。坚持下来租户串号这类问题基本不会漏到线上。
RELATED

相关推荐

Context7 使用文档:提升AI编程助手准确性的MCP服务器

Context7 使用文档:提升AI编程助手准确性的MCP服务器

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/10/12 20:20:39
基于MFC的LALR(1)分析表自动构造工具设计与实现

基于MFC的LALR(1)分析表自动构造工具设计与实现

简介:这是一套基于MFC界面开发的LALR(1)分析表自动构造程序资源包,专门用于编译原理课程设计,面向需要完成语法分析阶段实验的高校学生。程序能够对任意给定的文法自动构造LR(1)项目集规范族,再通过合并同心集得到LALR(1)项目集规…

📅 2026/10/12 20:20:39
单机与网游存储及反外挂设计:信任模型与工程实践

单机与网游存储及反外挂设计:信任模型与工程实践

1. 从存档文件到服务器数据库:两种存储哲学的分野单机游戏和网络游戏在数据存储上的差异,本质上不是技术选型的不同,而是信任模型的不同。单机游戏信任玩家,数据放在玩家自己的机器上;网络游戏不信任玩家,数…

📅 2026/10/12 20:20:39
MORE NEWS

更多资讯

📰

网申简历附件太大,先换模板还是压缩 PDF?

网申简历附件太大,先换模板还是压缩 PDF? 先看“大”从哪里来,再决定怎么改。模板里塞了整页照片、复杂背景,和一份扫描成图片的简历,处理方法不一样。别一上来就把 PDF 压到字也看不清;能减少装饰图片时&a…

📰

claude-mem:用命令行给AI助手装上长期记忆,告别每次重复交代背景

每天打开命令行工具,面对同一个 AI 助手,它却根本想不起来我们三天前刚讨论过的技术选型。这种"失忆"对于高频使用 AI 编程助手的开发者来说,几乎是日常最磨人的痛点。我一直在找一种方案,能把这堆散落在不同会话里的关…

📰

精彩回顾·成都|DeepSeek 接入 TaoToken 统一 API 通道实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

AI Agent Token 成本怎么算:prompt_tokens、completion_tokens、usage、上下文窗口一次讲清楚(TaoToken 统一 Key 视角)

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

重磅:网络大厂全资收购明星开源团队,未来云端运行底座或将彻底改写

重磅:网络大厂全资收购明星开源团队,未来云端运行底座或将彻底改写 一个亲手写出统治全球服务器代码的人,竟然决定亲手关掉自己花了数年心血打造的下一个心爱之作。 2026年10月9日,大洋彼岸的技术圈传出一记平地惊雷:云…

📰

十二星座英语口语全攻略:从破冰到聊性格的高频表达

十二星座这个话题,在英语口语里绝对是性价比最高的破冰素材之一。不管是英语角、外教课,还是跟刚认识的外国朋友闲聊,一句"Whats your sign?"基本就能让对话自然展开。我见过不少英语水平其实不错的人,一聊到星座就卡壳…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬