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:
co-authored by
Claude Opus 5.5
parent
371fe68b51
commit
3b192f12a4
@@ -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
|
||||
- 复发自:无
|
||||
- 修复版本:待定
|
||||
|
||||
+18
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user