4.2 授权与租户隔离:workspace 行级隔离
4.1 的认证回答了「你是谁」,但知道你是谁不等于知道你能碰什么。ResearchCopilot 是多租户服务:一个部署同时服务很多团队,每个团队的会话、运行、记忆都必须互不可见。这一节把「授权」落成一个具体的工程约束——每一条数据查询都必须带租户条件,一次漏掉,就是一次跨租户数据泄露。
本节把 ResearchCopilot 推进到「租户隔离」:确立
workspace_id作为所有资源的强制维度,用真连的本机 Postgres 演示行级隔离,并定下「越权访问返回 404 而非 403」这条反信息泄露纪律。
4.2.1 租户模型:workspace_id 是每个资源的隐形主键
最直观的租户模型有三层:user → workspace → resource。一个用户可以属于多个 workspace,一个 workspace 下有会话、运行、记忆、工具凭证等资源。关键设计决定是:资源直接挂在 workspace 上,而不是挂在 user 上。
user ──< membership >── workspace ──< runs / sessions / memories / api_keys
为什么不让资源挂 user?因为「团队共享」是研究助理的刚需——A 同事跑的报告,B 同事要能看。如果资源挂在 user 下,共享就得再加一张授权表,复杂度陡增。挂在 workspace 下,共享就退化成「同一 workspace 的成员天然可见」,简单且不易错。
于是 workspace_id 就成了每个资源表都必须有的列,而且它不属于「业务字段」,属于「访问控制字段」:
| 资源 | 必须带的租户列 | 说明 |
|---|---|---|
runs(运行) | workspace_id | 一次提问的执行记录 |
sessions(会话) | workspace_id | 多轮对话容器 |
memories(记忆) | workspace_id | 长期记忆条目 |
api_keys | workspace_id | key 归属于哪个租户 |
把 workspace_id 当成「隐形主键」的意思是:任何按 id 取资源的查询,实际主键是 (workspace_id, id) 的复合。 单看 id 唯一,但访问路径上必须带上租户。下面这节就是把这条规则写成代码。
4.2.2 为什么是 404,而不是 403
这是租户隔离里最容易做错、后果又最隐蔽的一个决定。直觉上,「资源存在但不属于你」应该返回 403 Forbidden,「资源不存在」返回 404 Not Found。听起来很合理,其实是个信息泄露漏洞:
如果越权返回 403、不存在返回 404,攻击者就能用一个 id 探测出「这个 id 到底存不存在」——403 意味着「存在,只是你没权限」,404 意味着「压根没有」。
这个差异在单租户系统里无所谓,在多租户系统里就是致命的:攻击者可以遍历 id,统计出每个租户有多少资源、哪些 id 是活跃的,甚至据此推断业务规模。正确做法是把「不属于你」伪装成「不存在」:无论资源真的不存在,还是属于别的租户,一律返回同一个 404 run_not_found。攻击者从响应里分辨不出这两种情况。
| 情况 | 直觉做法 | 正确做法 |
|---|---|---|
| 资源存在且属于本租户 | 200 | 200 |
| 资源存在但属于别的租户 | 403 | 404 |
| 资源根本不存在 | 404 | 404 |
一句话记牢:授权失败要伪装成「不存在」。 这条纪律的唯一例外是「你确实是这个租户的成员、但角色不够」(比如只读成员想删除)——那种情况资源归属无争议,用 403 不会泄露别租户信息。
4.2.3 行级隔离:把租户条件写进 SQL
隔离的落点非常具体:每一条查询的 WHERE 里都必须有 workspace_id = %s。这不是靠调用方自觉,而是靠仓储层(repository)强制。下面是一个最小仓储:
# api/repo.py
class RunRepo:
"""所有查询强制带 workspace_id —— 租户边界写在 SQL 里,而不是靠调用方自觉。"""
def __init__(self, conn):
self.conn = conn
def get(self, workspace_id: str, run_id: str):
return self.conn.execute(
"select id, workspace_id, question from runs "
"where workspace_id = %s and id = %s",
(workspace_id, run_id),
).fetchone() # 不属于该租户 -> None,上层映射成 404
def get_unsafe(self, run_id: str):
# 反例:忘了 where workspace_id,只按主键取 —— 跨租户泄露
return self.conn.execute(
"select id, workspace_id, question from runs where id = %s",
(run_id,),
).fetchone()
设计上有三个要点:
- 租户参数是方法签名的第一个位置参数,让「忘了传」在类型检查阶段就暴露,而不是运行时静默全表扫。
get返回None而不是抛异常,把「404 还是 200」的决定留给上层——因为「不存在」和「不属于你」要映射成同一个结果。- 参数化查询(
%s占位)顺带挡住了 SQL 注入,run_id直接拼进 SQL 是另一条红线。
get_unsafe 是故意留的反例,下面真跑会看到它泄露了什么。真实项目里,这种「按主键取、不带租户」的方法最容易在重构时被某个粗心的调用方用上,所以更好的做法是根本不提供——所有取数方法都强制要求 workspace_id。
4.2.4 真跑:A 租户访问 B 租户资源被拒
下面用真连的本机 Postgres 跑一遍。为了不污染库,用临时表(连接断开即消失)。若本机没有该容器,先起一个:
docker run --name ab-pg -e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=research -p 15435:5432 -d postgres:17-alpine
# demo_42.py
import psycopg
from psycopg.rows import dict_row
DSN = "postgresql://postgres:postgres@localhost:15435/research"
with psycopg.connect(DSN, row_factory=dict_row) as conn:
conn.execute("create temp table runs_demo "
"(id text primary key, workspace_id text not null, question text)")
conn.execute("insert into runs_demo values "
"('run_100','ws_acme','ResearchCopilot 是什么'),"
"('run_200','ws_globex','竞品分析')")
repo = RunRepo(conn)
def fetch(ws, run_id):
row = repo.get(ws, run_id)
if row is None:
return 404, {"code": "run_not_found", "message": "运行不存在", "details": {}}
return 200, row
for label, ws, run_id in [
("A 租户取自己的 run", "ws_acme", "run_100"),
("B 租户取 A 的 run", "ws_globex", "run_100"),
("A 租户取不存在的 run", "ws_acme", "run_999"),
]:
code, body = fetch(ws, run_id)
print(f"{label:<24} {code} {body}")
本机实测(Python 3.14.6 / psycopg 3.3.6 / PostgreSQL 17.11)输出:
A 租户取自己的 run 200 {'id': 'run_100', 'workspace_id': 'ws_acme', 'question': 'ResearchCopilot 是什么'}
B 租户取 A 的 run 404 {'code': 'run_not_found', 'message': '运行不存在', 'details': {}}
A 租户取不存在的 run 404 {'code': 'run_not_found', 'message': '运行不存在', 'details': {}}
忘了 where 的反例 泄露 {'id': 'run_100', 'workspace_id': 'ws_acme', 'question': 'ResearchCopilot 是什么'}
三行读下来,隔离的效果很清楚:ws_globex 去取 ws_acme 的 run_100,拿到的和「取一个根本不存在的 id」完全一样——都是 404 run_not_found。攻击者无法从这两个响应里区分「这是别人的」还是「没有」。而最后一行 get_unsafe 展示了漏掉 where 的后果:它直接把 ws_acme 的数据原样吐了出来,workspace_id 都懒得藏。
4.2.5 隔离失效的三个高频坑
租户隔离的 bug 几乎都长这样,记下来能省很多事故:
坑一:忘了加 where 条件。 最典型的是「先按 id 取、再在内存里判断 workspace_id」:
row = repo.get_unsafe(run_id) # 已经把数据取出来了
if row and row["workspace_id"] == ws: # 判断在应用层
return row
return 404
逻辑上「结果一样」,但它把别的租户的整行数据读进了本进程——一旦日志、异常栈、调试器打印了这行,数据就泄露了。租户条件必须在数据库里过滤,不能让越权数据进入应用内存。
坑二:缓存 key 没带租户。 检索结果缓存最常见的写法是 cache.set(run_id, result)。可 run_id 在不同租户下可能重名(尤其 id 用自增或用户可控时),于是 A 租户的缓存会被 B 租户命中,读到别人的结果。缓存 key 必须包含 workspace_id:cache.set(f"{ws}:{run_id}", result)。这条在第 6 章会再强调一次。
坑三:后台任务用了错误的租户上下文。 worker 从队列里取任务时,如果把「当前请求的租户」存在某个全局/线程局部变量里,任务在另一个上下文里执行就会串号。正确做法是把 workspace_id 显式塞进任务 payload,worker 从 payload 里读,绝不依赖任何「隐式当前租户」。
| 坑 | 症状 | 防线 |
|---|---|---|
忘记 where | 越权数据进入内存/日志 | 仓储层强制参数,不提供裸 id 取数 |
| 缓存 key 不带租户 | 跨租户读到别人的缓存 | key 前缀 {workspace_id}: |
| 后台任务串租户 | 任务在错的租户下执行 | 租户随 payload 显式传递 |
4.2.6 纵深防御:数据库兜底的行级安全(RLS)
应用层写 where 是第一道防线,但它依赖人不忘。第二道防线是让数据库自己过滤——Postgres 的行级安全(Row-Level Security, RLS)。开启 RLS 后,一条策略就把「只能看到本租户的行」变成数据库的硬约束,应用哪怕漏写 where 也拿不到越权数据。
真跑一遍(同样真连本机 Postgres)。注意:Postgres 超级用户默认绕过 RLS,所以演示里专门建一个非超级用户角色 rls_app 来扮演应用连接:
# demo_42_rls.py(节选)
with psycopg.connect(DSN) as conn:
conn.execute("create table runs_rls "
"(id text, workspace_id text not null, question text)")
conn.execute("insert into runs_rls values "
"('run_100','ws_acme','ResearchCopilot 是什么'),"
"('run_200','ws_globex','竞品分析')")
conn.execute("create role rls_app nologin")
conn.execute("grant usage on schema public to rls_app")
conn.execute("grant select on runs_rls to rls_app")
conn.execute("alter table runs_rls enable row level security")
conn.execute("create policy ws_iso on runs_rls "
"using (workspace_id = current_setting('app.workspace_id', true))")
conn.execute("set role rls_app") # 扮演应用连接
conn.execute("set app.workspace_id = 'ws_acme'")
print("ws_acme 全表扫 ->", conn.execute(
"select id, workspace_id from runs_rls order by id").fetchall())
conn.execute("set app.workspace_id = 'ws_globex'")
print("ws_globex 全表扫 ->", conn.execute(
"select id, workspace_id from runs_rls order by id").fetchall())
print("ws_globex 忘写 where 直取 run_100 ->", conn.execute(
"select id, workspace_id from runs_rls where id = 'run_100'").fetchall())
本机实测输出:
ws_acme 全表扫 -> [('run_100', 'ws_acme')]
ws_globex 全表扫 -> [('run_200', 'ws_globex')]
ws_globex 忘写 where 直取 run_100 -> []
三行各有含义:同一个 select(连 where 都没写)在 ws_acme 下只看到 run_100,切到 ws_globex 只看到 run_200;最狠的是最后一行——它明写了 where id='run_100',但 RLS 仍在背后加了一道 workspace_id 过滤,返回空。这就是「数据库兜底」:应用忘了,数据库替你记住。
RLS 的代价与注意点:
| 事项 | 说明 |
|---|---|
| 每个请求要设会话变量 | SET LOCAL app.workspace_id = ...,请求结束即失效 |
| 连接池要清理 | 归还连接前必须 RESET,否则下一个请求继承上一个租户 |
| 超级用户 / 表 owner 绕过 | 应用连接绝不能用超级用户,否则 RLS 形同虚设 |
| 性能 | 策略条件会进查询计划,需在 workspace_id 上建索引 |
第二行最容易翻车:连接池复用连接时,若忘了 RESET app.workspace_id,下一个请求就可能带着上一个租户的上下文跑——这正好是坑三「后台任务串租户」的数据库版本。用 SET LOCAL(而非 SET)会在事务结束时自动回滚,是更安全的选择。
4.2.7 真实环境下会怎样
演示用临时表和一个最小仓储,真实系统还要往下压三层:
| 演示做法 | 生产做法 | 收益 |
|---|---|---|
| 手写每条 SQL | ORM 全局 scope / 仓储基类 | 减少遗漏面 |
内存 API_KEYS | workspace_id 绑定 key + 审计 | 可追溯 |
| 临时表 + 手建 | 正式 schema + 迁移(第 5 章) | 可演进 |
RLS 是「纵深防御」的典型:应用层的租户条件是第一道,数据库策略是第二道,两道都过才拿到数据。它的运维代价(会话变量、连接池清理)留给第 5 章讲连接管理时展开。
验收清单:
- 所有资源表都有
workspace_id列且NOT NULL。 - 仓储层所有取数方法强制要求
workspace_id,不提供裸 id 入口。 - 越权访问返回 404(伪装成不存在),不是 403。
- 缓存 key、后台任务 payload 都显式携带
workspace_id。
到这里,「能给别人用」的门和围墙都有了。但还有一类风险不在数据边界上,而在动作本身:Agent 能调用的工具里,有些是「删库」「发邮件」「转账」这种不可逆操作。下一节给工具按危险程度分档,让高危动作在真正执行前停下来等人点头。想回看认证怎么把 workspace_id 带进 principal,翻 4.1 认证:JWT 与 API Key 中间件
。
阅读导航:上一节:4.1 认证:JWT 与 API Key 中间件 · 下一节:4.3 工具权限与审批:高危工具二次确认 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。