遥测表 llm_calls 缺少租户维度,多租户调用方无法在数据库层隔离 #11

Closed
opened 2026-08-17 16:18:33 +08:00 by iomgaa · 2 comments
Owner

背景

GovDoc-SaaS 是一个多租户 SaaS(法律文书分析),准备启用 PGW_TELEMETRY_BACKEND=postgres
落库本身正是我们要的——它是审计链里「这次模型调用到底发生了什么」的那一半证据,
另一半(业务事实)在我们自己的库里,两边靠 call_id 缝合。

但当前这张表没法用在多租户场景下,原因在下面。

现状

llm_calls 的 22 列里没有任何租户/调用方维度(src/polygateway/telemetry/postgres.py:28-52)。
能用来区分来源的只有 session_idparent_call_id,而这两个是调用方自填、库内不校验的自由字符串。

同时这张表存的是完整正文messages 落库前只过 digest_messages,而它只对多模态 part 里的
image_url 做 sha256,纯文本原样透传(src/polygateway/middleware/cache.py:31-44
写入点 src/polygateway/middleware/telemetry.py:209,218)。response 同理。
Embedding 路径有 200 字符上限(embedding.py:72),LLM 路径没有。

也就是说:多个租户的完整法律文书正文会混在同一张表里,而表结构本身不提供按租户过滤的能力。

为什么这是阻塞项,而不是「以后优化」

「靠 session_id 前缀区分」不够。那是字符串不是列,挂不上行级安全策略(RLS),
数据库层面没有任何东西阻止一次漏写过滤条件的查询读到别的租户的正文——而且那种查询不会报错,
它成功返回,只是多返回了不该看的东西。

更关键的是这件事不可逆:如果先启用、后加列,那么补列之前写进去的每一行都没有租户归属,
后加的列对老数据是空的,事后无法还原哪一行属于谁。而那些行里是客户合同和标书全文。

所以我们在自己的设计里定了一条顺序约束:本项目第一次把遥测指向数据库之前,
PolyGateway 必须已经支持租户维度;在那之前配置为不落库。

期望

  1. llm_calls 增加一个真实的租户列(不是把租户藏在 session_id 里)。
    看起来可以复用现有的补列机制(telemetry/postgres.py:57-60_BACKFILL);
  2. 调用方要有办法把租户标识传进去,GatewayClient.chat()EmbeddingClient.embed() 两条路径都需要;
  3. 该列要能挂 RLS 策略,或至少能作为复合索引和查询条件;
  4. 这张表既然存全文,建议同时考虑保留期与访问控制——「无限期保留全部租户全文、
    且任何能连库的账号都能读」不应是默认状态。这一条可以另开 issue,不必和前三条绑在一起。

具体列名、参数形状和是否提供 RLS 策略由本仓库决定,我们按发布出来的公共契约接入并锁版本。

有一条我们没有提

不要求对错误响应体做脱敏。 保留响应体是 #10 有意加的可诊断性设计,那段内容对排障有真实价值;
即使它可能包含上游回显的请求内容,脱敏也应该发生在记录的那一端而不是产生的那一端。
网关保持原样是对的。


提出方:GovDoc-SaaS,对应设计记录 research-wiki/design/0010-model-call-tenant-boundary.md
代码位置引用基于 2026-08-17 的 main(版本 1.2.0)。

## 背景 GovDoc-SaaS 是一个多租户 SaaS(法律文书分析),准备启用 `PGW_TELEMETRY_BACKEND=postgres`。 落库本身正是我们要的——它是审计链里「这次模型调用到底发生了什么」的那一半证据, 另一半(业务事实)在我们自己的库里,两边靠 `call_id` 缝合。 但当前这张表没法用在多租户场景下,原因在下面。 ## 现状 `llm_calls` 的 22 列里没有任何租户/调用方维度(`src/polygateway/telemetry/postgres.py:28-52`)。 能用来区分来源的只有 `session_id` 和 `parent_call_id`,而这两个是调用方自填、库内不校验的自由字符串。 同时这张表存的是**完整正文**:`messages` 落库前只过 `digest_messages`,而它只对多模态 part 里的 `image_url` 做 sha256,纯文本原样透传(`src/polygateway/middleware/cache.py:31-44`, 写入点 `src/polygateway/middleware/telemetry.py:209,218`)。`response` 同理。 Embedding 路径有 200 字符上限(`embedding.py:72`),LLM 路径没有。 也就是说:多个租户的**完整**法律文书正文会混在同一张表里,而表结构本身不提供按租户过滤的能力。 ## 为什么这是阻塞项,而不是「以后优化」 「靠 session_id 前缀区分」不够。那是字符串不是列,挂不上行级安全策略(RLS), 数据库层面没有任何东西阻止一次漏写过滤条件的查询读到别的租户的正文——而且那种查询不会报错, 它成功返回,只是多返回了不该看的东西。 更关键的是这件事不可逆:如果先启用、后加列,那么补列之前写进去的每一行都没有租户归属, 后加的列对老数据是空的,事后**无法**还原哪一行属于谁。而那些行里是客户合同和标书全文。 所以我们在自己的设计里定了一条顺序约束:本项目第一次把遥测指向数据库之前, PolyGateway 必须已经支持租户维度;在那之前配置为不落库。 ## 期望 1. `llm_calls` 增加一个真实的租户列(不是把租户藏在 session_id 里)。 看起来可以复用现有的补列机制(`telemetry/postgres.py:57-60` 的 `_BACKFILL`); 2. 调用方要有办法把租户标识传进去,`GatewayClient.chat()` 与 `EmbeddingClient.embed()` 两条路径都需要; 3. 该列要能挂 RLS 策略,或至少能作为复合索引和查询条件; 4. 这张表既然存全文,建议同时考虑保留期与访问控制——「无限期保留全部租户全文、 且任何能连库的账号都能读」不应是默认状态。这一条可以另开 issue,不必和前三条绑在一起。 具体列名、参数形状和是否提供 RLS 策略由本仓库决定,我们按发布出来的公共契约接入并锁版本。 ## 有一条我们没有提 **不要求对错误响应体做脱敏。** 保留响应体是 #10 有意加的可诊断性设计,那段内容对排障有真实价值; 即使它可能包含上游回显的请求内容,脱敏也应该发生在记录的那一端而不是产生的那一端。 网关保持原样是对的。 --- 提出方:GovDoc-SaaS,对应设计记录 `research-wiki/design/0010-model-call-tenant-boundary.md`。 代码位置引用基于 2026-08-17 的 main(版本 1.2.0)。
Author
Owner

补充一句,免得上面的理由被当成「防外人」而显得夸张。

GovDoc-SaaS 当前的部署形态是日志和数据库都只有开发者能直接访问,所以「有人越权读到别的租户」不是主要威胁。这个 issue 的必要性主要在另外三处,都和有没有外人无关:

  1. 没有租户维度就没法按租户做任何事——客户要求删除自己的数据时找不出该删哪些行;想看某个客户最近的调用只能拿 session_id 前缀去猜;
  2. 它是防呆不是防攻击——有了列才挂得上 RLS,一次漏写过滤条件的查询不至于悄悄多返回一批数据;
  3. 不可逆性与威胁模型无关——先启用后加列的话,补列之前那些行永远没有归属,无论谁能看到它们都一样。

所以第 4 条(保留期与访问控制)的优先级可以降下来,前三条不变。

补充一句,免得上面的理由被当成「防外人」而显得夸张。 GovDoc-SaaS 当前的部署形态是日志和数据库都只有开发者能直接访问,所以「有人越权读到别的租户」不是主要威胁。这个 issue 的必要性主要在另外三处,都和有没有外人无关: 1. **没有租户维度就没法按租户做任何事**——客户要求删除自己的数据时找不出该删哪些行;想看某个客户最近的调用只能拿 session_id 前缀去猜; 2. **它是防呆不是防攻击**——有了列才挂得上 RLS,一次漏写过滤条件的查询不至于悄悄多返回一批数据; 3. **不可逆性与威胁模型无关**——先启用后加列的话,补列之前那些行永远没有归属,无论谁能看到它们都一样。 所以第 4 条(保留期与访问控制)的优先级可以降下来,前三条不变。
Author
Owner

已在 1.2.1 实现并发布,registry 与 Releases 页均已可见。

四条诉求的处置

① 真实租户列llm_calls 新增 tenant_id TEXT NOT NULL DEFAULT '',是真实列不是 JSON 路径。旧表经既有 _BACKFILL 自动补列。

关于你提到的不可逆性: 论点成立,但落点与设想的略有不同,补列后老行读回是空串而非 NULL。这个区别是刻意的:PG 的 RLS USING 表达式返回 false 或 null 的行都不可见,且静默跳过不报错,所以 NULL 的 tenant_id 不是"未归属",而是对所有人永久不可见的黑洞,连想审计它的管理员都看不到。哨兵空串则能被一条 COUNT(*) WHERE tenant_id = '' 查出还有多少行待归属。这条已作为集成测试的机械化验收(手工建 22 列旧表 → 用当前 recorder 打开 → 断言老行为空串)。

② 两条路径 — 实际覆盖三条。除 chat()embed() 外,OcrClientrecognize_text()/parse_layout() 也经同一 emitter 写遥测且行落同一张表,只做两条会让同表内一部分行永远无归属,不可逆性论证对它们同样成立。四个公共方法各新增 tenant_idmeta 两个带默认值的 keyword-only 参数,既有调用点零改动。

③ 可挂 RLS / 可做复合索引 — 满足。但请注意边界见下。

④ 保留期与访问控制 — 按你说的另开,已建 #12

通用维度容器

按内部讨论把范围放大成了通用能力: 除 tenant_id 外新增 meta 列(PG 用 JSONB,SQLite 用 TEXT),承载任意调用方自定义 KV。校验规则: 最多 16 键、键须匹配 [a-z0-9_.]{1,64} 且不得用 pg_ 保留前缀、值仅限 str/int/float/bool(float 须有限)、字符串值 ≤256、tenant_id ≤128 且不得含首尾空白。超限一律报错,不静默丢弃,报错点在四个公共入口而非遥测写入点——遥测层的失败都会被降级成 warning,校验放那里等于没有校验。

meta 默认不建索引。需要按某个 key 高效筛选时,用表达式索引 CREATE INDEX CONCURRENTLY ON llm_calls ((meta->>'k')) 或库外建视图,由你们决定。

tenant_idmeta 都不进缓存 key——租户隔离已由既有的 cache_namespace 负责并已在 key 里。

为什么 tenant_id 必须提列而不是塞进 JSON

调研过纯 JSON 方案,否决理由是两条独立实证:① RLS 策略挂 meta->>'tenant_id' 语法合法,但 PG 的 Planner Statistics and Security 规则在 RLS 场景下对非 LEAKPROOF 函数当作没有统计信息来规划,而 ->> 未标 leakproof(pgsql-general 有实证案例,报告者最终的解法就是把索引列改成非 JSONB);② 与 RLS 无关的独立问题——planner 对 JSONB 本就没有可用统计,@> 走硬编码 0.1% 选择率。两者都在真实数据量下不可预测地退化,且退化点极难诊断。

需要你们 DBA 做的事(库不会代劳)

库只交付列,不建索引、不启用 RLS。 不执行下面的 DDL,tenant_id 就只是一个普通列,没有数据库层强制。

不自动启用的理由: 启用 RLS 而无匹配 policy 是 default-deny(零行可写且静默不报错)。三个下游里只有你们是多租户,库若自动启用,另两家升级后遥测会全量写失败,再叠加"遥测写失败降级为 warning"的铁律,就是无声丢数据。

完整模板与三个陷阱已写进 README 的「多租户与自定义维度」一节(随 sdist 分发,pip install 后可读)。三个陷阱简述:表属主默认豁免 RLS 故需 FORCE;租户上下文必须在显式事务内set_config(..., true)——asyncpg 默认 autocommit,单发 SET LOCAL 会当场失效而 PG 只发 warning 不报错,表现为 fail-closed 到零行;policy 必须同时写 USINGWITH CHECK,只写前者则租户 A 能插入标着 B 的行。

升级

pip install -U "polygateway>=1.2.1,<2"。无需改动既有调用点;要用维度时给相应方法传 tenant_id= / meta= 即可。

已在 **1.2.1** 实现并发布,registry 与 Releases 页均已可见。 ## 四条诉求的处置 **① 真实租户列** — `llm_calls` 新增 `tenant_id TEXT NOT NULL DEFAULT ''`,是真实列不是 JSON 路径。旧表经既有 `_BACKFILL` 自动补列。 **关于你提到的不可逆性**: 论点成立,但落点与设想的略有不同,补列后**老行读回是空串而非 NULL**。这个区别是刻意的:PG 的 RLS `USING` 表达式返回 **false 或 null 的行都不可见,且静默跳过不报错**,所以 NULL 的 `tenant_id` 不是"未归属",而是**对所有人永久不可见的黑洞**,连想审计它的管理员都看不到。哨兵空串则能被一条 `COUNT(*) WHERE tenant_id = ''` 查出还有多少行待归属。这条已作为集成测试的机械化验收(手工建 22 列旧表 → 用当前 recorder 打开 → 断言老行为空串)。 **② 两条路径** — 实际覆盖**三条**。除 `chat()` 与 `embed()` 外,`OcrClient` 的 `recognize_text()`/`parse_layout()` 也经同一 emitter 写遥测且行落同一张表,只做两条会让同表内一部分行永远无归属,不可逆性论证对它们同样成立。四个公共方法各新增 `tenant_id` 与 `meta` 两个带默认值的 keyword-only 参数,既有调用点零改动。 **③ 可挂 RLS / 可做复合索引** — 满足。但请注意边界见下。 **④ 保留期与访问控制** — 按你说的另开,已建 **#12**。 ## 通用维度容器 按内部讨论把范围放大成了通用能力: 除 `tenant_id` 外新增 `meta` 列(PG 用 `JSONB`,SQLite 用 `TEXT`),承载任意调用方自定义 KV。校验规则: 最多 16 键、键须匹配 `[a-z0-9_.]{1,64}` 且不得用 `pg_` 保留前缀、值仅限 `str`/`int`/`float`/`bool`(float 须有限)、字符串值 ≤256、`tenant_id` ≤128 且不得含首尾空白。**超限一律报错,不静默丢弃**,报错点在四个公共入口而非遥测写入点——遥测层的失败都会被降级成 warning,校验放那里等于没有校验。 `meta` **默认不建索引**。需要按某个 key 高效筛选时,用表达式索引 `CREATE INDEX CONCURRENTLY ON llm_calls ((meta->>'k'))` 或库外建视图,由你们决定。 `tenant_id` 与 `meta` **都不进缓存 key**——租户隔离已由既有的 `cache_namespace` 负责并已在 key 里。 ## 为什么 tenant_id 必须提列而不是塞进 JSON 调研过纯 JSON 方案,否决理由是两条独立实证:① RLS 策略挂 `meta->>'tenant_id'` 语法合法,但 PG 的 *Planner Statistics and Security* 规则在 RLS 场景下对非 LEAKPROOF 函数**当作没有统计信息**来规划,而 `->>` 未标 leakproof(pgsql-general 有实证案例,报告者最终的解法就是把索引列改成非 JSONB);② 与 RLS 无关的独立问题——planner 对 JSONB 本就没有可用统计,`@>` 走硬编码 0.1% 选择率。两者都在真实数据量下不可预测地退化,且退化点极难诊断。 ## 需要你们 DBA 做的事(库不会代劳) **库只交付列,不建索引、不启用 RLS。** 不执行下面的 DDL,`tenant_id` 就只是一个普通列,没有数据库层强制。 不自动启用的理由: 启用 RLS 而无匹配 policy 是 **default-deny**(零行可写且静默不报错)。三个下游里只有你们是多租户,库若自动启用,另两家升级后遥测会全量写失败,再叠加"遥测写失败降级为 warning"的铁律,就是无声丢数据。 完整模板与三个陷阱已写进 **README 的「多租户与自定义维度」一节**(随 sdist 分发,`pip install` 后可读)。三个陷阱简述:表属主默认**豁免** RLS 故需 `FORCE`;租户上下文必须在**显式事务内**用 `set_config(..., true)`——asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效而 PG 只发 warning 不报错,表现为 fail-closed 到零行;policy 必须同时写 `USING` 与 `WITH CHECK`,只写前者则租户 A 能插入标着 B 的行。 ## 升级 `pip install -U "polygateway>=1.2.1,<2"`。无需改动既有调用点;要用维度时给相应方法传 `tenant_id=` / `meta=` 即可。
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: iomgaa/PolyGateway#11