遥测表 llm_calls 缺少租户维度,多租户调用方无法在数据库层隔离 #11
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
背景
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 必须已经支持租户维度;在那之前配置为不落库。
期望
llm_calls增加一个真实的租户列(不是把租户藏在 session_id 里)。看起来可以复用现有的补列机制(
telemetry/postgres.py:57-60的_BACKFILL);GatewayClient.chat()与EmbeddingClient.embed()两条路径都需要;且任何能连库的账号都能读」不应是默认状态。这一条可以另开 issue,不必和前三条绑在一起。
具体列名、参数形状和是否提供 RLS 策略由本仓库决定,我们按发布出来的公共契约接入并锁版本。
有一条我们没有提
不要求对错误响应体做脱敏。 保留响应体是 #10 有意加的可诊断性设计,那段内容对排障有真实价值;
即使它可能包含上游回显的请求内容,脱敏也应该发生在记录的那一端而不是产生的那一端。
网关保持原样是对的。
提出方:GovDoc-SaaS,对应设计记录
research-wiki/design/0010-model-call-tenant-boundary.md。代码位置引用基于 2026-08-17 的 main(版本 1.2.0)。
补充一句,免得上面的理由被当成「防外人」而显得夸张。
GovDoc-SaaS 当前的部署形态是日志和数据库都只有开发者能直接访问,所以「有人越权读到别的租户」不是主要威胁。这个 issue 的必要性主要在另外三处,都和有没有外人无关:
所以第 4 条(保留期与访问控制)的优先级可以降下来,前三条不变。
已在 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=即可。