fix(kb): preserve bounded Observatory read grants
This commit is contained in:
parent
487c2a6599
commit
023782893e
2 changed files with 155 additions and 9 deletions
|
|
@ -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,8 +304,22 @@ begin
|
|||
)
|
||||
and acl.grantee <> 0
|
||||
and acl.grantee <> relation.relowner
|
||||
and pg_catalog.pg_get_userbyid(acl.grantee) not in (
|
||||
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
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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;")
|
||||
|
|
|
|||
Loading…
Reference in a new issue