From 023782893e551f3d2ac0cd93221ae97f272f38ff Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Wed, 22 Jul 2026 04:04:47 +0200 Subject: [PATCH] fix(kb): preserve bounded Observatory read grants --- scripts/kb_apply_prereqs.sql | 64 ++++++++++++++++++--- tests/test_kb_apply_prereqs.py | 100 +++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 9 deletions(-) diff --git a/scripts/kb_apply_prereqs.sql b/scripts/kb_apply_prereqs.sql index 96b44e5..08992bb 100644 --- a/scripts/kb_apply_prereqs.sql +++ b/scripts/kb_apply_prereqs.sql @@ -50,9 +50,10 @@ alter role kb_gate_owner nologin noinherit nosuperuser nocreatedb nocreaterole n alter role kb_review login noinherit nosuperuser nocreatedb nocreaterole noreplication nobypassrls; alter role kb_apply login noinherit nosuperuser nocreatedb nocreaterole noreplication nobypassrls; --- Protected roles are standalone principals. Any membership involving one of --- them creates an inherited or SET ROLE path that this migration cannot safely --- classify, so fail instead of silently revoking an operator-managed grant. +-- Protected review/apply roles are standalone principals. The Observatory +-- service principal is intentionally a member of kb_observatory_read, but the +-- read role itself must never be a member of another role: that would create an +-- inherited or SET ROLE path beyond its bounded SELECT surface. do $memberships$ declare v_memberships text; @@ -65,7 +66,9 @@ begin from pg_catalog.pg_auth_members membership join pg_catalog.pg_roles member_role on member_role.oid = membership.member join pg_catalog.pg_roles granted_role on granted_role.oid = membership.roleid - where member_role.rolname in ('kb_gate_owner', 'kb_review', 'kb_apply') + where member_role.rolname in ( + 'kb_gate_owner', 'kb_review', 'kb_apply', 'kb_observatory_read' + ) or granted_role.rolname in ('kb_gate_owner', 'kb_review', 'kb_apply'); if v_memberships is not null then @@ -301,9 +304,23 @@ begin ) and acl.grantee <> 0 and acl.grantee <> relation.relowner - and pg_catalog.pg_get_userbyid(acl.grantee) not in ( - 'kb_gate_owner', 'kb_review', 'kb_apply' - ); + and not ( + pg_catalog.pg_get_userbyid(acl.grantee) in ( + 'kb_gate_owner', 'kb_review', 'kb_apply' + ) + or ( + pg_catalog.pg_get_userbyid(acl.grantee) = 'kb_observatory_read' + and (namespace.nspname, relation.relname) in ( + ('public', 'claims'), + ('public', 'sources'), + ('public', 'claim_evidence'), + ('public', 'claim_edges'), + ('kb_stage', 'kb_proposals') + ) + and pg_catalog.upper(acl.privilege_type) = 'SELECT' + and not acl.is_grantable + ) + ); if v_drift is not null then raise exception 'kb_apply_prereqs: unexpected protected table grantee: %', v_drift; @@ -989,7 +1006,9 @@ begin from pg_catalog.pg_auth_members membership join pg_catalog.pg_roles member_role on member_role.oid = membership.member join pg_catalog.pg_roles granted_role on granted_role.oid = membership.roleid - where member_role.rolname in ('kb_gate_owner', 'kb_review', 'kb_apply') + where member_role.rolname in ( + 'kb_gate_owner', 'kb_review', 'kb_apply', 'kb_observatory_read' + ) or granted_role.rolname in ('kb_gate_owner', 'kb_review', 'kb_apply'); if v_drift is not null then @@ -1215,7 +1234,7 @@ begin raise exception 'kb_apply_prereqs: protected column ACL mismatch: %', v_drift; end if; - with expected(schema_name, object_name, grantee, privilege_type, is_grantable) as ( + with core_expected(schema_name, object_name, grantee, privilege_type, is_grantable) as ( values ('public', 'agents', 'kb_gate_owner', 'SELECT', false), ('public', 'agents', 'kb_apply', 'SELECT', false), @@ -1261,6 +1280,33 @@ begin union all select 'public', 'claim_evidence_assessments', 'kb_gate_owner', 'SELECT', false where pg_catalog.to_regclass('public.claim_evidence_assessments') is not null + ), expected(schema_name, object_name, grantee, privilege_type, is_grantable) as ( + select * from core_expected + union all + -- Preserve only the independently installed Observatory read grants. The + -- preflight above rejects writes, grant options, column ACLs, and any table + -- outside this exact surface before the migration can change state. + select namespace.nspname::text, + relation.relname::text, + grantee_role.rolname::text, + pg_catalog.upper(acl.privilege_type), + acl.is_grantable + from pg_catalog.pg_class relation + join pg_catalog.pg_namespace namespace on namespace.oid = relation.relnamespace + cross join lateral pg_catalog.aclexplode( + coalesce(relation.relacl, pg_catalog.acldefault('r', relation.relowner)) + ) acl + join pg_catalog.pg_roles grantee_role on grantee_role.oid = acl.grantee + where grantee_role.rolname = 'kb_observatory_read' + and (namespace.nspname, relation.relname) in ( + ('public', 'claims'), + ('public', 'sources'), + ('public', 'claim_evidence'), + ('public', 'claim_edges'), + ('kb_stage', 'kb_proposals') + ) + and pg_catalog.upper(acl.privilege_type) = 'SELECT' + and not acl.is_grantable ), tracked(schema_name, object_name) as ( values ('public', 'agents'), diff --git a/tests/test_kb_apply_prereqs.py b/tests/test_kb_apply_prereqs.py index 11ac89c..e43eedf 100644 --- a/tests/test_kb_apply_prereqs.py +++ b/tests/test_kb_apply_prereqs.py @@ -951,6 +951,106 @@ def test_migration_fails_closed_on_unexpected_table_grantee(migrated_postgres: s _psql(migrated_postgres, "revoke all on kb_stage.kb_proposal_approvals from rogue_grantee;") +def test_migration_preserves_current_and_complete_observatory_read_surfaces() -> None: + observatory_acl_sql = """ +select namespace.nspname || '|' || relation.relname || '|' || acl.privilege_type || '|' || + acl.is_grantable::text + from pg_catalog.pg_class relation + join pg_catalog.pg_namespace namespace on namespace.oid = relation.relnamespace + cross join lateral pg_catalog.aclexplode(relation.relacl) acl + join pg_catalog.pg_roles grantee on grantee.oid = acl.grantee + where grantee.rolname = 'kb_observatory_read' + order by namespace.nspname, relation.relname, acl.privilege_type; +""" + with _postgres_clone() as container: + _psql( + container, + """ +create role kb_observatory_read nologin noinherit nosuperuser nocreatedb nocreaterole + noreplication nobypassrls; +create role observatory_principal login noinherit nosuperuser nocreatedb nocreaterole + noreplication nobypassrls; +grant kb_observatory_read to observatory_principal; +grant usage on schema public, kb_stage to kb_observatory_read; +grant select on + public.claims, + public.claim_evidence, + public.claim_edges +to kb_observatory_read; +""", + ) + + # The current VPS manifest has this exact three-table read projection. + _apply_migration(container) + _apply_migration(container) + assert _lines(_psql(container, observatory_acl_sql)) == { + "public|claim_edges|SELECT|false", + "public|claim_evidence|SELECT|false", + "public|claims|SELECT|false", + } + + # The independently managed Observatory installer extends the same + # bounded surface to source and proposal provenance reads. + _psql( + container, + """ +grant select on public.sources, kb_stage.kb_proposals to kb_observatory_read; +""", + ) + _apply_migration(container) + assert _lines(_psql(container, observatory_acl_sql)) == { + "kb_stage|kb_proposals|SELECT|false", + "public|claim_edges|SELECT|false", + "public|claim_evidence|SELECT|false", + "public|claims|SELECT|false", + "public|sources|SELECT|false", + } + + for grant_sql, revoke_sql, expected_error in ( + ( + "grant insert on public.claims to kb_observatory_read;", + "revoke insert on public.claims from kb_observatory_read;", + "unexpected protected table grantee", + ), + ( + "grant select on public.agents to kb_observatory_read;", + "revoke select on public.agents from kb_observatory_read;", + "unexpected protected table grantee", + ), + ( + "grant select on public.sources to kb_observatory_read with grant option;", + "revoke grant option for select on public.sources from kb_observatory_read;", + "unexpected protected table grantee", + ), + ( + "grant select (id) on public.claims to kb_observatory_read;", + "revoke select (id) on public.claims from kb_observatory_read;", + "unexpected protected table grantee", + ), + ( + "grant execute on function " + "kb_stage.assert_approved_proposal(uuid,text,jsonb,text,uuid,timestamptz,text) " + "to kb_observatory_read;", + "revoke execute on function " + "kb_stage.assert_approved_proposal(uuid,text,jsonb,text,uuid,timestamptz,text) " + "from kb_observatory_read;", + "unexpected protected function grantee", + ), + ( + "grant broad_writer to kb_observatory_read;", + "revoke broad_writer from kb_observatory_read;", + "unexpected protected role membership", + ), + ): + _psql(container, grant_sql) + try: + _assert_migration_fails(container, expected_error) + finally: + _psql(container, revoke_sql) + + _apply_migration(container) + + def test_migration_refuses_rogue_canonical_insert_before_install_and_normalizes_after_removal() -> None: with _postgres_clone() as container: _psql(container, "grant usage on schema public to rogue_grantee;")