From 3b192f12a40d76617d0b79369069974689b088a9 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 27 Sep 2026 17:58:06 +0800 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8 --- docs/BUG_HISTORY.md | 30 ++++++++++++ ...file_adopted_birth_service_role_select.sql | 18 ++++++++ ...ofile-service-role-grants-20260927.test.ts | 46 +++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 frontend/supabase/migrations/20260927020000_profile_adopted_birth_service_role_select.sql create mode 100644 frontend/tests/profile-service-role-grants-20260927.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 346c3ed4..48591c13 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 +- 复发自:无 +- 修复版本:待定 diff --git a/frontend/supabase/migrations/20260927020000_profile_adopted_birth_service_role_select.sql b/frontend/supabase/migrations/20260927020000_profile_adopted_birth_service_role_select.sql new file mode 100644 index 00000000..62bdedb0 --- /dev/null +++ b/frontend/supabase/migrations/20260927020000_profile_adopted_birth_service_role_select.sql @@ -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; diff --git a/frontend/tests/profile-service-role-grants-20260927.test.ts b/frontend/tests/profile-service-role-grants-20260927.test.ts new file mode 100644 index 00000000..3d48d278 --- /dev/null +++ b/frontend/tests/profile-service-role-grants-20260927.test.ts @@ -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 { + const granted = new Set(); + 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 { + 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(); + 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"); +});