diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index ecfe7816..23d48cfa 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -20539,3 +20539,19 @@ - 相关记录:BUG-1095、BUG-1220(同一函数的地点 / 参考系参数)。 - 复发自:无 - 修复版本:分支 `codex/annual-153`。 + +## BUG-1502 | 给他人做生时校正的 v3 开案函数调不动 v2,staging 门禁真库测试失败 + +- 状态:resolved(迁移修正 + 回归测试;待 staging 门禁复核) +- 首次发现 / 最近更新:2026-11-01 / 2026-11-01(staging backend-quality-gate run 6342,提交 `1a7aba02`) +- 影响面:`frontend/supabase/migrations/20261101010000_rectification_chart_subject.sql` 新增的 `open_agentic_rectification_case_v3`(#160)。给档案里的人开校正、重开一个已有校正会话都走它;本人新开校正仍走 v2,不受影响。迁移从未在任何环境执行过(两次门禁都在迁移步骤之前失败),没有用户受影响。 +- 现象:门禁真库测试报 `permission denied for function open_agentic_rectification_case_v2`(`database-rectification-chart-subject-20261101.test.ts`),以及 `rectification_service_failed`(`database-segment-skill-history.test.ts` 的「post-bump historical exact Skill … session open」)。 +- 触发条件:任何对 v3 的调用(给他人开案,或按会话重开)。 +- 根因:v3 写成了 `security definer`,以属主身份调用 11 参数的 v2;而 `20260814010000_immutable_skill_registry.sql` 有意把这几个函数的 EXECUTE 从属主身上也收回,只留给 service_role,所以 definer 包装被拒。另一方面 service_role 对 `chart_profiles`、`chat_sessions` 没有权限(`20260715030000`),也不能简单改成 invoker 就完事。 +- 修复:v3 改为 `security invoker`(和 12 参数 v2 一致,以 service_role 身份调 v2、改校正案例);查人物、写会话人物标签拆到两个只授权 service_role 的 `security definer` 小函数 `agentic_rectification_case_subject_name_v1`、`agentic_rectification_bind_session_subject_v1`。迁移从未执行过,原文件就地修正。 +- 验证:`frontend/tests/rectification-chart-subject-20261101.test.ts` 锁住 v3 为 invoker、两个辅助函数为 definer 且只授权 service_role;本机没有 Postgres,真库测试由 staging 门禁跑。 +- 防复发:上述源文本断言;凡是包装 `20260814010000` 里那六个函数的新函数,都不能写成 definer。 +- 同一次门禁还暴露:`database-local-business.test.ts` 的定价整表断言没加 `annual.full`(#153 新定价),已补。前一次门禁(run 6338)是年运代码超出 `jyotish_api_server.py` 方法数、`scripts/` 根目录文件数和 `JyotishAPIHandler.__new__` 次数上限,已把年运代码移进 `scripts/annual_service/` 修正,没有放宽上限。 +- 相关记录:BUG-177(同一开案流程,进行中案例的约束)。 +- 复发自:无 +- 修复版本:staging 集成分支 `codex/chart-base-layer-label`。 diff --git a/frontend/supabase/migrations/20261101010000_rectification_chart_subject.sql b/frontend/supabase/migrations/20261101010000_rectification_chart_subject.sql index 034d4998..d2a418aa 100644 --- a/frontend/supabase/migrations/20261101010000_rectification_chart_subject.sql +++ b/frontend/supabase/migrations/20261101010000_rectification_chart_subject.sql @@ -400,12 +400,65 @@ revoke all on function public.agentic_rectification_chart_profiles_rebaseline() -- 5. Open a case for a person -- --------------------------------------------------------------------------- +-- The person is read and the chat session is labelled by two narrow +-- security-definer helpers: service_role has no rights on chart_profiles or +-- chat_sessions (20260715030000). Only service_role may execute them. +create or replace function public.agentic_rectification_case_subject_name_v1( + p_user_id uuid, p_chart_profile_id uuid +) +returns text +language plpgsql +stable +security definer +set search_path = '' +as $$ +declare + v_name text; +begin + select name into v_name + from public.chart_profiles + where id = p_chart_profile_id and user_id = p_user_id and role = 'other'; + if not found then + raise exception 'agentic_rectification_subject_not_found' using errcode = 'P0001'; + end if; + return v_name; +end; +$$; + +create or replace function public.agentic_rectification_bind_session_subject_v1( + p_user_id uuid, p_session_id uuid, p_chart_profile_id uuid, p_name text +) +returns void +language plpgsql +security definer +set search_path = '' +as $$ +begin + update public.chat_sessions + set chart_profile_id = p_chart_profile_id::text, + chart_profile_name = coalesce(nullif(pg_catalog.btrim(p_name), ''), chart_profile_name), + chart_profile_role = 'other' + where id = p_session_id and user_id = p_user_id; +end; +$$; + +revoke all on function public.agentic_rectification_case_subject_name_v1(uuid, uuid) + from public, anon, authenticated; +grant execute on function public.agentic_rectification_case_subject_name_v1(uuid, uuid) to service_role; +revoke all on function public.agentic_rectification_bind_session_subject_v1(uuid, uuid, uuid, text) + from public, anon, authenticated; +grant execute on function public.agentic_rectification_bind_session_subject_v1(uuid, uuid, uuid, text) + to service_role; + -- Wraps open_agentic_rectification_case_v2. p_product_domain null (or left -- out) = the 11-argument form: the session intent passes no domain, exactly -- as before. p_chart_profile_id null (or left out) = the owner. The person is -- checked here, against the same user, before anything is created; a fresh -- case and its conversation are then bound to that person. A replayed -- requestId or an exact session must already belong to the same person. +-- Security invoker, like the 12-argument v2: the 11-argument v2 is executable +-- by service_role only, not even by its owner (20260814010000), so a definer +-- wrapper would be refused. create or replace function public.open_agentic_rectification_case_v3( p_user_id uuid, p_request_id uuid, p_intent text, p_session_id uuid, p_skill_name text, p_skill_version text, p_skill_sha256 text, p_skill_source_commit text, @@ -414,24 +467,19 @@ create or replace function public.open_agentic_rectification_case_v3( ) returns jsonb language plpgsql -security definer +security invoker set search_path = '' as $$ declare v_result jsonb; v_case public.agentic_rectification_cases%rowtype; - v_subject public.chart_profiles%rowtype; + v_subject_name text; begin if p_user_id is null then raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; end if; if p_chart_profile_id is not null then - select * into v_subject - from public.chart_profiles - where id = p_chart_profile_id and user_id = p_user_id and role = 'other'; - if not found then - raise exception 'agentic_rectification_subject_not_found' using errcode = 'P0001'; - end if; + v_subject_name := public.agentic_rectification_case_subject_name_v1(p_user_id, p_chart_profile_id); end if; if p_product_domain is null then @@ -459,11 +507,7 @@ begin set chart_profile_id = p_chart_profile_id where id = v_case.id returning * into v_case; - update public.chat_sessions - set chart_profile_id = p_chart_profile_id::text, - chart_profile_name = coalesce(nullif(pg_catalog.btrim(v_subject.name), ''), chart_profile_name), - chart_profile_role = 'other' - where id = v_case.session_id and user_id = p_user_id; + perform public.agentic_rectification_bind_session_subject_v1(p_user_id, v_case.session_id, p_chart_profile_id, v_subject_name); end if; if p_intent <> 'session' and v_case.chart_profile_id is distinct from p_chart_profile_id then diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index 9e1c635d..c89aef26 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -255,7 +255,13 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic // 原值: "rectification:standard:3:draft" // 新值: 上面那行,再加上 synastry.full 的 standard / premium / internal 各 19 点、published // 原因: 20261020010000 发布了合盘定价。这条查询读的是整张表,不是只读刚插入的校正草稿。 + // 原值: 以 rectification 那行开头 + // 新值: 前面再加 annual.full 的 internal / premium / standard 各 20 点、published + // 原因: 20261101120100 发布了年运报告定价(#153),按 feature_key 排序排在最前 [ + "annual.full:internal:20:published", + "annual.full:premium:20:published", + "annual.full:standard:20:published", "rectification:standard:3:draft", "synastry.full:internal:19:published", "synastry.full:premium:19:published", diff --git a/frontend/tests/rectification-chart-subject-20261101.test.ts b/frontend/tests/rectification-chart-subject-20261101.test.ts index 18bf06ef..c3761262 100644 --- a/frontend/tests/rectification-chart-subject-20261101.test.ts +++ b/frontend/tests/rectification-chart-subject-20261101.test.ts @@ -327,7 +327,24 @@ test("the migration only adds: old cases stay the owner's, and no data is rewrit assert.equal(sets.length, 1); const v3 = functionBody(migration, "open_agentic_rectification_case_v3"); assert.ok(v3.includes("set chart_profile_id = p_chart_profile_id")); - assert.match(v3, /where id = p_chart_profile_id and user_id = p_user_id and role = 'other'/); + // 原值:v3 自己 select chart_profiles(where id = p_chart_profile_id and user_id = p_user_id and role = 'other') + // 新值:v3 是 security invoker,人物由 security definer 的 agentic_rectification_case_subject_name_v1 查, + // 会话标签由 agentic_rectification_bind_session_subject_v1 写,两者只授权 service_role + // 原因:staging 门禁真库报「permission denied for function open_agentic_rectification_case_v2」: + // 11 参数的 v2 连属主也没有 EXECUTE(20260814010000),definer 包装调用不了; + // service_role 又没有 chart_profiles / chat_sessions 的权限(20260715030000) + assert.match(v3, /security invoker/); + assert.doesNotMatch(v3, /security definer/); + assert.match(v3, /public\.agentic_rectification_case_subject_name_v1\(p_user_id, p_chart_profile_id\)/); + assert.match(v3, /perform public\.agentic_rectification_bind_session_subject_v1\(/); + const subject = functionBody(migration, "agentic_rectification_case_subject_name_v1"); + assert.match(subject, /security definer/); + assert.match(subject, /where id = p_chart_profile_id and user_id = p_user_id and role = 'other'/); + assert.match(functionBody(migration, "agentic_rectification_bind_session_subject_v1"), /security definer/); + for (const helper of ["agentic_rectification_case_subject_name_v1", "agentic_rectification_bind_session_subject_v1"]) { + assert.match(migration, new RegExp(`revoke all on function public\\.${helper}\\([^)]*\\)\\s+from public, anon, authenticated;`)); + assert.match(migration, new RegExp(`grant execute on function public\\.${helper}\\([^)]*\\)\\s*to service_role;`)); + } assert.match(v3, /agentic_rectification_subject_mismatch/); assert.match(migration, /grant execute on function public\.open_agentic_rectification_case_v3\(\s*uuid, uuid, text, uuid, text, text, text, text, text, jsonb, jsonb, text, uuid\s*\) to service_role;/); // Deletes exist only inside delete_chart_subject (2A).