fix(kb): preserve bounded Observatory read grants

This commit is contained in:
twentyOne2x 2026-07-22 04:04:47 +02:00
parent 487c2a6599
commit 023782893e
2 changed files with 155 additions and 9 deletions

View file

@ -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_review login noinherit nosuperuser nocreatedb nocreaterole noreplication nobypassrls;
alter role kb_apply 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 -- Protected review/apply roles are standalone principals. The Observatory
-- them creates an inherited or SET ROLE path that this migration cannot safely -- service principal is intentionally a member of kb_observatory_read, but the
-- classify, so fail instead of silently revoking an operator-managed grant. -- 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$ do $memberships$
declare declare
v_memberships text; v_memberships text;
@ -65,7 +66,9 @@ begin
from pg_catalog.pg_auth_members membership 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 member_role on member_role.oid = membership.member
join pg_catalog.pg_roles granted_role on granted_role.oid = membership.roleid 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'); or granted_role.rolname in ('kb_gate_owner', 'kb_review', 'kb_apply');
if v_memberships is not null then if v_memberships is not null then
@ -301,9 +304,23 @@ begin
) )
and acl.grantee <> 0 and acl.grantee <> 0
and acl.grantee <> relation.relowner and acl.grantee <> relation.relowner
and pg_catalog.pg_get_userbyid(acl.grantee) not in ( and not (
'kb_gate_owner', 'kb_review', 'kb_apply' 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 if v_drift is not null then
raise exception 'kb_apply_prereqs: unexpected protected table grantee: %', v_drift; raise exception 'kb_apply_prereqs: unexpected protected table grantee: %', v_drift;
@ -989,7 +1006,9 @@ begin
from pg_catalog.pg_auth_members membership 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 member_role on member_role.oid = membership.member
join pg_catalog.pg_roles granted_role on granted_role.oid = membership.roleid 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'); or granted_role.rolname in ('kb_gate_owner', 'kb_review', 'kb_apply');
if v_drift is not null then if v_drift is not null then
@ -1215,7 +1234,7 @@ begin
raise exception 'kb_apply_prereqs: protected column ACL mismatch: %', v_drift; raise exception 'kb_apply_prereqs: protected column ACL mismatch: %', v_drift;
end if; 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 values
('public', 'agents', 'kb_gate_owner', 'SELECT', false), ('public', 'agents', 'kb_gate_owner', 'SELECT', false),
('public', 'agents', 'kb_apply', 'SELECT', false), ('public', 'agents', 'kb_apply', 'SELECT', false),
@ -1261,6 +1280,33 @@ begin
union all union all
select 'public', 'claim_evidence_assessments', 'kb_gate_owner', 'SELECT', false select 'public', 'claim_evidence_assessments', 'kb_gate_owner', 'SELECT', false
where pg_catalog.to_regclass('public.claim_evidence_assessments') is not null 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 ( ), tracked(schema_name, object_name) as (
values values
('public', 'agents'), ('public', 'agents'),

View file

@ -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;") _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: def test_migration_refuses_rogue_canonical_insert_before_install_and_normalizes_after_removal() -> None:
with _postgres_clone() as container: with _postgres_clone() as container:
_psql(container, "grant usage on schema public to rogue_grantee;") _psql(container, "grant usage on schema public to rogue_grantee;")