docs: ship a production deployment template with its own test

README 的多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节: 三角色、
REVOKE + 触发器兜底、created_at RANGE 分区与 pg_partman retention、
库需要的最小权限、合规下游的推荐配置、截断覆盖面的诚实声明,以及
SQLite 侧按天轮转库文件的保留期建议。

模板 SQL 只有 README 里这一份: 集成测试用 HTML 注释锚点
(`<!-- pg-template:* -->`)把它解析出来,做受控标识符替换后在真实
PG 的临时 schema + 临时角色上逐条执行(doctest 同款范式)。测试里
另抄一份就会与 README 各自漂移,而"README 的 SQL 能跑"这个承诺只在
同源时才成立;解析不到必须当场红,故块名与占位符都显式钉死。

新增 5 条真实 PG 用例: app 能 INSERT 不能 UPDATE/DELETE(拿到的是
权限错而非触发器错)、report 只读、未设 app.tenant_id 时读为零行且
设了只见本租户、行落进当月分区、触发器拦得住 DELETE 却拦不住
DROP PARTITION(这是"清理只能走分区"的机械化依据)。

写侧 policy 定为 WITH CHECK (true) 而非等值比较: 库用一个连接池给
所有租户写遥测且从不发 set_config,把写侧绑到 GUC 上会让每条 INSERT
被拒,而遥测的失败方向是静默降级——表现是整表零行。
This commit is contained in:
2026-08-19 14:42:36 -04:00
parent 511aa4899c
commit daf7ab3268
2 changed files with 468 additions and 24 deletions
+172 -24
View File
@@ -127,30 +127,7 @@ resp = await client.chat(
存储上 `tenant_id` 两端都是 `TEXT NOT NULL DEFAULT ''`,`meta` 在 Postgres 是 `JSONB`、在 SQLite 是 `TEXT`;老表要补上这两列(补列是否由库自动执行取决于 `PGW_TELEMETRY_SCHEMA_MODE`,见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律)),**补列后老行读出是空串而非 NULL**(NULL 在任何 RLS policy 下都对所有人不可见,空串则可用一条 SQL 审出还有多少行待归属)。 存储上 `tenant_id` 两端都是 `TEXT NOT NULL DEFAULT ''`,`meta` 在 Postgres 是 `JSONB`、在 SQLite 是 `TEXT`;老表要补上这两列(补列是否由库自动执行取决于 `PGW_TELEMETRY_SCHEMA_MODE`,见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律)),**补列后老行读出是空串而非 NULL**(NULL 在任何 RLS policy 下都对所有人不可见,空串则可用一条 SQL 审出还有多少行待归属)。
**库只提供列,不启用 RLS、不建索引。** 数据库层的强制隔离,以下 DDL 是**下游 DBA 的职责,库不会代劳**;不执行则 `tenant_id` 只是一个可查可过滤的普通列,没有任何数据库层强制。库不代劳的原因是 default-deny:启用 RLS 而没有匹配的 policy = 零行可写且静默不报错,会让非多租户部署的遥测全量写失败。 **库只提供列,不启用 RLS、不建索引。** 数据库层的强制隔离是**下游 DBA 的职责,库不会代劳**;不执行则 `tenant_id` 只是一个可查可过滤的普通列,没有任何数据库层强制。库不代劳的原因是 default-deny:启用 RLS 而没有匹配的 policy = 零行可写且静默不报错,会让非多租户部署的遥测全量写失败。三角色、RLS policy、分区与保留期的完整可执行模板见[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)。
```sql
ALTER TABLE llm_calls ENABLE ROW LEVEL SECURITY;
ALTER TABLE llm_calls FORCE ROW LEVEL SECURITY; -- 属主不豁免
CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''))
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
```
```sql
CREATE INDEX CONCURRENTLY idx_llm_calls_tenant_created
ON llm_calls (tenant_id, created_at);
```
`current_setting(..., true)` 的第二参数令 GUC 未设时返回 NULL 而非抛错,外层 `NULLIF` 把空串归一为 NULL——合起来使**未设租户 = 零行**(fail-closed)而不是全部行。索引列序不可颠倒:启用 RLS 后 policy 给每条查询隐式追加 `tenant_id` 等值谓词,它出现在 100% 的谓词里,必然是前导列。
三个陷阱,每一个的失败形态都是**静默的**:
| 陷阱 | 后果 |
|---|---|
| 表属主默认**豁免** RLS | 只写 `ENABLE` 而漏 `FORCE`,用属主角色连库时隔离形同虚设,且查询一切正常看不出来 |
| 租户上下文必须在**显式事务内**用 `set_config('app.tenant_id', ..., true)` | asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效,而 PG **只发 warning 不报错**;表现是 policy 永远拿不到租户 → fail-closed 到零行 |
| policy 必须同时写 `USING``WITH CHECK` | 只写前者则租户 A 读不到 B 的行,却**能插入标着 B 的行**——污染发生在写入侧,读侧查不出来 |
## 遥测表 schema 与升级纪律 ## 遥测表 schema 与升级纪律
@@ -210,6 +187,176 @@ PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`,**整段可重复执行**
| 库从不 `SELECT *`,也从不读回这张表的数据 | 库侧根本没有读路径,你加索引、加自己的列、挂 RLS 都影响不到它 | | 库从不 `SELECT *`,也从不读回这张表的数据 | 库侧根本没有读路径,你加索引、加自己的列、挂 RLS 都影响不到它 |
| 写入的冲突处理**不绑定具体约束** | 你可以把 `llm_calls` 建成 `PARTITION BY RANGE (created_at)` 的分区表(此时主键必须是 `(call_id, created_at)`,PG 要求分区表唯一约束含分区键),库的探测、补列与写入照常工作 | | 写入的冲突处理**不绑定具体约束** | 你可以把 `llm_calls` 建成 `PARTITION BY RANGE (created_at)` 的分区表(此时主键必须是 `(call_id, created_at)`,PG 要求分区表唯一约束含分区键),库的探测、补列与写入照常工作 |
## 生产部署 DDL 模板(PostgreSQL)
上一节讲的是**库怎么对待这张表**(只探测、只 INSERT、可选建表);本节讲的是**你该把这张表部署成什么样**:谁能读、谁能写、写进去的行能不能被改、存多久。这些库一件都不代劳——它没有、也不该有这些权限。
<!-- 下面带 `pg-template:*` 锚点的 SQL 块被 tests/integration/test_postgres_telemetry.py 逐条解析并在真实 PG 上执行;改动块内容或锚点名请同步该测试。 -->
模板按下表顺序执行,标识符(角色名、schema、分区月份、密码)按你的环境改;`llm_calls` 一律不写 schema 限定,靠 `search_path` 解析,与库的写入口径一致。
| # | 锚点 | 做什么 |
|---|---|---|
| 1 | `roles` | 建三角色并授 schema 级权限 |
| 2 | `table` | 把 `llm_calls` 改造成按 `created_at` 的 RANGE 分区表,属主归 `polygateway_owner` |
| 3 | `partition` | 建一个月分区(生产用 `pg_partman` 自动滚动) |
| 4 | `grants` | 授表级权限并 `REVOKE UPDATE, DELETE` |
| 5 | `immutable` | 触发器兜底(只防误操作) |
| 6 | `rls` | 启用并 `FORCE` RLS + 两条 policy |
| 7 | `index` | `(tenant_id, created_at)` 复合索引 |
### 1. 三角色
| 角色 | 拿到什么 | 谁在用 |
|---|---|---|
| `polygateway_owner` | 表属主:DDL、加分区、删分区 | DBA / 定时任务;**不用它连库跑业务** |
| `polygateway_app` | `INSERT` + 受 RLS 约束的 `SELECT` | 库的连接串用这个 |
| `polygateway_report` | 受 RLS 约束的 `SELECT` | BI、对账、成本报表 |
<!-- pg-template:roles -->
```sql
CREATE ROLE polygateway_owner NOLOGIN;
CREATE ROLE polygateway_app LOGIN PASSWORD 'CHANGE_ME_APP';
CREATE ROLE polygateway_report LOGIN PASSWORD 'CHANGE_ME_REPORT';
GRANT polygateway_owner TO CURRENT_USER; -- 下一块要把表属主改过去,须先成为它的成员
GRANT USAGE ON SCHEMA public TO polygateway_owner, polygateway_app, polygateway_report;
GRANT CREATE ON SCHEMA public TO polygateway_owner; -- 滚动分区要在该 schema 里建表
```
### 2. 分区表
分区表**必须下游先手工建**:库的 `CREATE TABLE` 只会建普通表。列不在这里重抄一份——抄了就会漂移,故先用库自带脚本建出普通表,再原地改造:
```bash
python -c "import polygateway; print(polygateway.telemetry_schema_sql('postgres'))" \
| psql "$PGW_TELEMETRY_PG_DSN"
```
<!-- pg-template:table -->
```sql
ALTER TABLE llm_calls RENAME TO llm_calls_seed; -- 上一步建出的普通表当模子
CREATE TABLE llm_calls (
LIKE llm_calls_seed INCLUDING DEFAULTS, -- 列/类型/NOT NULL/DEFAULT 全照搬
PRIMARY KEY (call_id, created_at) -- 分区表的唯一约束必须含分区键
) PARTITION BY RANGE (created_at);
DROP TABLE llm_calls_seed;
ALTER TABLE llm_calls OWNER TO polygateway_owner;
```
<!-- pg-template:partition -->
```sql
CREATE TABLE llm_calls_2026_01 PARTITION OF llm_calls
FOR VALUES FROM ('2026-01-01 00:00:00+00') TO ('2026-02-01 00:00:00+00');
ALTER TABLE llm_calls_2026_01 OWNER TO polygateway_owner;
```
生产不要手工滚月份,交给 [`pg_partman`](https://github.com/pgpartman/pg_partman):5.x 用 `create_parent(p_parent_table := 'public.llm_calls', p_control := 'created_at', p_interval := '1 month')`(4.x 的参数序不同,以你装的版本文档为准),再把 `part_config.retention` 设成 `'6 months'``retention_keep_table` 设成 `false`,`run_maintenance_proc()` 就会到期 `DROP` 整个分区。清理必须走 `DETACH`/`DROP PARTITION` 而**不是** `DELETE`——这不是性能偏好,是权限张力的唯一解:下一块要对应用角色 `REVOKE DELETE`,而 `DROP PARTITION` 是属主的 DDL,两者不冲突,`DELETE` 则必然冲突。
**分区部署改变了幂等键**,按 `cache_hit` 出报表的下游必须知道:普通表上主键是 `call_id`,分区表上是 `(call_id, created_at)`。库的写入是无冲突目标的 `ON CONFLICT DO NOTHING`,两种表形态都合法;但 `emit_cache_hit` 复用的是响应里的**历史** `call_id`,于是同一次缓存命中的重复回放,在普通表上第二次起被 `DO NOTHING` 吞掉、在分区表上**每次都落一行**(`created_at``DEFAULT now()` 生成,主键不再重复)。逐次尝试行不受影响(每次尝试都是新 `call_id`)。
### 3. 权限与不可变性
`llm_calls` 按**不可变审计表**对待:写进去的行谁都不许改、不许删,过期数据靠 `DROP PARTITION` 整块消失。
<!-- pg-template:grants -->
```sql
GRANT INSERT, SELECT ON llm_calls TO polygateway_app;
GRANT SELECT ON llm_calls TO polygateway_report;
REVOKE UPDATE, DELETE, TRUNCATE ON llm_calls FROM polygateway_app, polygateway_report;
```
<!-- pg-template:immutable -->
```sql
CREATE FUNCTION llm_calls_reject_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'llm_calls 是不可变审计表,% 被拒绝', TG_OP;
END;
$$;
CREATE TRIGGER llm_calls_immutable BEFORE UPDATE OR DELETE ON llm_calls
FOR EACH ROW EXECUTE FUNCTION llm_calls_reject_mutation();
```
触发器**只防误操作,不防恶意**:表属主可以 `ALTER TABLE llm_calls DISABLE TRIGGER llm_calls_immutable` 把它关掉。真正的强制是上一块的 `REVOKE`——权限检查发生在触发器之前,应用角色连触发器都碰不到。要防属主本人,需要的是数据库之外的手段(WAL 归档、只追加的外部存证),不是本表能解决的。
`DROP PARTITION``DETACH PARTITION` 是 DDL,**不会触发**行级触发器,故保留期清理不受这一块影响。
### 4. 行级安全与多租户隔离
<!-- pg-template:rls -->
```sql
ALTER TABLE llm_calls ENABLE ROW LEVEL SECURITY;
ALTER TABLE llm_calls FORCE ROW LEVEL SECURITY; -- 属主不豁免
CREATE POLICY llm_calls_app_write ON llm_calls FOR INSERT TO polygateway_app
WITH CHECK (true);
CREATE POLICY llm_calls_app_read ON llm_calls FOR SELECT TO polygateway_app
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
CREATE POLICY llm_calls_report_read ON llm_calls FOR SELECT TO polygateway_report
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
```
<!-- pg-template:index -->
```sql
CREATE INDEX idx_llm_calls_tenant_created ON llm_calls (tenant_id, created_at);
```
`current_setting(..., true)` 的第二参数令 GUC 未设时返回 NULL 而非抛错,外层 `NULLIF` 把空串归一为 NULL——合起来使**未设租户 = 零行**(fail-closed)而不是全部行。索引列序不可颠倒:启用 RLS 后 policy 给每条查询隐式追加 `tenant_id` 等值谓词,它出现在 100% 的谓词里,必然是前导列。分区表上**不能**用 `CREATE INDEX CONCURRENTLY`(PG 不支持在分区父表上并发建索引);父表此时还没有数据,直接建即可,给已有数据的普通表补索引才需要逐个分区 `CONCURRENTLY`
**写侧 policy 为什么是 `WITH CHECK (true)` 而不是等值比较**:库用一个连接池给**所有**租户写遥测,且从不发 `set_config('app.tenant_id', ...)`(源码里没有这条语句)。把写侧也绑到 GUC 上,库的每一条 `INSERT` 都会被 policy 拒绝——而遥测的失败方向是静默降级,表现是逐行 warning + 整表零行。隔离在这个模型里由**读侧**承担:写入方是库自己(可信),读取方才是要隔离的人。若你的调用点保证每次调用都带 `tenant_id`,可把写侧收紧成 `WITH CHECK (tenant_id <> '')`,代价是漏传 `tenant_id` 的调用点会**丢遥测行**(只留一条 warning)。
四个陷阱,每一个的失败形态都是**静默的**:
| 陷阱 | 后果 |
|---|---|
| 表属主默认**豁免** RLS | 只写 `ENABLE` 而漏 `FORCE`,用属主角色连库时隔离形同虚设,且查询一切正常看不出来 |
| `FORCE` 之后属主自己也被 policy 管 | 模板没给 `polygateway_owner` 任何 policy,故它读不到、也写不进任何行——这是有意的(它只用来做 DDL),但别拿它跑报表 |
| 租户上下文必须在**显式事务内**用 `set_config('app.tenant_id', ..., true)` | asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效,而 PG **只发 warning 不报错**;表现是 policy 永远拿不到租户 → fail-closed 到零行 |
| 读侧 policy 漏写 `USING` | `FOR SELECT` 的 policy 只认 `USING`;写成 `WITH CHECK` 不报错也不生效,隔离直接落空 |
### 5. 库本身需要的最小权限
按上面的模板部署后,库的连接串用 `polygateway_app`,它需要的权限恰好是下表这些——多一分都不必给:
| 库会发的语句 | 需要什么 |
|---|---|
| 连库 | 数据库 `CONNECT` + schema `USAGE` |
| `SELECT to_regclass('llm_calls')`、查 `pg_attribute`(列探测) | 无需额外授权(系统 catalog 默认对 `PUBLIC` 可读) |
| `INSERT INTO llm_calls (...)` | 表 `INSERT`;RLS 打开后还须有一条允许写的 policy |
| `CREATE TABLE IF NOT EXISTS`(**仅当表不存在**) | schema `CREATE`。生产建议**不给**:表由 `owner` 先建好,库探测到表在就不发这条 |
| `ALTER TABLE ADD COLUMN`(**仅 `PGW_TELEMETRY_SCHEMA_MODE=auto`**) | 表**属主**——PG 的 `ALTER TABLE` 只认属主,这一项无法单独 `GRANT`。PG 侧缺省就是 `manual`,补列交给 DBA |
### 6. 合规下游的推荐配置
三件事(截断、保留期、访问控制)要一起上才有意义,故给一份可直接照抄的组合,而不是让你自己拼:
```dotenv
PGW_TELEMETRY_BACKEND=postgres
PGW_TELEMETRY_PG_DSN=postgresql://polygateway_app:...@db:5432/telemetry
PGW_TELEMETRY_SCHEMA_MODE=manual # PG 侧本就是缺省;写出来是为了不依赖缺省
PGW_TELEMETRY_TEXT_CAP=2000 # 落库正文的字符上限;不设 = 存全文
```
| 层 | 配置 |
|---|---|
| 正文体量 | `PGW_TELEMETRY_TEXT_CAP=2000`(按需调);超出部分头部硬切并附 `…(略 N 字)` |
| 保留期 | 上面的分区模板 + `pg_partman``retention`,过期分区整块 `DROP` |
| 访问控制 | 上面的三角色 + `REVOKE UPDATE, DELETE` + `FORCE` RLS |
| 存量兜底 | 已经攒成一张大普通表、来不及改造分区时,用 `tools/telemetry_retention.py`(默认 dry-run,`--apply` 才动手;探测到分区表会直接退出让路给 `DROP PARTITION`) |
**`PGW_TELEMETRY_TEXT_CAP` 的覆盖面必须说清,否则合规判断会出错。** cap 落在四处:`messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response``thinking` 两列。消息侧的这个面与缓存摘要函数 `digest_messages` 一致——**只碰 `content`**,消息里别的字段一概不碰。所以调用方自己塞进 `tool_calls.function.arguments``name` 等字段的内容**不在覆盖范围内**:开了 cap 不等于表里没有全文残留。另需知道:缺省是**不截断**(存全文),而截断之后遥测不再是可复现重放的证据。
### 7. SQLite 侧的保留期
SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**按天/按实验轮转库文件**——`runs/<date>.db``runs/<experiment>.db` 这样,到期直接删文件。这是三个现有下游(Video-Tree-TRM5 / CHSAnalyzer / dissect)天然就有的形态,比删行省事也安全得多:删文件是 O(1) 且不可能删错行,而 `VACUUM` 会重写整库、期间需要一倍磁盘空间,还会把并发写入方挡在外面。
`tools/telemetry_retention.py` 的 SQLite 分支是给**存量场景**兜底的——已经攒成一个大库、来不及改轮转时用它,不是推荐路径。
## 错误模型(四分类) ## 错误模型(四分类)
一切失败在 transport 层翻译为四类之一,治理行为由分类决定,业务侧不需要判断状态码: 一切失败在 transport 层翻译为四类之一,治理行为由分类决定,业务侧不需要判断状态码:
@@ -254,6 +401,7 @@ PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`,**整段可重复执行**
| `PGW_CACHE_BACKEND` | `none` / `memory` / `redis`;非 `none` 时需 `PGW_CACHE_NAMESPACE` + `PGW_CACHE_TTL_S`(须 > 0) | | `PGW_CACHE_BACKEND` | `none` / `memory` / `redis`;非 `none` 时需 `PGW_CACHE_NAMESPACE` + `PGW_CACHE_TTL_S`(须 > 0) |
| `PGW_TELEMETRY_BACKEND` | `none` / `sqlite`(需 `PGW_TELEMETRY_SQLITE_PATH`)/ `postgres`(需 `PGW_TELEMETRY_PG_DSN`) | | `PGW_TELEMETRY_BACKEND` | `none` / `sqlite`(需 `PGW_TELEMETRY_SQLITE_PATH`)/ `postgres`(需 `PGW_TELEMETRY_PG_DSN`) |
| `PGW_TELEMETRY_SCHEMA_MODE` | 可选:`auto` / `manual`;**不设则按后端派生**(sqlite→`auto`、postgres→`manual`),显式设置则两侧都可覆盖。决定库是否给已存在的旧表自动 `ALTER` 补列,详见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律) | | `PGW_TELEMETRY_SCHEMA_MODE` | 可选:`auto` / `manual`;**不设则按后端派生**(sqlite→`auto`、postgres→`manual`),显式设置则两侧都可覆盖。决定库是否给已存在的旧表自动 `ALTER` 补列,详见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律) |
| `PGW_TELEMETRY_TEXT_CAP` | 可选正整数:遥测落库正文的字符上限(作用于每条消息的文本 `content`、多模态 part 的 `text``response``thinking`);**不设 = 不截断**,详见[合规下游的推荐配置](#6-合规下游的推荐配置) |
| `PGW_PRICING_PATH` / `PGW_STRUCTURED_MAX_RETRIES` / `PGW_LEASE_TTL_S` | 可选:价格表(缺省则成本恒 `None`)/ 结构化重问上限(缺省 2)/ permit 租约秒数(缺省 1500,须 ≥ 最大源 `TIMEOUT_S`) | | `PGW_PRICING_PATH` / `PGW_STRUCTURED_MAX_RETRIES` / `PGW_LEASE_TTL_S` | 可选:价格表(缺省则成本恒 `None`)/ 结构化重问上限(缺省 2)/ permit 租约秒数(缺省 1500,须 ≥ 最大源 `TIMEOUT_S`) |
两个易被忽略的源级键:`MISSING_DONE` 决定 SSE 缺 `[DONE]` 时的处置(`retry` 默认判瞬时重试 / `salvage` 收下已收内容并把用量可信度降为 `estimated`;零内容恒 `retry`,不受该键影响);`EXTRA_BODY` 是该源**恒定**的采样参数(JSON 对象串,并入请求体,优先级低于 `chat(overlay=...)`),禁用键 `model` / `messages` / `stream` / `stream_options` 配了直接报错,OCR 与 EMBED scope 不消费该键(配了忽略并 warning)。 两个易被忽略的源级键:`MISSING_DONE` 决定 SSE 缺 `[DONE]` 时的处置(`retry` 默认判瞬时重试 / `salvage` 收下已收内容并把用量可信度降为 `estimated`;零内容恒 `retry`,不受该键影响);`EXTRA_BODY` 是该源**恒定**的采样参数(JSON 对象串,并入请求体,优先级低于 `chat(overlay=...)`),禁用键 `model` / `messages` / `stream` / `stream_options` 配了直接报错,OCR 与 EMBED scope 不消费该键(配了忽略并 warning)。
@@ -14,7 +14,9 @@ import asyncio
import json import json
import os import os
import re import re
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@@ -913,3 +915,297 @@ class TestPublishedSchemaScript:
await _execute_script(fresh_dsn, script) # 可重复执行: 第二遍不得抛 await _execute_script(fresh_dsn, script) # 可重复执行: 第二遍不得抛
rerun = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)] rerun = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
assert rerun == actual # 且第二遍没有偷偷改动表结构 assert rerun == actual # 且第二遍没有偷偷改动表结构
# ---------------------------------------------------------------------------
# issue #12 Task 4: README 的生产部署 DDL 模板,逐条在真实 PG 上执行
# ---------------------------------------------------------------------------
# 模板 SQL **只有一份**,在 README 里。测试从 README 解析出来跑,而不是在这里另抄
# 一份: 抄一份就是两份会各自漂移的东西,而"README 里的 SQL 能跑"这个承诺恰恰只在
# 同源时才成立(doctest / Rust doc tests / mdbook test 都是这个范式)。
_README = Path(__file__).resolve().parents[2] / "README.md"
# 锚点写成 HTML 注释,渲染时不可见,比按章节标题或代码块序号定位稳固得多。
_TEMPLATE_BLOCK = re.compile(r"<!-- pg-template:([a-z_]+) -->\s*\n```sql\n(.*?)\n```", re.DOTALL)
# 顺序即执行顺序;数量与名字都钉死——解析不到或多出一块必须当场红,
# 绝不能退化成空列表让这条测试变成永远绿的摆设。
_EXPECTED_TEMPLATE_BLOCKS = (
"roles",
"table",
"partition",
"grants",
"immutable",
"rls",
"index",
)
# README 里必须原样保留、由本测试做受控替换的标识符。README 那份是给下游照抄的,
# 故占位符是**合法可执行的具体值**而不是 `<schema>` 之类的尖括号洞。
_TEMPLATE_PLACEHOLDERS = (
"polygateway_owner",
"polygateway_app",
"polygateway_report",
"CHANGE_ME_APP",
"CHANGE_ME_REPORT",
"SCHEMA public",
"llm_calls_2026_01",
"'2026-01-01 00:00:00+00'",
"'2026-02-01 00:00:00+00'",
)
# 应用角色在生产里能发的唯一一类写语句(与库的 INSERT 同形,只列 NOT NULL 列)
_TEMPLATE_INSERT = (
"INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
"prompt_tokens, completion_tokens, usage_source, latency_ms, tenant_id) "
"VALUES ($1, 'm', 'p', 's1', '[]', 'ok', 1, 2, 'measured', 10, $2)"
)
def _template_blocks() -> dict[str, str]:
"""从 README 解析带锚点的 SQL 块;顺序即文中出现顺序。"""
return dict(_TEMPLATE_BLOCK.findall(_README.read_text(encoding="utf-8")))
@dataclass(frozen=True)
class _TemplateEnv:
"""模板部署完成后的现场句柄:三个角色各自的连接串 + 当月分区名。"""
admin_dsn: str
app_dsn: str
report_dsn: str
schema: str
partition: str
seeded: tuple[str, str] # (tenant-a 的行, tenant-b 的行)
def _localize(sql: str, schema: str, roles: dict[str, str], month: datetime) -> str:
"""把 README 里给下游照抄的标识符换成本次运行专属的临时对象。
替换规则写在测试里而不是让 README 变得不可直接复制: README 里那份必须是
下游 `pip install` 后照抄就能用的,占位符因此都是合法 SQL 值。
"""
start = month.strftime("%Y-%m-%d %H:%M:%S%z")
end = (month + timedelta(days=32)).replace(day=1).strftime("%Y-%m-%d %H:%M:%S%z")
for placeholder, actual in (
# 长名在前: 三个角色名互不为前缀,但顺序稳定便于排查
("polygateway_owner", roles["owner"]),
("polygateway_report", roles["report"]),
("polygateway_app", roles["app"]),
("CHANGE_ME_APP", _PROBE_PASSWORD),
("CHANGE_ME_REPORT", _PROBE_PASSWORD),
("SCHEMA public", f"SCHEMA {schema}"),
("llm_calls_2026_01", f"llm_calls_{month:%Y_%m}"),
("'2026-01-01 00:00:00+00'", f"'{start}'"),
("'2026-02-01 00:00:00+00'", f"'{end}'"),
):
sql = sql.replace(placeholder, actual)
return sql
def _role_dsn(dsn: str, role: str, schema: str) -> str:
low = re.sub(r"//[^@/]+@", f"//{role}:{_PROBE_PASSWORD}@", dsn, count=1)
return _search_path_dsn(low, schema)
async def _drop_template_objects(dsn: str, schema: str, roles: dict[str, str]) -> None:
"""删净临时 schema 与三个角色(角色是**全局**对象,漏删会跨 run 残留)。"""
import asyncpg
admin = await asyncpg.connect(dsn, timeout=10)
try:
await admin.execute(f"DROP SCHEMA IF EXISTS {schema} CASCADE")
for role in roles.values():
await admin.execute(f"DROP OWNED BY {role}")
await admin.execute(f"DROP ROLE IF EXISTS {role}")
finally:
await admin.close()
@pytest.fixture
async def production_template(dsn):
"""在临时 schema + 临时角色上跑完 README 的整套模板,产出可用的三条连接串。
隔离纪律(M4 事故教训)同 `least_privilege_dsn`: 共享的 `public.llm_calls`
一个字节都不碰,建的 schema / 角色 / 函数 / 分区在 teardown 里删净。
"""
import asyncpg
suffix = uuid4().hex[:8]
schema = f"pgwtpl_{suffix}"
roles = {
"owner": f"pgwtpl_owner_{suffix}",
"app": f"pgwtpl_app_{suffix}",
"report": f"pgwtpl_report_{suffix}",
}
month = datetime.now(UTC).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
blocks = _template_blocks()
# 解析不到就地红: 空 dict 会让下面的 for 一句不执行,测试变成"只验证了能连上库"
assert list(blocks) == list(_EXPECTED_TEMPLATE_BLOCKS), (
f"README 的模板锚点与预期不符: {list(blocks)}"
)
seeded = (_cid("tpl-a"), _cid("tpl-b"))
admin_dsn = _search_path_dsn(dsn, schema)
admin = await asyncpg.connect(dsn, timeout=10)
# 权限门放在建任何对象**之前**: `pytest.skip` 抛的是 BaseException,
# 若它在下面的清理块内触发,清理会去 DROP 从未建过的角色而把 skip 盖掉
can_create = await admin.fetchval(
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user"
)
if not can_create:
await admin.close()
pytest.skip("当前账号无权建临时角色,跳过生产模板用例")
try:
await admin.execute(f"CREATE SCHEMA {schema}")
await admin.execute(f"SET search_path = {schema}")
# README §2 写明的前置步骤: 先用库自带脚本建出普通表当模子
await admin.execute(telemetry_schema_sql("postgres"))
for name in _EXPECTED_TEMPLATE_BLOCKS:
await admin.execute(_localize(blocks[name], schema, roles, month))
# 种两个租户的行(超级用户绕过 RLS,属于布景不属于被测行为)
for call_id, tenant in zip(seeded, ("tenant-a", "tenant-b"), strict=True):
await admin.execute(_TEMPLATE_INSERT, call_id, tenant)
except BaseException:
# 模板 SQL 出错时也必须删净: 建到一半的 schema 会残留一张 llm_calls,
# 而 `TestSchema` 那条按 table_name 查 information_schema 的用例不带
# schema 过滤,会被残留物在**下一次运行**里以列数不符的形态误伤
await admin.close()
await _drop_template_objects(dsn, schema, roles)
raise
finally:
if not admin.is_closed():
await admin.close()
yield _TemplateEnv(
admin_dsn=admin_dsn,
app_dsn=_role_dsn(dsn, roles["app"], schema),
report_dsn=_role_dsn(dsn, roles["report"], schema),
schema=schema,
partition=f"llm_calls_{month:%Y_%m}",
seeded=seeded,
)
admin = await asyncpg.connect(dsn, timeout=10)
try:
await admin.execute(f"DROP SCHEMA IF EXISTS {schema} CASCADE")
for role in roles.values():
await admin.execute(f"DROP OWNED BY {role}")
await admin.execute(f"DROP ROLE IF EXISTS {role}")
finally:
await admin.close()
class TestProductionTemplate:
"""issue #12: README 的生产部署 DDL 模板必须逐条可执行,且行为与文中描述一致。
模板出错的代价全部落在下游身上(照抄就中招),而人工核对不构成回归保护——
改一次 README 就会悄悄失去它。故这里从 README **直接解析** SQL 来执行。
"""
def test_readme_exposes_exactly_the_expected_template_blocks(self):
"""先钉死解析本身: 锚点没了、改名了、块数变了,这条当场红。
没有它,`production_template` 里解析出空 dict 时下面每条用例都会以
"表不存在"之类的间接形态失败,真因(README 结构变了)要靠猜。
"""
blocks = _template_blocks()
assert list(blocks) == list(_EXPECTED_TEMPLATE_BLOCKS)
assert all(sql.strip() for sql in blocks.values())
joined = "\n".join(blocks.values())
for placeholder in _TEMPLATE_PLACEHOLDERS:
# 占位符没了 = 受控替换静默失效,测试会去打真实的 polygateway_* 角色
assert placeholder in joined, f"README 模板缺占位符 {placeholder!r}"
async def test_app_can_insert_but_cannot_mutate(self, production_template):
"""应用角色: INSERT 通过,UPDATE / DELETE 被权限层拒绝(不是被触发器拒)。
权限检查早于行级触发器,故这里拿到的必须是 InsufficientPrivilegeError——
若换成触发器的 RaiseError,说明 REVOKE 那一块没生效,而"不可变"就只剩
一层属主随手可关的兜底。
"""
import asyncpg
env = production_template
conn = await asyncpg.connect(env.app_dsn, timeout=10)
try:
await conn.execute(_TEMPLATE_INSERT, _cid("tpl-app"), "tenant-a")
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
await conn.execute("DELETE FROM llm_calls WHERE call_id = $1", _cid("tpl-app"))
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
await conn.execute("UPDATE llm_calls SET response = 'x'")
finally:
await conn.close()
rows = await _fetch(
env.admin_dsn, "SELECT call_id FROM llm_calls WHERE call_id = $1", _cid("tpl-app")
)
assert [r["call_id"] for r in rows] == [_cid("tpl-app")] # 写入真落库了
async def test_report_can_read_but_cannot_write(self, production_template):
"""报表角色: 带租户上下文读得到自己的行,任何写入都被拒。"""
import asyncpg
env = production_template
conn = await asyncpg.connect(env.report_dsn, timeout=10)
try:
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
await conn.execute(_TEMPLATE_INSERT, _cid("tpl-rpt"), "tenant-a")
async with conn.transaction():
await conn.execute("SELECT set_config('app.tenant_id', 'tenant-a', true)")
rows = await conn.fetch("SELECT call_id, tenant_id FROM llm_calls")
assert [(r["call_id"], r["tenant_id"]) for r in rows] == [(env.seeded[0], "tenant-a")]
finally:
await conn.close()
async def test_reads_are_fail_closed_until_the_tenant_guc_is_set(self, production_template):
"""未设 `app.tenant_id` → 零行(fail-closed);设了 → 只看得到本租户。
两个断言缺一不可: 只验"设了能看到自己的"漏掉了 GUC 未设时全表泄露,
只验"未设是零行"则一条永远返回 false 的 policy 也能通过。
"""
import asyncpg
env = production_template
conn = await asyncpg.connect(env.app_dsn, timeout=10)
try:
async with conn.transaction():
assert await conn.fetch("SELECT call_id FROM llm_calls") == []
async with conn.transaction():
await conn.execute("SELECT set_config('app.tenant_id', 'tenant-b', true)")
rows = await conn.fetch("SELECT call_id, tenant_id FROM llm_calls")
assert [(r["call_id"], r["tenant_id"]) for r in rows] == [(env.seeded[1], "tenant-b")]
finally:
await conn.close()
async def test_rows_land_in_the_current_month_partition(self, production_template):
"""分区表写入成功,且行确实落进当月分区(不是落进某个兜底分区)。"""
env = production_template
rows = await _fetch(
env.admin_dsn,
"SELECT tableoid::regclass::text AS part FROM llm_calls WHERE call_id = $1",
env.seeded[0],
)
assert [r["part"].split(".")[-1] for r in rows] == [env.partition]
async def test_trigger_blocks_delete_while_drop_partition_still_works(
self, production_template
):
"""兜底触发器拦得住 DELETE(连超级用户也拦),却拦不住 DROP PARTITION。
这正是 README 说"清理只能走 DROP PARTITION 而不是 DELETE"的机械化依据:
既要对应用角色 REVOKE DELETE、又要能清理过期数据,分区是唯一不冲突的解。
"""
import asyncpg
env = production_template
conn = await asyncpg.connect(env.admin_dsn, timeout=10)
try:
with pytest.raises(asyncpg.exceptions.RaiseError) as exc:
await conn.execute("DELETE FROM llm_calls WHERE call_id = $1", env.seeded[0])
assert "不可变审计表" in str(exc.value)
await conn.execute(f"ALTER TABLE llm_calls DETACH PARTITION {env.partition}")
await conn.execute(f"DROP TABLE {env.partition}")
assert await conn.fetchval("SELECT count(*) FROM llm_calls") == 0
finally:
await conn.close()