diff --git a/deploy/postgresql/reputation_state_runtime_principal.sql b/deploy/postgresql/reputation_state_runtime_principal.sql new file mode 100644 index 00000000..d88a1274 --- /dev/null +++ b/deploy/postgresql/reputation_state_runtime_principal.sql @@ -0,0 +1,218 @@ +-- Map one externally managed PostgreSQL LOGIN identity to Wardnet's existing +-- least-privilege runtime capability role. +-- +-- This artifact deliberately does not CREATE ROLE, set passwords, own schema +-- objects, or enable the application PostgreSQL adapter. Deployment/IAM owns +-- login lifecycle and credentials; Wardnet owns only the bounded membership +-- edge required by its reputation-state repository boundary. +-- +-- Usage: +-- psql -v ON_ERROR_STOP=1 \ +-- -v wardnet_runtime_principal='wardnet_app' \ +-- -f deploy/postgresql/reputation_state_runtime_principal.sql +\set ON_ERROR_STOP on + +\if :{?wardnet_runtime_principal} +\else +DO $wardnet_missing_runtime_principal$ +BEGIN + RAISE EXCEPTION 'Wardnet runtime principal mapping requires wardnet_runtime_principal.'; +END +$wardnet_missing_runtime_principal$; +\endif + +BEGIN; + +-- The capability roles are installed by reputation_state_roles.sql. Refuse to +-- grant through drifted or nested capability roles: an unexpected membership +-- can import authority that this mapper cannot safely characterize. +SELECT + count(*) = 2 + AND bool_and(NOT rolcanlogin) + AND bool_and(NOT rolsuper) + AND bool_and(NOT rolcreatedb) + AND bool_and(NOT rolcreaterole) + AND bool_and(NOT rolinherit) + AND bool_and(NOT rolreplication) + AND bool_and(NOT rolbypassrls) + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_auth_members membership + JOIN pg_catalog.pg_roles member_role + ON member_role.oid = membership.member + WHERE member_role.rolname IN ('wardnet_runtime', 'wardnet_state_owner') + ) AS capability_roles_safe +FROM pg_catalog.pg_roles +WHERE rolname IN ('wardnet_runtime', 'wardnet_state_owner') +\gset + +\if :capability_roles_safe +\else +DO $wardnet_unsafe_capability_roles$ +BEGIN + RAISE EXCEPTION 'Wardnet runtime principal mapping refused: capability-role attributes or memberships are unsafe.'; +END +$wardnet_unsafe_capability_roles$; +\endif + +-- The supplied login must already exist and remain an ordinary inheriting +-- application identity. Special PostgreSQL role attributes are not inherited, +-- but SET-capable membership in an elevated role could still acquire them, so +-- reject any direct or indirect privileged-role membership as well as any +-- access to Wardnet's state-owner role. The principal must also arrive without +-- out-of-band mutation or inner-admission privileges on Wardnet state objects; +-- otherwise adding wardnet_runtime would preserve a wider effective authority +-- than this mapper is allowed to establish. Runtime membership is valid only +-- when absent (first mapping) or already present as the mapper's exact bounded +-- direct edge (idempotent replay); any alternate role path to wardnet_runtime +-- is outside this artifact's authority and therefore fails closed. +SELECT + count(*) = 1 + AND bool_and(rolcanlogin) + AND bool_and(NOT rolsuper) + AND bool_and(NOT rolcreatedb) + AND bool_and(NOT rolcreaterole) + AND bool_and(rolinherit) + AND bool_and(NOT rolreplication) + AND bool_and(NOT rolbypassrls) + AND NOT pg_catalog.pg_has_role( + :'wardnet_runtime_principal', + 'wardnet_state_owner', + 'MEMBER' + ) + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_roles elevated_role + WHERE ( + elevated_role.rolsuper + OR elevated_role.rolcreatedb + OR elevated_role.rolcreaterole + OR elevated_role.rolreplication + OR elevated_role.rolbypassrls + ) + AND pg_catalog.pg_has_role( + :'wardnet_runtime_principal', + elevated_role.oid, + 'MEMBER' + ) + ) + AND NOT pg_catalog.has_table_privilege( + :'wardnet_runtime_principal', + 'public.reputation_source_generation', + 'INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER' + ) + AND NOT pg_catalog.has_any_column_privilege( + :'wardnet_runtime_principal', + 'public.reputation_source_generation', + 'INSERT,UPDATE,REFERENCES' + ) + AND NOT pg_catalog.has_table_privilege( + :'wardnet_runtime_principal', + 'public.reputation_source_publication', + 'INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER' + ) + AND NOT pg_catalog.has_any_column_privilege( + :'wardnet_runtime_principal', + 'public.reputation_source_publication', + 'INSERT,UPDATE,REFERENCES' + ) + AND NOT pg_catalog.has_table_privilege( + :'wardnet_runtime_principal', + 'public.reputation_source_publication_head', + 'INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER' + ) + AND NOT pg_catalog.has_any_column_privilege( + :'wardnet_runtime_principal', + 'public.reputation_source_publication_head', + 'INSERT,UPDATE,REFERENCES' + ) + AND NOT pg_catalog.has_function_privilege( + :'wardnet_runtime_principal', + 'public.wardnet_admit_reputation_source_generation(text,text,text,bigint,bigint,text)', + 'EXECUTE' + ) + AND ( + SELECT + count(*) = 0 + OR ( + count(*) = 1 + AND bool_and(NOT membership.admin_option) + AND bool_and(membership.inherit_option) + AND bool_and(NOT membership.set_option) + ) + FROM pg_catalog.pg_auth_members membership + JOIN pg_catalog.pg_roles granted_role + ON granted_role.oid = membership.roleid + JOIN pg_catalog.pg_roles member_role + ON member_role.oid = membership.member + WHERE granted_role.rolname = 'wardnet_runtime' + AND member_role.rolname = :'wardnet_runtime_principal' + ) + AND NOT EXISTS ( + WITH RECURSIVE alternate_memberships(roleid) AS ( + SELECT membership.roleid + 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 granted_role + ON granted_role.oid = membership.roleid + WHERE member_role.rolname = :'wardnet_runtime_principal' + AND granted_role.rolname <> 'wardnet_runtime' + UNION + SELECT membership.roleid + FROM pg_catalog.pg_auth_members membership + JOIN alternate_memberships inherited + ON inherited.roleid = membership.member + ) + SELECT 1 + FROM alternate_memberships inherited + JOIN pg_catalog.pg_roles inherited_role + ON inherited_role.oid = inherited.roleid + WHERE inherited_role.rolname = 'wardnet_runtime' + ) AS runtime_principal_safe +FROM pg_catalog.pg_roles +WHERE rolname = :'wardnet_runtime_principal' +\gset + +\if :runtime_principal_safe +\else +DO $wardnet_unsafe_runtime_principal$ +BEGIN + RAISE EXCEPTION 'Wardnet runtime principal mapping refused: principal is absent, non-login, non-inheriting, privileged, state-owner capable, already has Wardnet mutation/inner-admission authority, or already has an unbounded runtime membership path.'; +END +$wardnet_unsafe_runtime_principal$; +\endif + +-- format(%I) is the sole conversion from the supplied identity string to a SQL +-- identifier. INHERIT exposes only wardnet_runtime object privileges; SET FALSE +-- prevents the login from changing current_user to the capability role, and +-- ADMIN FALSE prevents delegation of the capability to another principal. +SELECT pg_catalog.format( + 'GRANT wardnet_runtime TO %I WITH ADMIN FALSE, INHERIT TRUE, SET FALSE', + :'wardnet_runtime_principal' +) +\gexec + +SELECT + count(*) = 1 + AND bool_and(NOT membership.admin_option) + AND bool_and(membership.inherit_option) + AND bool_and(NOT membership.set_option) AS runtime_mapping_complete +FROM pg_catalog.pg_auth_members membership +JOIN pg_catalog.pg_roles granted_role + ON granted_role.oid = membership.roleid +JOIN pg_catalog.pg_roles member_role + ON member_role.oid = membership.member +WHERE granted_role.rolname = 'wardnet_runtime' + AND member_role.rolname = :'wardnet_runtime_principal' +\gset + +\if :runtime_mapping_complete + COMMIT; +\else +DO $wardnet_runtime_mapping_failed$ +BEGIN + RAISE EXCEPTION 'Wardnet runtime principal mapping refused: bounded membership postcondition is incomplete.'; +END +$wardnet_runtime_mapping_failed$; +\endif diff --git a/tests/postgres_runtime_principal_mapping.rs b/tests/postgres_runtime_principal_mapping.rs new file mode 100644 index 00000000..e38c0e36 --- /dev/null +++ b/tests/postgres_runtime_principal_mapping.rs @@ -0,0 +1,336 @@ +use std::process::{Command, Output, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +const POSTGRES_IMAGE: &str = "postgres:18.4-bookworm"; +const MIGRATION_ENTRYPOINT_IN_CONTAINER: &str = + "/wardnet/deploy/postgresql/reputation_state_migrate.sql"; +const ROLE_INSTALLER_IN_CONTAINER: &str = "/wardnet/deploy/postgresql/reputation_state_roles.sql"; +const PRINCIPAL_MAPPER_IN_CONTAINER: &str = + "/wardnet/deploy/postgresql/reputation_state_runtime_principal.sql"; +const FINAL_STARTUP_MARKER: &str = "PostgreSQL init process complete; ready for start up."; + +struct PostgresContainer { + name: String, +} + +impl Drop for PostgresContainer { + fn drop(&mut self) { + let _ = Command::new("docker") + .args(["rm", "-f", &self.name]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + +fn run_docker(args: &[&str]) -> Output { + Command::new("docker") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("docker command must finish") +} + +fn assert_success(output: Output, context: &str) -> String { + if !output.status.success() { + panic!( + "{context} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + String::from_utf8(output.stdout).expect("command output must be UTF-8") +} + +fn psql(container: &PostgresContainer, sql: &str) -> Output { + run_docker(&[ + "exec", + &container.name, + "psql", + "-X", + "-q", + "-A", + "-t", + "-v", + "ON_ERROR_STOP=1", + "-U", + "postgres", + "-d", + "postgres", + "-c", + sql, + ]) +} + +fn psql_file(container: &PostgresContainer, path: &str) -> Output { + run_docker(&[ + "exec", + &container.name, + "psql", + "-X", + "-q", + "-A", + "-t", + "-v", + "ON_ERROR_STOP=1", + "-U", + "postgres", + "-d", + "postgres", + "-f", + path, + ]) +} + +fn map_runtime_principal(container: &PostgresContainer, principal: &str) -> Output { + let variable = format!("wardnet_runtime_principal={principal}"); + run_docker(&[ + "exec", + &container.name, + "psql", + "-X", + "-q", + "-A", + "-t", + "-v", + "ON_ERROR_STOP=1", + "-v", + &variable, + "-U", + "postgres", + "-d", + "postgres", + "-f", + PRINCIPAL_MAPPER_IN_CONTAINER, + ]) +} + +fn start_postgres() -> Option { + let available = Command::new("docker") + .arg("version") + .arg("--format") + .arg("{{.Server.Version}}") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + if !available { + if std::env::var_os("CI").is_some() { + panic!("Docker is required for PostgreSQL integration tests in CI"); + } + eprintln!("skipping PostgreSQL integration test because Docker is unavailable"); + return None; + } + + let name = format!("wardnet-postgres-runtime-principal-{}", std::process::id()); + assert_success( + run_docker(&[ + "run", + "--rm", + "-d", + "--name", + &name, + "-e", + "POSTGRES_HOST_AUTH_METHOD=trust", + POSTGRES_IMAGE, + ]), + "start PostgreSQL 18.4 container", + ); + let container = PostgresContainer { name }; + let deadline = Instant::now() + Duration::from_secs(60); + loop { + let ready = Command::new("docker") + .args([ + "exec", + &container.name, + "pg_isready", + "-U", + "postgres", + "-d", + "postgres", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + let logs = run_docker(&["logs", &container.name]); + let started = logs.status.success() + && (String::from_utf8_lossy(&logs.stdout).contains(FINAL_STARTUP_MARKER) + || String::from_utf8_lossy(&logs.stderr).contains(FINAL_STARTUP_MARKER)); + if ready && started { + return Some(container); + } + assert!( + Instant::now() < deadline, + "PostgreSQL 18.4 final server did not become ready within 60 seconds" + ); + thread::sleep(Duration::from_millis(500)); + } +} + +fn stage_deployment_tree(container: &PostgresContainer) { + assert_success( + run_docker(&["exec", &container.name, "mkdir", "-p", "/wardnet"]), + "create deployment fixture root", + ); + let destination = format!("{}:/wardnet/", container.name); + assert_success( + run_docker(&["cp", "migrations", &destination]), + "stage canonical migrations", + ); + assert_success( + run_docker(&["cp", "deploy", &destination]), + "stage deployment artifacts", + ); +} + +#[test] +fn runtime_principal_mapping_is_external_identity_least_privilege_and_injection_safe() { + let Some(container) = start_postgres() else { + return; + }; + stage_deployment_tree(&container); + assert_success( + psql_file(&container, MIGRATION_ENTRYPOINT_IN_CONTAINER), + "migrate empty database to current supported schema", + ); + assert_success( + psql_file(&container, ROLE_INSTALLER_IN_CONTAINER), + "install Wardnet capability roles", + ); + + assert_success( + psql( + &container, + "CREATE ROLE wardnet_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT NOBYPASSRLS NOREPLICATION;", + ), + "create externally managed synthetic application login", + ); + let before = assert_success( + psql( + &container, + "SELECT concat_ws(':', pg_has_role('wardnet_app', 'wardnet_runtime', 'member'), pg_has_role('wardnet_app', 'wardnet_state_owner', 'member'), has_table_privilege('wardnet_app', 'public.reputation_source_generation', 'SELECT'), has_table_privilege('wardnet_app', 'public.reputation_source_generation', 'INSERT'), has_function_privilege('wardnet_app', 'public.wardnet_publish_reputation_source_generation(text,text,text,text,bigint,bigint,text,text,text,text)', 'EXECUTE'), has_function_privilege('wardnet_app', 'public.wardnet_admit_reputation_source_generation(text,text,text,bigint,bigint,text)', 'EXECUTE'));", + ), + "inspect unmapped application login", + ); + assert_eq!(before.trim(), "f:f:f:f:f:f"); + + assert_success( + map_runtime_principal(&container, "wardnet_app"), + "map externally managed application login to bounded runtime capability", + ); + assert_success( + map_runtime_principal(&container, "wardnet_app"), + "replay application principal mapping idempotently", + ); + let after = assert_success( + psql( + &container, + "SELECT concat_ws(':', pg_has_role('wardnet_app', 'wardnet_runtime', 'member'), pg_has_role('wardnet_app', 'wardnet_state_owner', 'member'), has_table_privilege('wardnet_app', 'public.reputation_source_generation', 'SELECT'), has_table_privilege('wardnet_app', 'public.reputation_source_generation', 'INSERT'), has_function_privilege('wardnet_app', 'public.wardnet_publish_reputation_source_generation(text,text,text,text,bigint,bigint,text,text,text,text)', 'EXECUTE'), has_function_privilege('wardnet_app', 'public.wardnet_admit_reputation_source_generation(text,text,text,bigint,bigint,text)', 'EXECUTE'), (SELECT count(*) FROM pg_catalog.pg_auth_members membership JOIN pg_catalog.pg_roles granted_role ON granted_role.oid = membership.roleid JOIN pg_catalog.pg_roles member_role ON member_role.oid = membership.member WHERE granted_role.rolname = 'wardnet_runtime' AND member_role.rolname = 'wardnet_app'));", + ), + "inspect mapped application login", + ); + assert_eq!(after.trim(), "t:f:t:f:t:f:1"); + + assert_success( + psql( + &container, + "CREATE ROLE wardnet_privileged LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT BYPASSRLS NOREPLICATION;", + ), + "create privileged synthetic login", + ); + let privileged = map_runtime_principal(&container, "wardnet_privileged"); + assert!( + !privileged.status.success(), + "BYPASSRLS principal must fail closed rather than inherit Wardnet runtime authority" + ); + let privileged_membership = assert_success( + psql( + &container, + "SELECT pg_has_role('wardnet_privileged', 'wardnet_runtime', 'member');", + ), + "inspect rejected privileged principal", + ); + assert_eq!(privileged_membership.trim(), "f"); + + assert_success( + psql( + &container, + "CREATE ROLE wardnet_state_delegate NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT NOBYPASSRLS NOREPLICATION; GRANT INSERT ON TABLE public.reputation_source_generation TO wardnet_state_delegate; GRANT EXECUTE ON FUNCTION public.wardnet_admit_reputation_source_generation(text,text,text,bigint,bigint,text) TO wardnet_state_delegate; CREATE ROLE wardnet_overprivileged LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT NOBYPASSRLS NOREPLICATION; GRANT wardnet_state_delegate TO wardnet_overprivileged WITH ADMIN FALSE, INHERIT TRUE, SET TRUE;", + ), + "create an inherited Wardnet state-capability path outside the bounded mapper", + ); + let overprivileged_before = assert_success( + psql( + &container, + "SELECT concat_ws(':', has_table_privilege('wardnet_overprivileged', 'public.reputation_source_generation', 'INSERT'), has_function_privilege('wardnet_overprivileged', 'public.wardnet_admit_reputation_source_generation(text,text,text,bigint,bigint,text)', 'EXECUTE'), pg_has_role('wardnet_overprivileged', 'wardnet_runtime', 'member'));", + ), + "inspect out-of-band Wardnet state authority before mapping", + ); + assert_eq!(overprivileged_before.trim(), "t:t:f"); + let overprivileged = map_runtime_principal(&container, "wardnet_overprivileged"); + assert!( + !overprivileged.status.success(), + "principal with out-of-band Wardnet mutation or inner-admission authority must fail closed" + ); + let overprivileged_after = assert_success( + psql( + &container, + "SELECT pg_has_role('wardnet_overprivileged', 'wardnet_runtime', 'member');", + ), + "inspect rejected overprivileged principal", + ); + assert_eq!(overprivileged_after.trim(), "f"); + + assert_success( + psql( + &container, + "CREATE ROLE wardnet_runtime_delegate NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT NOBYPASSRLS NOREPLICATION; GRANT wardnet_runtime TO wardnet_runtime_delegate WITH ADMIN TRUE, INHERIT TRUE, SET TRUE; CREATE ROLE wardnet_shadowed LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT NOBYPASSRLS NOREPLICATION; GRANT wardnet_runtime_delegate TO wardnet_shadowed WITH ADMIN FALSE, INHERIT TRUE, SET TRUE;", + ), + "create a shadow runtime-membership path outside the bounded mapper", + ); + let shadowed_before = assert_success( + psql( + &container, + "SELECT concat_ws(':', pg_has_role('wardnet_shadowed', 'wardnet_runtime', 'member'), (SELECT count(*) FROM pg_catalog.pg_auth_members membership JOIN pg_catalog.pg_roles granted_role ON granted_role.oid = membership.roleid JOIN pg_catalog.pg_roles member_role ON member_role.oid = membership.member WHERE granted_role.rolname = 'wardnet_runtime' AND member_role.rolname = 'wardnet_shadowed'));", + ), + "inspect shadowed runtime principal before mapping", + ); + assert_eq!(shadowed_before.trim(), "t:0"); + let shadowed = map_runtime_principal(&container, "wardnet_shadowed"); + assert!( + !shadowed.status.success(), + "principal with an inherited runtime path must fail closed instead of gaining a second direct mapping" + ); + let shadowed_after = assert_success( + psql( + &container, + "SELECT count(*) FROM pg_catalog.pg_auth_members membership JOIN pg_catalog.pg_roles granted_role ON granted_role.oid = membership.roleid JOIN pg_catalog.pg_roles member_role ON member_role.oid = membership.member WHERE granted_role.rolname = 'wardnet_runtime' AND member_role.rolname = 'wardnet_shadowed';", + ), + "inspect rejected shadow runtime principal", + ); + assert_eq!(shadowed_after.trim(), "0"); + + let hostile = "wardnet_hostile; CREATE ROLE wardnet_injected"; + assert_success( + psql( + &container, + "CREATE ROLE \"wardnet_hostile; CREATE ROLE wardnet_injected\" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT NOBYPASSRLS NOREPLICATION;", + ), + "create synthetic hostile-name login", + ); + assert_success( + map_runtime_principal(&container, hostile), + "map hostile-name principal without SQL injection", + ); + let hostile_result = assert_success( + psql( + &container, + "SELECT concat_ws(':', pg_has_role('wardnet_hostile; CREATE ROLE wardnet_injected', 'wardnet_runtime', 'member'), to_regrole('wardnet_injected') IS NULL);", + ), + "inspect hostile-name mapping", + ); + assert_eq!(hostile_result.trim(), "t:t"); +}