diff --git a/ops/gcp_leoclean_runtime_role.sql b/ops/gcp_leoclean_runtime_role.sql index cae46c2..1e27889 100644 --- a/ops/gcp_leoclean_runtime_role.sql +++ b/ops/gcp_leoclean_runtime_role.sql @@ -886,6 +886,56 @@ begin raise exception 'scoped Leo roles retain role memberships: %', unexpected; 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, ''), + 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 ( select 1 from pg_catalog.pg_class relation diff --git a/ops/provision_gcp_leoclean_runtime_role.sh b/ops/provision_gcp_leoclean_runtime_role.sh index 9ff89f8..1dc47e9 100755 --- a/ops/provision_gcp_leoclean_runtime_role.sh +++ b/ops/provision_gcp_leoclean_runtime_role.sh @@ -23,6 +23,7 @@ SERVER_CA_FILE="$SCRIPT_DIR/gcp-teleo-pgvector-standby-server-ca.pem" PSQL_BIN=/usr/bin/psql SETSID_BIN=/usr/bin/setsid ENV_BIN=/usr/bin/env +BASH_BIN=/bin/bash SHA256SUM_BIN=/usr/bin/sha256sum SERVER_CA_SHA256=80701e768f0e1f6b9d621aa0b53f6e851daaa276c6d9a8e51a300fbc015539cb @@ -70,6 +71,7 @@ fi assert_trusted_executable "$PSQL_BIN" assert_trusted_executable "$SETSID_BIN" assert_trusted_executable "$ENV_BIN" +assert_trusted_executable "$BASH_BIN" assert_trusted_executable "$SHA256SUM_BIN" 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 @@ -117,12 +119,17 @@ trap cleanup_password EXIT trap 'terminate_provisioning 130' INT trap 'terminate_provisioning 143' TERM -# \password encrypts client-side. --no-password ensures an administrator-login -# prompt cannot consume either of the two runtime-password records from stdin. +# The isolated shell consumes the administrator password from the first 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 # its PID as the session/process-group leader so an interruption can terminate # 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" } | "$ENV_BIN" -i \ @@ -133,10 +140,15 @@ trap 'terminate_provisioning 143' TERM PGPORT=5432 \ PGDATABASE=teleo_canonical \ PGUSER=postgres \ - PGPASSWORD="$administrator_password" \ PGSSLMODE=verify-ca \ 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-password \ --set=ON_ERROR_STOP=1 \ diff --git a/ops/verify_gcp_leoclean_runtime_permissions.py b/ops/verify_gcp_leoclean_runtime_permissions.py index 204d70b..4eb1ac5 100644 --- a/ops/verify_gcp_leoclean_runtime_permissions.py +++ b/ops/verify_gcp_leoclean_runtime_permissions.py @@ -345,6 +345,7 @@ CATALOG_ZERO_COUNT_FIELDS: tuple[str, ...] = ( "unsafe_proposal_rewrite_rules", "unsafe_proposal_triggers", "unsafe_proposal_generated_columns", + "unsafe_explicit_default_acl_rows", ) CATALOG_TRUE_FIELDS: tuple[str, ...] = ( @@ -1590,6 +1591,31 @@ select (pg_catalog.jsonb_build_object( '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', ( select pg_catalog.count(*)::int from allowed_select_column diff --git a/tests/test_gcp_leoclean_runtime_permissions.py b/tests/test_gcp_leoclean_runtime_permissions.py index 1d5fb70..4d46047 100644 --- a/tests/test_gcp_leoclean_runtime_permissions.py +++ b/tests/test_gcp_leoclean_runtime_permissions.py @@ -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_function_privilege" 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 <> 'information_schema'" in catalog_sql assert verifier.STAGE_OWNER_DATABASE_ROLE in catalog_sql diff --git a/tests/test_gcp_leoclean_runtime_role_postgres.py b/tests/test_gcp_leoclean_runtime_role_postgres.py index 1c79e33..40e288a 100644 --- a/tests/test_gcp_leoclean_runtime_role_postgres.py +++ b/tests/test_gcp_leoclean_runtime_role_postgres.py @@ -614,6 +614,25 @@ with scoped_role_names(role_name) as ( 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) 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 ( select coalesce( pg_catalog.jsonb_agg( @@ -674,6 +693,7 @@ select pg_catalog.jsonb_build_object( 'roles', (select value from role_state), 'database_role_settings', (select value from database_role_settings), 'memberships', (select value from membership_state), + 'default_acls', (select value from default_acl_state), 'ownership', (select value from ownership_state), 'acls', (select value from normalized_acls), '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", ) + _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]] = [] for provision_number in range(1, 4): _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}", ) 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"] == { "owner": "leoclean_kb_stage_owner", "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 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( _psql(container, f"grant connect on database {CONNECT_SENTINEL_DATABASE} to public;"), "rollback database ACL fixture", diff --git a/tests/test_gcp_leoclean_runtime_role_provisioner.py b/tests/test_gcp_leoclean_runtime_role_provisioner.py new file mode 100644 index 0000000..74654b5 --- /dev/null +++ b/tests/test_gcp_leoclean_runtime_role_provisioner.py @@ -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 == "" diff --git a/tests/test_hermes_leoclean_kb_bridge_source.py b/tests/test_hermes_leoclean_kb_bridge_source.py index 09be5a7..589ec99 100644 --- a/tests/test_hermes_leoclean_kb_bridge_source.py +++ b/tests/test_hermes_leoclean_kb_bridge_source.py @@ -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_admin) < 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 'wait "$provision_pid"' in provisioner assert "SERVER_CA_SHA256=80701e" in provisioner assert "--no-password" in provisioner assert "PGSSLMODE=verify-ca" in provisioner - def test_vps_bridge_recognizes_natural_mixed_briefing_contract() -> None: module = _load_module(BRIDGE_DIR / "kb_tool.py") contracts = module.operational_contracts(