fix(db): service_role SELECT on the adopted-birth-date columns (BUG-1062)

20260920020000 added active_birth_date / active_birth_timezone_offset /
active_birth_provenance without a service_role column grant, while the
account PATCH and the report worker's self lookup read them through
service_role (42501, recurrence of BUG-039 / BUG-600). Additive SELECT-only
grant plus a static contract: every profiles column a service_role reader
selects must be granted. BUG-1063 (worker cannot read chart_profiles as
service_role) recorded as investigating, not fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-27 18:30:57 +08:00
co-authored by Claude Opus 5.5
parent 371fe68b51
commit 3b192f12a4
3 changed files with 94 additions and 0 deletions
+30
View File
@@ -14323,3 +14323,33 @@
- 相关记录:BUG-1060
- 复发自:无
- 修复版本:待定
## BUG-1062 | 服务角色读不到「采用日期」三列:账户保存与本人报告 worker 在自托管 PostgreSQL 上会 42501
- 状态:investigating(静态证据确定;补授权迁移已随 `codex/consult-gender-optional-20260927` 提交,等门禁 DB job 与 staging 真实保存 smoke 后再改 resolved)
- 首次发现 / 最近更新:2026-09-27 / 2026-09-27
- 影响面:`PATCH /api/account`(并发保护读、写后 RETURNING 读)、报告 worker 的本人资料读取(`loadSubjectBirth` → `ACCOUNT_BIRTH_SELECT`),两者都经 `createAdminSupabaseClient()` → `set local role service_role`。
- 现象(推断,未在真实库复现):新账户首次保存称呼 / 出生资料返回 `500 {"error":"暂时无法核对现有出生资料"}`;本人报告 worker 读资料失败按可重试处理。
- 触发条件:`20260920020000_adopted_birth_date.sql` 部署后任何经服务角色读取 `profiles.active_birth_date / active_birth_timezone_offset / active_birth_provenance` 的请求。
- 根因:该迁移加了三列,只给函数授了 `service_role` 执行权,没有列级 `SELECT`。`20260718060000` 起 `profiles` 对 `service_role` 是逐列授权,新列默认无权限;PostgreSQL 对未授权列返回 `42501 permission denied for table profiles`,这句不含 `column`,账户路由的缺列回退不会生效。开工时用迁移全集静态比对:账户 PATCH 与 `ACCOUNT_BIRTH_SELECT` 读取的列里,只有这三列没有 `service_role` 的 `SELECT` 授权。
- 修复:新增迁移 `20260927020000_profile_adopted_birth_service_role_select.sql`,只授 `SELECT`(两条路径都不直接写这三列;采用 RPC 是 SECURITY DEFINER)。加法、幂等、不动数据。
- 验证:新增静态合同 `frontend/tests/profile-service-role-grants-20260927.test.ts`(去掉新迁移时红、加上后绿);`frontend/tests/database-profile-gender.test.ts` 在真实 PostgreSQL 上走 `PATCH /api/account`(门禁 DB job 跑,本机无 Docker)。
- 防复发:静态合同把「服务角色读取的 profiles 列 ⊆ 迁移里授给 service_role 的 SELECT 列」锁住,以后加列漏授权会直接红,不再依赖人记得 BUG-600 的防复发句。
- 相关记录:BUG-039、BUG-600(同类列级授权缺口第三次)、BUG-1031(worker 改走 `loadSubjectBirth`)
- 复发自:BUG-600。当时的防复发只写成一句规则和针对 `ayanamsa` 的单列断言,没有通用合同,所以 `20260920020000` 加列时没有拦住。
- 修复版本:`codex/consult-gender-optional-20260927`(未推送、未部署)
## BUG-1063 | 他人报告 worker 用服务角色读 `chart_profiles`,但服务角色对这张表没有任何权限
- 状态:investigating(静态证据;本轮不修,授权范围需要产品 / 安全决定)
- 首次发现 / 最近更新:2026-09-27 / 2026-09-27
- 影响面:报告 worker 为「星盘档案里的其他人」生成报告(`personal-report-worker-subject.ts` → `loadSubjectBirth(admin, …)` → `from("chart_profiles").select(CHART_SUBJECT_SELECT)`)。
- 现象(推断,未在真实库复现):选别人生成的报告,worker 读人物资料失败,按 `subject_unavailable` → `profile_incomplete`(可重试)处理,报告可能一直排队或最终失败退款。
- 触发条件:BUG-1031 让 worker 改走 `loadSubjectBirth` 之后(`4d801e53` 起)的他人报告。
- 根因(已确认的事实):`20260718100000_repair_missing_chart_profiles.sql` 对 `service_role` 执行 `revoke all on table public.chart_profiles`,之后没有任何迁移把它授回;admin 客户端在自托管模式下是 `set local role service_role`(`admin-client-core.ts`)。BYPASSRLS 只绕过行级策略,不给表权限。未在真实库验证 staging 是否另有手工授权。
- 修复:未修。可选做法是给 `service_role` 授 `chart_profiles` 的逐列 `SELECT`(只列 `CHART_SUBJECT_SELECT` 的列),或让 worker 走 SECURITY DEFINER 的只读函数;两者都扩大服务端能读到的他人出生资料范围,需要决定。
- 验证:—(建议先在 staging 库只读执行 `select has_table_privilege('service_role','public.chart_profiles','select')` 确认)
- 防复发:新增的服务角色读取路径必须有真实 PostgreSQL 用例,mock 客户端测不出表权限。
- 相关记录:BUG-1031、BUG-1062
- 复发自:无
- 修复版本:待定
@@ -0,0 +1,18 @@
-- BUG-1062: 20260920020000_adopted_birth_date added active_birth_date,
-- active_birth_timezone_offset and active_birth_provenance without a
-- service_role column grant. The account PATCH (concurrency read and the
-- RETURNING select) and the report worker's self lookup (ACCOUNT_BIRTH_SELECT)
-- read these columns through service_role, so PostgreSQL answers 42501
-- "permission denied for table profiles" — the same gap as BUG-039 / BUG-600.
--
-- Additive and idempotent: SELECT only (neither path writes these columns
-- directly; the adoption RPCs are SECURITY DEFINER), no data change.
begin;
grant select (
active_birth_date,
active_birth_timezone_offset,
active_birth_provenance
) on table public.profiles to service_role;
commit;
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import test from "node:test";
import { ACCOUNT_BIRTH_SELECT } from "../src/lib/server-owned-birth-profile.ts";
// BUG-1062 (recurrence of BUG-039 / BUG-600): every profiles column that a
// service_role reader selects must have a service_role SELECT grant in a
// migration after the least-privilege reset. Static; the real grant is also
// exercised by tests/database-profile-gender.test.ts in the gate's DB job.
const LEAST_PRIVILEGE_RESET = "20260718060000";
const directory = new URL("../supabase/migrations/", import.meta.url);
function serviceRoleSelectGrants(): Set<string> {
const granted = new Set<string>();
for (const name of readdirSync(directory).filter((file) => file.endsWith(".sql")).sort()) {
if (name.slice(0, 14) < LEAST_PRIVILEGE_RESET) continue;
const sql = readFileSync(new URL(name, directory), "utf8").replace(/--.*$/gm, "");
const pattern = /grant\s+([a-z ,]+?)\s*\(([^)]*)\)\s*on\s+table\s+public\.profiles\s+to\s+([a-z_, ]+);/gi;
for (const match of sql.matchAll(pattern)) {
if (!/select/i.test(match[1]!) || !/service_role/.test(match[3]!)) continue;
for (const column of match[2]!.split(",")) granted.add(column.trim());
}
}
return granted;
}
function accountPatchSelects(): Set<string> {
const route = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8");
const patch = route.slice(route.indexOf("export async function PATCH"));
const columns = new Set<string>();
for (const match of patch.matchAll(/\.select\("([^"]+)"\)/g)) {
for (const column of match[1]!.split(",")) columns.add(column.trim());
}
return columns;
}
test("service_role can select every profiles column the account PATCH and the report worker read", () => {
const granted = serviceRoleSelectGrants();
const patchMissing = [...accountPatchSelects()].filter((column) => !granted.has(column));
const workerMissing = ACCOUNT_BIRTH_SELECT.split(",").filter((column) => !granted.has(column));
assert.deepEqual(patchMissing, [], "PATCH /api/account reads these through service_role");
assert.deepEqual(workerMissing, [], "the report worker's self lookup reads these through service_role");
assert.ok(granted.has("gender"), "the optional gender column is readable by the account PATCH");
});