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_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'),
|
||||||
|
|
|
||||||
|
|
@ -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;")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue