diff --git a/backend/tests/test_delivery.py b/backend/tests/test_delivery.py index d658a14..0fe4c85 100644 --- a/backend/tests/test_delivery.py +++ b/backend/tests/test_delivery.py @@ -1759,6 +1759,7 @@ def test_readiness_rejects_wrong_or_excess_database_authority(field, value): "set_role": False, "connection_limit": 8, "privileges": node_health.PRIVILEGES["coordinator"].copy(), + "functions": node_health.function_privileges("coordinator"), } node_health.validate_role("coordinator", observed) observed[field] = value @@ -2421,3 +2422,69 @@ def test_readonly_helper_import_does_not_load_installation_or_configuration_tool "pydantic", } ) + + +def test_readiness_database_handshake_uses_only_explicit_local_transport(monkeypatch): + import asyncio + + import asyncpg + + class ReachedLocalConnection(Exception): + pass + + async def connect(raw, **kwargs): + assert raw == "synthetic-local-dsn" + assert kwargs["ssl"] is False + assert kwargs["timeout"] == 5 and kwargs["command_timeout"] == 2 + assert kwargs["server_settings"]["default_transaction_read_only"] == "on" + assert kwargs["server_settings"]["statement_timeout"] == "2000" + raise ReachedLocalConnection + + monkeypatch.setattr(asyncpg, "connect", connect) + with pytest.raises(ReachedLocalConnection): + asyncio.run(node_health.roles({"database": {"api": "synthetic-local-dsn"}})) + + +@pytest.mark.parametrize( + "change", [None, "table_read", "column_write", "unlogged", "missing_entry"] +) +def test_identity_readiness_requires_only_the_audited_authority_entrypoint(change): + observed = { + "login": "kf_identity_operator", + "database": "kf_onlinejudge", + "canlogin": True, + "superuser": False, + "createdb": False, + "createrole": False, + "replication": False, + "bypassrls": False, + "member": True, + "inherited": True, + "set_role": False, + "connection_limit": 8, + "privileges": { + "accounts:SELECT": False, + "accounts:is_administrator:UPDATE": False, + "accounts:password_hash:SELECT": False, + "sessions:token_hash:SELECT": False, + "evaluations:SELECT": False, + "evaluations:state:UPDATE": False, + }, + "functions": { + "public.kf_manage_account_authority(text,text)": True, + "public.kf_manage_account_authority_unlogged(text,text)": False, + }, + } + if change == "table_read": + observed["privileges"]["accounts:SELECT"] = True + elif change == "column_write": + observed["privileges"]["accounts:is_administrator:UPDATE"] = True + elif change == "unlogged": + observed["functions"]["public.kf_manage_account_authority_unlogged(text,text)"] = True + elif change == "missing_entry": + observed["functions"]["public.kf_manage_account_authority(text,text)"] = False + if change is None: + node_health.validate_role("identity", observed) + else: + with pytest.raises(policy.DeliveryError, match="role authority"): + node_health.validate_role("identity", observed) diff --git a/scripts/release/README.md b/scripts/release/README.md index 6d5ea3d..7117623 100644 --- a/scripts/release/README.md +++ b/scripts/release/README.md @@ -420,6 +420,11 @@ its memory, process and privilege limits remain fixed. These operator budgets do not change student execution budgets. Startup opens admission only after all required readiness queries succeed. +Database authority queries use the fixed local TCP/Unix endpoints with TLS disabled +and a five-second connection deadline. SQL remains read-only with a two-second +statement limit. The identity operator needs EXECUTE on the audited authority +function, with no direct account/session access or unlogged authority entrypoint. + Broker metadata uses one fixed read-only RPC with a five-second remote deadline and a 30-second CLI process deadline, within the helper's 45-second limit. It reads queue metadata, vhost limits, usernames/tags and permissions; it does not read diff --git a/scripts/release/node_health.py b/scripts/release/node_health.py index 84346f0..f258e06 100644 --- a/scripts/release/node_health.py +++ b/scripts/release/node_health.py @@ -51,12 +51,24 @@ PRIVILEGES = { "accounts:SELECT": False, }, "identity": { - "accounts:SELECT": True, - "accounts:is_administrator:UPDATE": True, + "accounts:SELECT": False, + "accounts:is_administrator:UPDATE": False, + "accounts:password_hash:SELECT": False, + "sessions:token_hash:SELECT": False, "evaluations:SELECT": False, "evaluations:state:UPDATE": False, }, } +AUTHORITY_FUNCTIONS = ( + "public.kf_manage_account_authority(text,text)", + "public.kf_manage_account_authority_unlogged(text,text)", +) + + +def function_privileges(role: str) -> dict: + return { + name: role == "identity" and name == AUTHORITY_FUNCTIONS[0] for name in AUTHORITY_FUNCTIONS + } def binding() -> tuple[dict, dict, Path]: @@ -260,6 +272,7 @@ def validate_role(role: str, observed: dict) -> None: "set_role": False, "connection_limit": 8, "privileges": PRIVILEGES[role], + "functions": function_privileges(role), } if canonical(observed) != canonical(expected): raise DeliveryError("database role authority differs") @@ -272,7 +285,11 @@ async def roles(endpoints: dict) -> dict: for role, requirements in PRIVILEGES.items(): connection = await asyncpg.connect( endpoints["database"][role], - timeout=2, + # Connections are restricted to numeric loopback or a private Unix + # socket; PostgreSQL has TLS disabled. Avoid home-certificate lookup + # inside ProtectHome and allow the bounded local SCRAM handshake. + ssl=False, + timeout=5, command_timeout=2, server_settings={ "application_name": "kf-node-health", @@ -304,6 +321,12 @@ async def roles(endpoints: dict) -> dict: else "SELECT has_column_privilege(current_user,$1,$2,$3)", *parts, ) + observed["functions"] = { + name: await connection.fetchval( + "SELECT has_function_privilege(current_user,$1,'EXECUTE')", name + ) + for name in AUTHORITY_FUNCTIONS + } validate_role(role, observed) result[role] = {"authority_verified": True} finally: