Harden leoclean runtime authority checks

This commit is contained in:
fwazb 2026-07-22 20:02:18 -07:00
parent ce559253bd
commit 963ab34584
7 changed files with 317 additions and 6 deletions

View file

@ -886,6 +886,56 @@ begin
raise exception 'scoped Leo roles retain role memberships: %', unexpected; raise exception 'scoped Leo roles retain role memberships: %', unexpected;
end if; end if;
-- Reject explicit default-ACL state that could expose future objects to a
-- scoped role. This is deliberately checked before later capability
-- validation, while the phase-1 NOLOGIN fence is still in force.
-- PostgreSQL hard-wired PUBLIC defaults for routines and types are not rows
-- in pg_default_acl and remain a separately bounded staging residual.
-- The closure follows member -> granted role conservatively; any membership
-- edge is independently forbidden for these roles.
with recursive scoped_role(oid) as (
values (runtime_oid), (owner_oid)
), reachable_role(oid) as (
select oid from scoped_role
union
select membership.roleid
from pg_catalog.pg_auth_members membership
join reachable_role on reachable_role.oid = membership.member
)
select pg_catalog.string_agg(
pg_catalog.format(
'owner=%I schema=%s object_type=%s grantee=%s privilege=%s grantable=%s',
default_owner.rolname,
coalesce(default_namespace.nspname, '<global>'),
default_acl.defaclobjtype,
case when acl.grantee = 0 then 'PUBLIC' else acl_grantee.rolname end,
acl.privilege_type,
acl.is_grantable
),
', '
order by default_owner.rolname,
default_namespace.nspname nulls first,
default_acl.defaclobjtype,
acl.grantee,
acl.privilege_type,
acl.is_grantable
)
into unexpected
from pg_catalog.pg_default_acl default_acl
join pg_catalog.pg_roles default_owner on default_owner.oid = default_acl.defaclrole
left join pg_catalog.pg_namespace default_namespace on default_namespace.oid = default_acl.defaclnamespace
left join lateral pg_catalog.aclexplode(default_acl.defaclacl) acl on true
left join pg_catalog.pg_roles acl_grantee on acl_grantee.oid = acl.grantee
where default_acl.defaclobjtype in ('r', 'S', 'f', 'T', 'n')
and (
default_acl.defaclrole in (select oid from reachable_role)
or acl.grantee = 0
or acl.grantee in (select oid from reachable_role)
);
if unexpected is not null then
raise exception 'scoped Leo roles have unsafe explicit default ACL entries: %', unexpected;
end if;
if not exists ( if not exists (
select 1 select 1
from pg_catalog.pg_class relation from pg_catalog.pg_class relation

View file

@ -23,6 +23,7 @@ SERVER_CA_FILE="$SCRIPT_DIR/gcp-teleo-pgvector-standby-server-ca.pem"
PSQL_BIN=/usr/bin/psql PSQL_BIN=/usr/bin/psql
SETSID_BIN=/usr/bin/setsid SETSID_BIN=/usr/bin/setsid
ENV_BIN=/usr/bin/env ENV_BIN=/usr/bin/env
BASH_BIN=/bin/bash
SHA256SUM_BIN=/usr/bin/sha256sum SHA256SUM_BIN=/usr/bin/sha256sum
SERVER_CA_SHA256=80701e768f0e1f6b9d621aa0b53f6e851daaa276c6d9a8e51a300fbc015539cb SERVER_CA_SHA256=80701e768f0e1f6b9d621aa0b53f6e851daaa276c6d9a8e51a300fbc015539cb
@ -70,6 +71,7 @@ fi
assert_trusted_executable "$PSQL_BIN" assert_trusted_executable "$PSQL_BIN"
assert_trusted_executable "$SETSID_BIN" assert_trusted_executable "$SETSID_BIN"
assert_trusted_executable "$ENV_BIN" assert_trusted_executable "$ENV_BIN"
assert_trusted_executable "$BASH_BIN"
assert_trusted_executable "$SHA256SUM_BIN" assert_trusted_executable "$SHA256SUM_BIN"
if [ "$("$SHA256SUM_BIN" "$SERVER_CA_FILE")" != "$SERVER_CA_SHA256 $SERVER_CA_FILE" ]; then if [ "$("$SHA256SUM_BIN" "$SERVER_CA_FILE")" != "$SERVER_CA_SHA256 $SERVER_CA_FILE" ]; then
echo "Reviewed Cloud SQL server CA does not match the pinned certificate." >&2 echo "Reviewed Cloud SQL server CA does not match the pinned certificate." >&2
@ -117,12 +119,17 @@ trap cleanup_password EXIT
trap 'terminate_provisioning 130' INT trap 'terminate_provisioning 130' INT
trap 'terminate_provisioning 143' TERM trap 'terminate_provisioning 143' TERM
# \password encrypts client-side. --no-password ensures an administrator-login # The isolated shell consumes the administrator password from the first stdin
# prompt cannot consume either of the two runtime-password records from stdin. # record and exports it only in the final psql process environment. It is never
# an env(1) assignment operand or another process argument. \password consumes
# the two remaining records and encrypts the runtime password client-side.
# --no-password ensures an administrator-login prompt cannot consume either
# runtime-password record.
# setsid detaches psql from any controlling terminal without --fork, keeping # setsid detaches psql from any controlling terminal without --fork, keeping
# its PID as the session/process-group leader so an interruption can terminate # its PID as the session/process-group leader so an interruption can terminate
# and reap the complete credential-bearing child before this wrapper returns. # and reap the complete credential-bearing child before this wrapper returns.
{ {
printf '%s\n' "$administrator_password"
printf '%s\n' "$runtime_password" printf '%s\n' "$runtime_password"
printf '%s\n' "$runtime_password" printf '%s\n' "$runtime_password"
} | "$ENV_BIN" -i \ } | "$ENV_BIN" -i \
@ -133,10 +140,15 @@ trap 'terminate_provisioning 143' TERM
PGPORT=5432 \ PGPORT=5432 \
PGDATABASE=teleo_canonical \ PGDATABASE=teleo_canonical \
PGUSER=postgres \ PGUSER=postgres \
PGPASSWORD="$administrator_password" \
PGSSLMODE=verify-ca \ PGSSLMODE=verify-ca \
PGSSLROOTCERT="$SERVER_CA_FILE" \ PGSSLROOTCERT="$SERVER_CA_FILE" \
"$SETSID_BIN" -- "$PSQL_BIN" \ "$SETSID_BIN" -- "$BASH_BIN" --noprofile --norc -c '
set +x
IFS= read -r PGPASSWORD || exit 64
[ -n "$PGPASSWORD" ] && [ "${#PGPASSWORD}" -le 4096 ] || exit 64
export PGPASSWORD
exec "$@"
' leoclean-runtime-role-provisioner "$PSQL_BIN" \
--no-psqlrc \ --no-psqlrc \
--no-password \ --no-password \
--set=ON_ERROR_STOP=1 \ --set=ON_ERROR_STOP=1 \

View file

@ -345,6 +345,7 @@ CATALOG_ZERO_COUNT_FIELDS: tuple[str, ...] = (
"unsafe_proposal_rewrite_rules", "unsafe_proposal_rewrite_rules",
"unsafe_proposal_triggers", "unsafe_proposal_triggers",
"unsafe_proposal_generated_columns", "unsafe_proposal_generated_columns",
"unsafe_explicit_default_acl_rows",
) )
CATALOG_TRUE_FIELDS: tuple[str, ...] = ( CATALOG_TRUE_FIELDS: tuple[str, ...] = (
@ -1590,6 +1591,31 @@ select (pg_catalog.jsonb_build_object(
'CREATE' 'CREATE'
) )
), ),
-- Counts explicit default-ACL rows in this database only. PostgreSQL's
-- hard-wired PUBLIC defaults for new routines and types are not represented
-- in pg_default_acl and are outside this catalog-zero assertion.
'unsafe_explicit_default_acl_rows', (
with recursive scoped_role(oid) as (
select oid from runtime_role
union
select oid from owner_role
), reachable_role(oid) as (
select oid from scoped_role
union
select membership.roleid
from pg_catalog.pg_auth_members membership
join reachable_role on reachable_role.oid = membership.member
)
select pg_catalog.count(distinct default_acl.oid)::int
from pg_catalog.pg_default_acl default_acl
left join lateral pg_catalog.aclexplode(default_acl.defaclacl) acl on true
where default_acl.defaclobjtype in ('r', 'S', 'f', 'T', 'n')
and (
default_acl.defaclrole in (select oid from reachable_role)
or acl.grantee = 0
or acl.grantee in (select oid from reachable_role)
)
),
'missing_allowed_table_selects', ( 'missing_allowed_table_selects', (
select pg_catalog.count(*)::int select pg_catalog.count(*)::int
from allowed_select_column from allowed_select_column

View file

@ -1535,6 +1535,10 @@ def test_catalog_queries_use_exact_regprocedure_signatures_and_both_membership_d
assert "pg_catalog.has_sequence_privilege" in catalog_sql assert "pg_catalog.has_sequence_privilege" in catalog_sql
assert "pg_catalog.has_function_privilege" in catalog_sql assert "pg_catalog.has_function_privilege" in catalog_sql
assert "pg_catalog.aclexplode" in catalog_sql assert "pg_catalog.aclexplode" in catalog_sql
assert "pg_catalog.pg_default_acl" in catalog_sql
assert "unsafe_explicit_default_acl_rows" in catalog_sql
assert "acl.grantee = 0" in catalog_sql
assert "default_acl.defaclrole in (select oid from reachable_role)" in catalog_sql
assert "nspname !~ '^pg_'" in catalog_sql assert "nspname !~ '^pg_'" in catalog_sql
assert "nspname <> 'information_schema'" in catalog_sql assert "nspname <> 'information_schema'" in catalog_sql
assert verifier.STAGE_OWNER_DATABASE_ROLE in catalog_sql assert verifier.STAGE_OWNER_DATABASE_ROLE in catalog_sql

View file

@ -614,6 +614,25 @@ with scoped_role_names(role_name) as (
join pg_catalog.pg_roles grantor_role on grantor_role.oid = membership.grantor join pg_catalog.pg_roles grantor_role on grantor_role.oid = membership.grantor
where granted_role.rolname in (select role_name from scoped_role_names) where granted_role.rolname in (select role_name from scoped_role_names)
or member_role.rolname in (select role_name from scoped_role_names) or member_role.rolname in (select role_name from scoped_role_names)
), default_acl_state as (
select coalesce(
pg_catalog.jsonb_agg(
pg_catalog.jsonb_build_object(
'owner', owner_role.rolname,
'schema', namespace.nspname,
'object_type', default_acl.defaclobjtype,
'acl', default_acl.defaclacl::text
)
order by owner_role.rolname,
namespace.nspname nulls first,
default_acl.defaclobjtype,
default_acl.defaclacl::text
),
'[]'::pg_catalog.jsonb
) as value
from pg_catalog.pg_default_acl default_acl
join pg_catalog.pg_roles owner_role on owner_role.oid = default_acl.defaclrole
left join pg_catalog.pg_namespace namespace on namespace.oid = default_acl.defaclnamespace
), ownership_state as ( ), ownership_state as (
select coalesce( select coalesce(
pg_catalog.jsonb_agg( pg_catalog.jsonb_agg(
@ -674,6 +693,7 @@ select pg_catalog.jsonb_build_object(
'roles', (select value from role_state), 'roles', (select value from role_state),
'database_role_settings', (select value from database_role_settings), 'database_role_settings', (select value from database_role_settings),
'memberships', (select value from membership_state), 'memberships', (select value from membership_state),
'default_acls', (select value from default_acl_state),
'ownership', (select value from ownership_state), 'ownership', (select value from ownership_state),
'acls', (select value from normalized_acls), 'acls', (select value from normalized_acls),
'stage_function', pg_catalog.jsonb_build_object( 'stage_function', pg_catalog.jsonb_build_object(
@ -1126,6 +1146,77 @@ def test_digest_pinned_network_disabled_postgres_16_14_permission_contract(tmp_p
"provider residual drift recovery", "provider residual drift recovery",
) )
_require_success(
_psql(
container,
"alter default privileges for role leoclean_kb_stage_owner "
"revoke execute on functions from public;",
),
"scoped-owner protective default ACL fixture",
)
scoped_owner_default_acl = _provision(container, "/tmp/gcp_leoclean_runtime_role.sql")
assert scoped_owner_default_acl.returncode != 0
assert "scoped Leo roles have unsafe explicit default ACL entries" in scoped_owner_default_acl.stderr
assert _require_success(
_psql(container, f"select rolcanlogin from pg_catalog.pg_roles where rolname = '{RUNTIME_ROLE}';"),
"scoped-owner default ACL role fence",
) == "f"
_require_success(
_psql(
container,
"alter default privileges for role leoclean_kb_stage_owner "
"grant execute on functions to public;",
),
"scoped-owner protective default ACL recovery",
)
_require_success(
_psql(
container,
"create role default_acl_probe nologin; "
"alter default privileges for role default_acl_probe in schema public "
"grant select on tables to leoclean_kb_runtime; "
"alter default privileges for role default_acl_probe in schema public "
"grant select on tables to public; "
"alter default privileges for role default_acl_probe in schema public "
"grant usage on sequences to leoclean_kb_stage_owner; "
"alter default privileges for role default_acl_probe "
"grant execute on functions to leoclean_kb_runtime; "
"alter default privileges for role default_acl_probe "
"grant usage on types to leoclean_kb_stage_owner; "
"alter default privileges for role default_acl_probe "
"grant usage on schemas to leoclean_kb_runtime;",
),
"scoped default ACL drift fixture",
)
default_acl_drift = _provision(container, "/tmp/gcp_leoclean_runtime_role.sql")
assert default_acl_drift.returncode != 0
assert "scoped Leo roles have unsafe explicit default ACL entries" in default_acl_drift.stderr
assert _require_success(
_psql(container, f"select rolcanlogin from pg_catalog.pg_roles where rolname = '{RUNTIME_ROLE}';"),
"default ACL drift role fence",
) == "f"
_require_success(
_psql(
container,
"alter default privileges for role default_acl_probe in schema public "
"revoke select on tables from leoclean_kb_runtime; "
"alter default privileges for role default_acl_probe in schema public "
"revoke select on tables from public; "
"alter default privileges for role default_acl_probe in schema public "
"revoke usage on sequences from leoclean_kb_stage_owner; "
"alter default privileges for role default_acl_probe "
"revoke execute on functions from leoclean_kb_runtime; "
"alter default privileges for role default_acl_probe "
"revoke usage on types from leoclean_kb_stage_owner; "
"alter default privileges for role default_acl_probe "
"revoke usage on schemas from leoclean_kb_runtime; "
"alter default privileges for role default_acl_probe "
"revoke execute on functions from public;",
),
"scoped default ACL drift recovery",
)
successful_states: list[dict[str, object]] = [] successful_states: list[dict[str, object]] = []
for provision_number in range(1, 4): for provision_number in range(1, 4):
_require_success( _require_success(
@ -1133,6 +1224,14 @@ def test_digest_pinned_network_disabled_postgres_16_14_permission_contract(tmp_p
f"runtime role provisioning run {provision_number}", f"runtime role provisioning run {provision_number}",
) )
state = _read_provisioning_state(container) state = _read_provisioning_state(container)
assert _require_success(
_psql(
container,
"select count(*) from pg_catalog.pg_default_acl "
"where defaclrole = 'default_acl_probe'::pg_catalog.regrole;",
),
"harmless default ACL preservation",
) == "1"
assert state["catalog"]["stage_function"] == { assert state["catalog"]["stage_function"] == {
"owner": "leoclean_kb_stage_owner", "owner": "leoclean_kb_stage_owner",
"owner_execute": False, "owner_execute": False,
@ -1151,6 +1250,37 @@ def test_digest_pinned_network_disabled_postgres_16_14_permission_contract(tmp_p
assert successful_states[1:] == successful_states[:1] * 2 assert successful_states[1:] == successful_states[:1] * 2
stable_state = successful_states[0] stable_state = successful_states[0]
_require_success(
_psql(
container,
"alter default privileges for role default_acl_probe in schema public "
"grant select on tables to leoclean_kb_runtime;",
),
"runtime verifier default ACL fixture",
)
runtime_default_acl_posture = json.loads(
_require_success(
_psql(container, verifier._catalog_privilege_posture_sql(), role=RUNTIME_ROLE),
"runtime verifier default ACL readback",
)
)
assert runtime_default_acl_posture["unsafe_explicit_default_acl_rows"] == 1
_require_success(
_psql(
container,
"alter default privileges for role default_acl_probe in schema public "
"revoke select on tables from leoclean_kb_runtime;",
),
"runtime verifier default ACL recovery",
)
recovered_default_acl_posture = json.loads(
_require_success(
_psql(container, verifier._catalog_privilege_posture_sql(), role=RUNTIME_ROLE),
"runtime verifier default ACL recovery readback",
)
)
assert recovered_default_acl_posture["unsafe_explicit_default_acl_rows"] == 0
_require_success( _require_success(
_psql(container, f"grant connect on database {CONNECT_SENTINEL_DATABASE} to public;"), _psql(container, f"grant connect on database {CONNECT_SENTINEL_DATABASE} to public;"),
"rollback database ACL fixture", "rollback database ACL fixture",

View file

@ -0,0 +1,90 @@
"""Credential-transport contracts for the GCP leoclean role provisioner."""
from __future__ import annotations
import hashlib
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PROVISIONER = ROOT / "ops" / "provision_gcp_leoclean_runtime_role.sh"
def test_password_provisioner_launcher_keeps_admin_secret_out_of_argv_and_preserves_runtime_records(
tmp_path: Path,
) -> None:
provisioner = PROVISIONER.read_text(encoding="utf-8")
prefix = '"$SETSID_BIN" -- "$BASH_BIN" --noprofile --norc -c \'\n'
suffix = '\n \' leoclean-runtime-role-provisioner "$PSQL_BIN"'
assert provisioner.count(prefix) == 1
assert "set +x" in provisioner
assert "IFS= read -r PGPASSWORD" in provisioner
assert 'PGPASSWORD="$administrator_password"' not in provisioner
assert provisioner.index("printf '%s\\n' \"$administrator_password\"") < provisioner.index(
"printf '%s\\n' \"$runtime_password\""
)
launcher = provisioner.split(prefix, 1)[1].split(suffix, 1)[0]
probe = tmp_path / "password_transport_probe.py"
probe.write_text(
"""import hashlib
import json
import os
import sys
records = sys.stdin.buffer.read().splitlines()
payload = {
"administrator_sha256": hashlib.sha256(os.environ["PGPASSWORD"].encode()).hexdigest(),
"runtime_sha256": [hashlib.sha256(record).hexdigest() for record in records],
}
print(json.dumps(payload, sort_keys=True))
raise SystemExit(0 if len(records) == 2 else 73)
""",
encoding="utf-8",
)
administrator = " admin $() * \\ ' \" ;\ttrailing "
runtime = " runtime [] {} $HOME \\ path ; ' \" \t "
command = [
"/bin/bash",
"--noprofile",
"--norc",
"-c",
launcher,
"leoclean-runtime-role-provisioner",
sys.executable,
str(probe),
]
assert administrator not in command
assert runtime not in command
completed = subprocess.run(
command,
input=f"{administrator}\n{runtime}\n{runtime}\n",
capture_output=True,
check=False,
text=True,
)
assert completed.returncode == 0, completed.stderr
assert completed.stderr == ""
observed = json.loads(completed.stdout)
assert observed == {
"administrator_sha256": hashlib.sha256(administrator.encode()).hexdigest(),
"runtime_sha256": [hashlib.sha256(runtime.encode()).hexdigest()] * 2,
}
assert administrator not in completed.stdout
assert runtime not in completed.stdout
for invalid_input in (administrator, f"{'a' * 4097}\n"):
denied = subprocess.run(
command,
input=invalid_input,
capture_output=True,
check=False,
text=True,
)
assert denied.returncode == 64
assert denied.stdout == ""
assert denied.stderr == ""

View file

@ -101,14 +101,13 @@ def test_gcp_wrapper_and_password_provisioner_are_executable_and_syntax_valid()
assert provisioner.index(capture_runtime) < provisioner.index(first_external_child) assert provisioner.index(capture_runtime) < provisioner.index(first_external_child)
assert provisioner.index(capture_admin) < provisioner.index(first_external_child) assert provisioner.index(capture_admin) < provisioner.index(first_external_child)
assert provisioner.index(clear_ambient) < provisioner.index(first_external_child) assert provisioner.index(clear_ambient) < provisioner.index(first_external_child)
assert '"$SETSID_BIN" -- "$PSQL_BIN"' in provisioner assert '"$SETSID_BIN" -- "$BASH_BIN" --noprofile --norc -c' in provisioner
assert 'kill -TERM -- "-$provision_pid"' in provisioner assert 'kill -TERM -- "-$provision_pid"' in provisioner
assert 'wait "$provision_pid"' in provisioner assert 'wait "$provision_pid"' in provisioner
assert "SERVER_CA_SHA256=80701e" in provisioner assert "SERVER_CA_SHA256=80701e" in provisioner
assert "--no-password" in provisioner assert "--no-password" in provisioner
assert "PGSSLMODE=verify-ca" in provisioner assert "PGSSLMODE=verify-ca" in provisioner
def test_vps_bridge_recognizes_natural_mixed_briefing_contract() -> None: def test_vps_bridge_recognizes_natural_mixed_briefing_contract() -> None:
module = _load_module(BRIDGE_DIR / "kb_tool.py") module = _load_module(BRIDGE_DIR / "kb_tool.py")
contracts = module.operational_contracts( contracts = module.operational_contracts(