Merge pull request #233 from living-ip/fix/leoclean-runtime-authority-guard
Some checks are pending
CI / lint-and-test (push) Waiting to run
Some checks are pending
CI / lint-and-test (push) Waiting to run
Harden leoclean runtime authority checks
This commit is contained in:
commit
5ba6c56aba
7 changed files with 317 additions and 6 deletions
|
|
@ -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, '<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 (
|
||||
select 1
|
||||
from pg_catalog.pg_class relation
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
90
tests/test_gcp_leoclean_runtime_role_provisioner.py
Normal file
90
tests/test_gcp_leoclean_runtime_role_provisioner.py
Normal 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 == ""
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue