ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

多租户自动化测试进阶:从上下文穿透到越权扫描的实战指南

多租户自动化测试进阶:从上下文穿透到越权扫描的实战指南 1. 一次租户串号事故暴露了多租户测试的三个盲区先讲一个让我印象深刻的线上事故。那天凌晨两点告警群里突然炸了某餐饮连锁客户A的运营人员在后台看到了另一家酒店客户B的全部订单数据。数据量不大但性质很严重——这是典型的租户数据串号。查了很久才发现问题出在一个老服务里有人用了一个static的Map缓存租户上下文请求A时写进去的值在某种并发时序下被请求B读到了。更尴尬的是我们的自动化测试跑了上百个用例全绿。为什么全绿复盘后我总结了三个盲区第一测试用例都是单租户视角。每个用例只针对一个租户的数据做校验从来没有一个用例同时操作两个租户更没有用例去验证A的接口能不能拿到B的数据。单租户环境里即使代码里有static缓存、线程局部变量没清理、SQL漏掉了tenant_id条件测试也全部照常通过。第二测试数据没有租户属性。当时测试库里就一套数据谁在跑都用它。数据本身不区分归属隔离逻辑根本无从验证。就相当于你在一间只有一个房间的房子里测门锁测不出任何问题。第三只测功能不测隔离边界。大家习惯了按功能点来写用例——登录、列表、详情、导出。但多租户系统里最值钱最危险的逻辑恰恰不是这些功能本身而是这些功能在不同租户之间怎么划边界。边界测不透功能做得再对也可能出事。从那次之后我把团队的多租户测试体系重新拆了一遍定下了四个层次的目标上下文穿透、数据隔离、并行资源调度、越权防护。这篇文章就按这四个层次展开标题虽然叫进阶但内容全部来自实际踩坑后的沉淀。2. 租户上下文贯穿从JWT到SQL测试怎么验证这条看不见的链路多租户系统里最核心的一条链路是请求进来后系统怎么知道当前操作的是哪个租户这个答案通常藏在三层里——认证层、请求链路层、数据访问层。测试的任务就是验证这条链路在每一层都没有断掉也没有串。2.1 认证层不同租户的Token怎么区分大多数SaaS系统的做法是登录成功后签发JWTJWT的payload里带上tenant_id、user_id、role这些声明。测试要做的第一件事就是让每个测试用例都能拿到一个属于自己的租户身份而不是所有用例共用一个账号。我习惯在pytest里做一个租户工厂fixture它负责把租户标识变成可用的认证凭据# conftest.py import os import pytest from dataclasses import dataclass dataclass class TenantContext: tenant_id: str user_id: str role: str token: str USER_REGISTRY { t_enterprise: {admin: 13800000001, operator: 13800000002}, t_free: {admin: 13900000001}, } pytest.fixture def tenant_context(request): 从命令行参数或环境变量读取租户标识返回带身份的上下文对象 tenant_id request.config.getoption(--tenant-id) or os.getenv(TENANT_ID, t_enterprise) role getattr(request, param, admin) phone USER_REGISTRY[tenant_id][role] password os.getenv(TEST_PASSWORD, default-pass) resp auth_service.login(tenant_idtenant_id, phonephone, passwordpassword) assert resp.status_code 200, f登录失败: {resp.text} return TenantContext( tenant_idtenant_id, user_idresp.json()[user_id], rolerole, tokenresp.json()[token], )这里有个细节很多人会忽略登录接口本身也是需要测的。比如一个租户注销后旧Token还能不能继续访问JWT里的租户声明如果被人为篡改系统能不能识别这些都属于认证层的隔离测试。我们在测试套件里专门有一组用例专门造带错误租户声明的Token去请求期望被网关拦截。这种用例写起来不难但绝大多数团队根本没想过要写。2.2 请求链路层X-Tenant-Id的前置校验很多系统在网关层会强制要求请求头里带X-Tenant-Id或者从JWT里解析租户标识。测试要验证的是如果两个信息不一致系统必须拒绝而不是默默采用其中一个。比如我用A租户的Token但把请求头里的X-Tenant-Id改成B。正确行为应当是被网关拦截返回401/403而不是真的拿B的环境去执行。这个场景我用一个参数化用例来覆盖pytest.mark.parametrize(scenario, [ (t_enterprise, t_free), # 高权限租户冒充低权限 (t_free, t_enterprise), # 低权限租户冒充高权限 (t_enterprise, not_exist), # 不存在的租户 ]) def test_tenant_context_conflict(tenant_context, scenario): auth_tenant, header_tenant scenario client get_client(auth_tenant) resp client.get(/api/v1/dashboard, headers{X-Tenant-Id: header_tenant}) assert resp.status_code in (401, 403), f租户上下文冲突未被拦截: {resp.status_code}2.3 数据访问层SQL里有没有漏掉tenant_id这一层是纯后端持久层的逻辑。在共享表的隔离模式下SQL如果不带tenant_id条件就会扫描全表并返回其他租户的数据。自动化测试很难直接看到SQL但可以通过结果反推——凡是查询类接口返回数据里不能出现不属于当前租户的记录。我们封装了一个递归断言工具专门检查JSON响应里所有租户相关字段def assert_tenant_scope(payload, expected_tenant_id): 递归校验JSON中所有租户标识字段都不越界 if isinstance(payload, dict): for key, value in payload.items(): if key in (tenant_id, tenantId): assert value expected_tenant_id, \ f数据越权: 期望租户 {expected_tenant_id}, 实际返回 {value} else: assert_tenant_scope(value, expected_tenant_id) elif isinstance(payload, list): for item in payload: assert_tenant_scope(item, expected_tenant_id)这个工具简单但极好用。凡是返回列表页、详情页、导出文件的接口我都会把它挂在断言链路上。一旦后端在SQL层面漏了租户过滤用例立刻红。2.4 用Fixture把租户上下文做成测试基座很多团队做多租户测试时最大的痛点不是不会写用例而是每个用例都要手动拉Token、拼请求头、管理租户数据代码冗余得离谱。我的建议是把租户上下文做成整个测试套件的基座而不是游离在单个用例里。具体做法是自定义一个fixture让它返回一个已经绑定租户身份的客户端对象后续所有用例都从它上面发请求而不是直接调用裸的requests或httpxpytest.fixture def api(tenant_context): 绑定租户身份的API客户端 return TenantApiClient( base_urlconfig.BASE_URL, tokentenant_context.token, tenant_idtenant_context.tenant_id, )这样一来用例里写当前租户的逻辑就完全消失了取而代之的是我是谁我就能访问什么。测试代码的语义变得非常干净新人也容易理解。同时将来如果需要支持移动端多租户场景可以在同样的基座上接入appium把UI层面的租户上下文验证也挂到同一套fixture下面逻辑是一致的。3. 三种数据隔离模型下的验证策略不能只会查tenant_id做过SaaS的都知道多租户的数据隔离有三种经典模型独立数据库、共享库独立Schema、共享库共享Schema靠行级字段隔离。很多测试同学一上来就盯着tenant_id校验但不同模型的验证重点其实差别很大。3.1 独立数据库模式验证连接、迁移与跨库访问这种模式下每个租户拥有独立的数据库或独立的实例安全性最高。但测试要关注的不是行级隔离而是另外三件事连接路由是否正确租户A的请求必须落到A的库。我们通过在每个租户库里写入不同的埋点记录比如初始化数据里的seed标记然后断言接口返回的数据里含有该租户特有的标记。迁移版本是否对齐多租户模式下最怕这个租户的库还是旧版本。所以每次发布后会自动跑一次全租户的schema版本巡检自动化测试里加入一条用例遍历租户库列表检查flyway/liquibase版本记录全部一致才放行。跨库访问是否被物理隔离尝试从租户A的连接串去连租户B的库期望连接被拒绝。这个用例不一定每个环境都能跑但至少要在CI的独立测试环境里覆盖。3.2 共享库独立Schema模式重点验证Schema切换共享一个数据库但各自Schema的模型下最典型的Bug是连接池串Schema。你的服务可能在初始化时用了一个默认的search_path结果所有租户都操作到了同一个Schema上。对这种模型我的测试策略是造两个Schema表结构相同但初始数据完全不同然后让两个租户并行发起请求最后校验两个租户拿到的数据结果互相不干扰。注意一定要并发跑因为串Schema这类问题往往在连接复用的时序下才暴露。3.3 行级隔离模式防全表扫描式泄露行级隔离是SaaS平台最常用的模式也是最容易出问题的。这里的Bug往往是漏了条件比如一条统计SQL忘了加where tenant_id ?结果把全平台的数据聚合到了报表里。针对这类问题光靠接口断言tenant_id是不够的。因为你只能看到接口返回的结果合不合法看不到执行过程。我的补充做法是在测试环境开启数据库的慢日志和全SQL审计。对关键的高危接口报表、导出、检索用例执行完后去审计日志里捞SQL。用正则匹配检查涉及的查询SQL是否都携带了tenant_id条件。def assert_sql_has_tenant_condition(sql_text: str): # 简单检查SELECT / UPDATE / DELETE 语句里必须出现 tenant_id 过滤 normalized .join(sql_text.split()) if normalized.upper().startswith((SELECT, UPDATE, DELETE)): assert tenant_id in normalized.lower(), f高危SQL缺少租户条件: {sql_text}这招非常暴力但很有效直接给代码里偷懒的人上眼药。当然了这个检查只适合测试环境生产环境开审计要谨慎。3.4 数据污染检测与测试后清场多租户测试最烦人的一个问题数据污染。用例A创建了一条租户X的数据没清理用例B跑的时候查询列表里多出一条来源不明的数据断言失败但排查半天发现根本是别人留下的垃圾。我们的解决方案是把清场变成一个一等公民而不是一个可选的teardown。每个租户在测试开始时都会打一个运行标识run_id测试过程中创建的所有业务数据都通过请求头或测试数据工厂带上这个标识。清理任务就一句话删除所有run_id 当前批次的记录。pytest.fixture(autouseTrue) def clean_tenant_data(api): run_id frun_{uuid.uuid4().hex[:8]} api.run_id run_id yield run_id # 清理只删本批次、本租户的数据 data_cleaner.delete_all(tenant_idapi.tenant_id, run_idrun_id)4. 多租户并行测试抢同一份数据的用例才是真正的坑当你把租户上下文改成可配置之后第一个想到的优化一定是既然现在有多个租户了那测试能不能并行跑能但这里藏着一个大坑如果你只有一套测试租户并行等于自杀。4.1 租户池动态租用与归还我们最初的方案是给每个测试进程都配一个独立的租户。CI上起了10个pytest worker就给10个租户。但租户数量不可能无限增长于是我们做了一个简单的租户池——每个租户同时只能被一个worker租用用完之后归还。租户池的实现可以很简单不需要专门的中间件。我们把租户池做成一个独立的进程或服务用文件锁或Redis来标记租户的占用状态。测试启动时worker向租户池申请租户结束后归还如果池子空了就排队等待。class TenantPool: def __init__(self, config_path: str): self._pool load_tenant_config(config_path) self._lock threading.Lock() self._leased: set[str] set() def lease(self) - TenantConfig: with self._lock: for tenant in self._pool: if tenant.id not in self._leased: self._leased.add(tenant.id) return tenant raise RuntimeError(租户池已耗尽请等待其他worker释放) def release(self, tenant_id: str) - None: with self._lock: self._leased.discard(tenant_id)租户池的关键不是实现本身而是池子的管理策略一个租户里同时只跑一个用例组避免同一租户的数据被两个进程同时修改。我们用到pytest-xdist时每个worker拿到的租户都不同数据天然隔离并行速度和心理负担都会好很多。4.2 用例数据打标签清理不用全库扫并行环境下最怕清理误伤。我们规定凡是测试创建的数据名字或描述里都必须加上租户ID和用例ID。举例来说创建一个订单订单号就带上t_enterprise_case_1012。这样清理时只需要按订单号前缀去删除不影响其他租户的数据。标记本身还能反向帮助定位问题。如果测试失败后想复盘数据只要搜索用例ID就能找到该用例在这轮跑批里创建的所有记录。4.3 并行速率与隔离性的取舍并行不是越多越好。我们在实际压测中发现当同一时间有超过30个租户在跑读写类用例时测试环境的数据库连接池会成为瓶颈。所以后来给测试环境加了独立的资源配额同时控制并行度——不是跑不动而是你不想让测试环境先于代码挂掉否则你很难区分到底是代码问题还是环境资源问题。一个折中的方案是读写分离。把只读类用例跑在共享的副库上把写操作限制在独立租户库上既能提高并行度又把互相干扰的窗口降到最低。5. 水平越权自动化检测用差分比对扫出每个接口的越权多租户系统里最隐蔽的一类漏洞是水平越权——租户A的普通用户访问租户B的资源ID如果系统只校验这个资源存在不存在而没有校验这个资源属于谁那就中招了。这类漏洞靠人工点测很难测全因为接口数量太多。我写了一个偏向差分比对的自动化越权扫描器思路很简单同一个接口分别用资源属主和外来租户的身份去请求对比响应差异。5.1 越权测试的本质换个身份访问别人的资源先明确一个原则访问别人的资源期望结果只有两种——403或者404。有些系统会刻意返回404来防止资源存在性泄露这也算安全。但如果系统返回了200和数据或者返回了500说明在鉴权之前就在代码里抛了异常都值得标红。越权检测的用例结构可以统一为两段式def test_horizontal_authorization(owner_client, intruder_client, resource_factory, api_spec): for endpoint in api_spec.resource_endpoints: # 第一步资源属主创建一个资源 resource resource_factory.create(owner_client, endpoint) # 第二步入侵者尝试访问该资源 resp intruder_client.request(GET, endpoint.format(idresource[id])) assert resp.status_code in (403, 404), \ f[{endpoint}] 越权访问未被拦截, 状态码: {resp.status_code}这个用例要把resource_factory做得足够智能知道每个接口需要什么前置数据。我们是从Swagger文档里解析出需要id参数的接口列表然后自动构造资源的。初期可以只覆盖最核心的30个接口优先选那些涉及订单、报表、个人敏感信息的。5.2 自动化扫描器的设计思路这里我给一个更完整的思路。很多团队说我们也测了越权但其实是拿登录态A去手动点一下列表页看到没数据就以为安全了。这完全不够。真正的越权扫描应该做到对象级直查拿着别人的资源ID直接访问详情接口。列表级越权换个租户的身份翻列表看能不能翻出不属于自己的数据。写操作越权用A的身份去修改/删除B的资源。批量越权把ID改成别的租户的ID再叠加另一个参数比如批量导出限制条件一少就出问题。在报告维度上我们最终按接口聚合而不是用例聚合来输出。同一个接口下如果出现3次越权就说明这个接口是重灾区需要提级修复。这块在CI流程里就是一条硬门禁。5.3 结果去重与误报处理越权扫描器最大的痛点不是漏报而是误报。比如系统里有公共资源的设计——企业公开的商品信息任何租户都应该可以看到。这类接口如果被扫描器判定为越权那就是误报。我们的做法是在配置文件里维护一个白名单凡是标记为public的接口直接跳过校验。同时扫描器的入侵者身份不固定尽量选择与资源属主具有相同角色权限的租户用户避免因为角色差异导致的不合理拦截被误判为安全——这一点其实恰恰是问题所在。我们更关注的是同一个角色在不同租户之间能不能访问对方的资源。如果连这个都拦不住那就是真正的水平越权。6. 租户配置矩阵企业版和免费版测的不是同一套系统多租户系统还有一个折磨人的特性不同租户的配置不一样。有些租户是免费版、有些是专业版、有些是企业版功能开关、配额上限、报表维度全都不一样。严格来说这些不是同一个系统而是同一套代码多套配置的系统。6.1 配置差异带来的组合爆炸假设系统有8个功能开关、4档套餐、3种业务场景全排列就是96种组合。如果每种组合都写一套用例测试套件会膨胀到无法维护。我们的做法是先按风险分类高危开关影响数据可见性例如跨门店数据合并、影响操作权限例如批量删除、影响计费例如导出次数上限。低危开关只是UI展示层面例如首页banner是否显示。高危开关必须全覆盖低危开关做抽样。6.2 按风险等级做Pairwise精简对于需要覆盖的开关组合我们用Pairwise的思路来精简。Pairwise的核心假设是绝大多数Bug是由两个因素的交互触发的三个及以上因素同时作用的概率很低。所以不需要全组合只需要保证任意两个因素的取值都至少同时出现过一次。举例来说套餐有4档免费/专业/企业/旗舰其中一个关键开关是高级报表是否开启——免费版强制关闭旗舰版强制开启只有专业版和企业版允许动态切换。那么测试组合可以取套餐高级报表开关数据保留周期期望行为免费版off30天报表页隐藏免费版on非法90天被强制纠正为off专业版on30天报表可导出专业版off无限期非法配额报错企业版on无限期报表可看不可导旗舰版on无限期全功能可用这样一张矩阵表贴进需求文档里产品、开发、测试三方对着同一张表说话比我见过的大部分功能点清单有用得多。6.3 配置快照与动态切换测试要执行这些组合前提是能快速把租户的配置改到目标状态。我们给每个测试租户保存了一份配置快照JSON文件。每次执行用例前通过管理后台的API把配置推送到测试租户上用例结束后再恢复快照避免影响下一批用例。{ tenant_id: t_enterprise, plan: enterprise, features: { advanced_report: true, batch_delete: true, audit_log: true }, quota: { export_limit_per_day: 1000, member_limit: 200 } }在fixture里做一次配置重置比在每个用例里手动改配置要可靠得多。这也是我在前几次项目中踩过的坑有个用例把专业版租户改成了企业版配置后面几十个用例全挂了因为大家共用同一个租户。7. 最后分享一点体系建设的体会多租户自动化测试的完整体系绝不是一个测试框架能解决的也不存在一个开箱即用的工具能包打天下。它需要你同时处理身份、数据、并行、安全、配置五个维度的交叉问题。我在实践中最大的体会有三点第一从测试数据下手而不是从测试用例下手。多租户测试的复杂度主要是数据复杂度。如果能让每个测试用例天然地运行在一个带租户身份的数据环境里大部分隔离问题都会自己暴露出来。第二把隔离校验做成默认能力。不要指望测试人员在每个用例里手动去断言租户字段而应该像assert_tenant_scope这样的工具一样在客户端基座层自动附加校验。默认能力才有人用靠自觉的都是摆设。第三线上巡检和CI要分开设计。CI里跑的是确定性的、构造数据的功能用例线上巡检跑的是只读的、基于真实数据的越权和上下文扫描。两者的目标完全不同不要试图用一套用例覆盖两边。多租户测试的进阶之路没有终点因为业务的隔离策略一直在变。但只要你把上下文穿透、数据隔离、并行调度、越权扫描这几个底座打牢了后面碰到任何新功能要做的只是往底座上挂新的用例而已。
返回列表