diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..01f8576 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +jobs: + docker: + name: Docker integration test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Build and run tests + run: | + docker compose up \ + --build \ + --abort-on-container-exit \ + --exit-code-from test diff --git a/Cargo.toml b/Cargo.toml index 4825218..b5d62cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,11 @@ [package] name = "pg_command_fw" -version = "0.0.0" +version = "0.1.0" edition = "2021" +description = "PostgreSQL extension that enforces a configurable DDL/utility command firewall via ProcessUtility hook" +license = "BSD-3-Clause" +keywords = ["postgresql", "pgrx", "security", "ddl", "firewall"] +categories = ["database"] [lib] crate-type = ["cdylib", "lib"] @@ -11,7 +15,7 @@ name = "pgrx_embed_pg_command_fw" path = "./src/bin/pgrx_embed.rs" [features] -default = ["pg13"] +default = ["pg17"] pg13 = ["pgrx/pg13", "pgrx-tests/pg13" ] pg14 = ["pgrx/pg14", "pgrx-tests/pg14" ] pg15 = ["pgrx/pg15", "pgrx-tests/pg15" ] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..623e989 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# Stage 1: Build the extension using pgrx +FROM rust:1-trixie AS builder + +# Add the official PostgreSQL apt repository so postgresql-server-dev-17 is available +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + gnupg \ + lsb-release \ + && curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | gpg --dearmor -o /usr/share/keyrings/pgdg.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] \ + https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update && apt-get install -y --no-install-recommends \ + postgresql-server-dev-17 \ + clang \ + libclang-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +RUN cargo install cargo-pgrx --version 0.17.0 --locked + +WORKDIR /build +COPY . . + +# Register system pg17 with pgrx (no download needed), then package the extension +RUN cargo pgrx init --pg17 /usr/bin/pg_config && \ + cargo pgrx package --pg-config /usr/bin/pg_config --features pg17 + +# Stage 2: PostgreSQL 17 with the extension installed +FROM postgres:17-trixie + +COPY --from=builder \ + /build/target/release/pg_command_fw-pg17/usr/lib/postgresql/17/lib/pg_command_fw.so \ + /usr/lib/postgresql/17/lib/pg_command_fw.so + +COPY --from=builder \ + /build/target/release/pg_command_fw-pg17/usr/share/postgresql/17/extension/ \ + /usr/share/postgresql/17/extension/ diff --git a/README.md b/README.md index cc5ce09..86cbea3 100644 --- a/README.md +++ b/README.md @@ -1 +1,162 @@ -# pg_command_fw \ No newline at end of file +# pg_command_fw + +A PostgreSQL extension that intercepts and optionally blocks DDL and utility commands via a `ProcessUtility` hook. Each command category is independently controlled by a GUC flag. + +## Building + +The extension is built with [pgrx](https://github.com/pgcentralfoundation/pgrx). + +**Prerequisites:** Rust toolchain, `clang`, PostgreSQL server dev headers. + +```bash +cargo install cargo-pgrx --version 0.17.0 --locked +cargo pgrx init # downloads and configures a managed PostgreSQL instance +``` + +Build for a specific PostgreSQL version (13–18): + +```bash +cargo build --features pg17 +``` + +Produce an installable package (`.so` + extension files): + +```bash +cargo pgrx package --features pg17 +``` + +## Running tests + +### Unit tests (pgrx managed instance) + +Spins up a temporary PostgreSQL process, runs all `#[pg_test]` functions, then shuts it down: + +```bash +cargo pgrx test --features pg17 +``` + +To run against a different PostgreSQL version replace `pg17` with `pg13`–`pg18`. + +### Integration tests (Docker) + +Builds the extension inside Docker and runs the full integration suite in `tests/docker/test.sh` against a real PostgreSQL 17 instance: + +```bash +docker compose up --build --abort-on-container-exit --exit-code-from test +``` + +This is the same command run by CI on every push. + +## Installation + +Add to `postgresql.conf`: + +``` +shared_preload_libraries = 'pg_command_fw' +``` + +Then create the extension in the target database: + +```sql +CREATE EXTENSION pg_command_fw; +``` + +## Command categories + +| Category | GUC | Default | Who is blocked | +|---|---|---|---| +| `TRUNCATE` | `pg_command_fw.block_truncate` | `on` | Non-superusers | +| `DROP TABLE` | `pg_command_fw.block_drop_table` | `off` | Non-superusers (opt-in) | +| `ALTER SYSTEM` | `pg_command_fw.block_alter_system` | `on` | Everyone including superusers | +| `LOAD` | `pg_command_fw.block_load` | `on` | Everyone including superusers | +| `COPY … PROGRAM` | `pg_command_fw.block_copy_program` | `on` | Everyone including superusers | +| Plain `COPY` | `pg_command_fw.block_copy` | `off` | Non-superusers (opt-in) | + +Superusers are always exempt from non-superuser checks unless they appear in `pg_command_fw.blocked_roles`. + +## GUC reference + +### Master switch + +**`pg_command_fw.enabled`** (bool, default `on`) +Set to `off` to disable all firewall checks without unloading the extension. + +### Per-category flags + +**`pg_command_fw.block_truncate`** (bool, default `on`) +Block `TRUNCATE` for non-superusers. + +**`pg_command_fw.block_drop_table`** (bool, default `off`) +Block `DROP TABLE` for non-superusers. When `production_schemas` is set, only drops targeting those schemas are blocked; otherwise all `DROP TABLE` is blocked. + +**`pg_command_fw.production_schemas`** (string, default empty) +Comma-separated list of schemas for `DROP TABLE` checks. Only schema-qualified table names are matched; unqualified names are not resolved via `search_path`. + +**`pg_command_fw.block_alter_system`** (bool, default `on`) +Block `ALTER SYSTEM` for all roles including superusers. + +**`pg_command_fw.block_load`** (bool, default `on`) +Block `LOAD` (dynamic library loading) for all roles including superusers. + +**`pg_command_fw.block_copy_program`** (bool, default `on`) +Block `COPY … TO/FROM PROGRAM` for all roles including superusers. Prevents shell command execution via COPY. + +**`pg_command_fw.block_copy`** (bool, default `off`) +Block plain `COPY` (to/from file or stdout) for non-superusers. Superusers are exempt unless listed in `blocked_roles`. + +### Cross-category + +**`pg_command_fw.blocked_roles`** (string, default empty) +Comma-separated list of roles that are always blocked from any firewall-governed command, regardless of superuser status or per-category flags. + +**`pg_command_fw.hint`** (string, default empty) +Custom hint message appended to the error when a command is blocked (e.g. `'Contact your DBA to request access'`). + +**`pg_command_fw.audit_log_enabled`** (bool, default `on`) +Write every intercepted command to `command_fw.audit_log` via SPI. Blocked events are best-effort: the INSERT is rolled back when the transaction aborts, so the server log is authoritative for blocked events. + +## Audit log + +Every intercepted command (allowed or blocked) is recorded in `command_fw.audit_log`: + +| Column | Type | Description | +|---|---|---| +| `id` | bigint | Auto-increment primary key | +| `ts` | timestamptz | Event timestamp | +| `session_user_name` | text | Session-level user | +| `current_user_name` | text | Current (possibly SET ROLE) user | +| `query_text` | text | Original query string | +| `command_type` | text | e.g. `TRUNCATE`, `DROP_TABLE`, `ALTER_SYSTEM`, `LOAD`, `COPY_PROGRAM`, `COPY` | +| `target_schema` | text | Schema that triggered the block (DROP TABLE with `production_schemas`) | +| `target_object` | text | Object name (LOAD: library path) | +| `client_addr` | inet | Client IP address | +| `application_name` | text | `application_name` setting | +| `blocked` | bool | Whether the command was blocked | +| `block_reason` | text | Internal reason code | + +## Examples + +Block `TRUNCATE` and `DROP TABLE` in production schemas for all non-superusers: + +```sql +ALTER SYSTEM SET pg_command_fw.block_truncate = on; +ALTER SYSTEM SET pg_command_fw.block_drop_table = on; +ALTER SYSTEM SET pg_command_fw.production_schemas = 'public, payments'; +ALTER SYSTEM SET pg_command_fw.hint = 'File a ticket at https://internal/infra'; +SELECT pg_reload_conf(); +``` + +Prevent a specific role from running any governed command even if it is a superuser: + +```sql +ALTER SYSTEM SET pg_command_fw.blocked_roles = 'app_deploy'; +SELECT pg_reload_conf(); +``` + +Temporarily disable the firewall for a maintenance session: + +```sql +SET pg_command_fw.enabled = off; +TRUNCATE big_table; +SET pg_command_fw.enabled = on; +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e9a804c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +services: + postgres: + build: . + environment: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: testdb + # Load the extension at startup so the hook is active for all connections + command: postgres -c shared_preload_libraries=pg_command_fw + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 10 + + test: + image: postgres:17-trixie + depends_on: + postgres: + condition: service_healthy + environment: + PGHOST: postgres + PGUSER: postgres + PGPASSWORD: postgres + PGDATABASE: testdb + volumes: + - ./tests/docker/test.sh:/test.sh:ro + entrypoint: ["/bin/bash", "/test.sh"] diff --git a/pg_command_fw.control b/pg_command_fw.control index 461a379..7573de2 100644 --- a/pg_command_fw.control +++ b/pg_command_fw.control @@ -1,4 +1,4 @@ -comment = 'pg_command_fw: Created by pgrx' +comment = 'pg_command_fw: ProcessUtility hook firewall for DDL/utility commands (TRUNCATE, DROP TABLE, ALTER SYSTEM, LOAD, COPY PROGRAM)' default_version = '@CARGO_VERSION@' module_pathname = 'pg_command_fw' relocatable = false diff --git a/sql/hooks.sql b/sql/hooks.sql new file mode 100644 index 0000000..aa74cde --- /dev/null +++ b/sql/hooks.sql @@ -0,0 +1,53 @@ +-- pg_command_fw: ProcessUtility hook firewall for DDL/utility commands. +-- To activate for all connections, add to postgresql.conf: +-- shared_preload_libraries = 'pg_command_fw' + +CREATE SCHEMA IF NOT EXISTS command_fw; + +-- Audit log for all intercepted DDL/utility commands. +-- +-- NOTE: for *blocked* commands the INSERT is performed before ERROR is raised, +-- so it will be rolled back when the current transaction aborts. The server log +-- (LOG: blocked ...) is the authoritative record for blocked events. +-- Allowed commands commit normally and their rows persist here. +CREATE TABLE command_fw.audit_log ( + id bigserial NOT NULL, + -- clock_timestamp() captures the actual wall-clock time; now() would give + -- the transaction start time, which is the same row for every statement in + -- a multi-statement transaction. + ts timestamptz NOT NULL DEFAULT clock_timestamp(), + -- Both user fields are recorded because SET ROLE makes them diverge: + -- session_user_name is who actually authenticated, current_user_name is the + -- effective role at the time of the command. + session_user_name text NOT NULL, + current_user_name text NOT NULL, + query_text text NOT NULL, + -- Command category: 'TRUNCATE' | 'DROP_TABLE' | 'ALTER_SYSTEM' | 'LOAD' | 'COPY_PROGRAM' + command_type text NOT NULL, + -- For DROP_TABLE: the production schema that triggered the block (NULL otherwise). + target_schema text, + -- For LOAD: the library filename. NULL for other command types. + target_object text, + -- inet_client_addr() returns NULL for local (Unix-socket) connections. + client_addr inet, + application_name text, + blocked bool NOT NULL, + -- NULL when not blocked; one of: 'role_listed', 'truncate_non_superuser', + -- 'drop_production_table', 'alter_system', 'load', 'copy_program' when blocked. + block_reason text, + PRIMARY KEY (id) +); + +-- Index for time-range queries and dashboards. +CREATE INDEX ON command_fw.audit_log (ts); +-- Index for per-user audits. +CREATE INDEX ON command_fw.audit_log (current_user_name); +-- Index for per-command-type queries. +CREATE INDEX ON command_fw.audit_log (command_type); +-- Partial index: fast scan of blocked-only events (typically a small fraction). +CREATE INDEX ON command_fw.audit_log (ts) WHERE blocked; + +-- Lock down the schema and table; superusers can explicitly grant SELECT to +-- monitoring roles as needed. +REVOKE ALL ON SCHEMA command_fw FROM PUBLIC; +REVOKE ALL ON command_fw.audit_log FROM PUBLIC; diff --git a/src/lib.rs b/src/lib.rs index c27a769..363038c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,664 @@ +#![allow(clippy::too_many_arguments)] + +use pgrx::datum::DatumWithOid; +use pgrx::guc::{GucContext, GucFlags, GucRegistry, GucSetting}; +use pgrx::is_a; +use pgrx::pg_sys; +use pgrx::pg_sys::panic::ErrorReport; use pgrx::prelude::*; +use std::ffi::CString; + +pg_module_magic!(); + +// Master switch +static FW_ENABLED: GucSetting = GucSetting::::new(true); + +// Per-category switches +// TRUNCATE: blocked for non-superusers. +static BLOCK_TRUNCATE: GucSetting = GucSetting::::new(true); +// DROP TABLE: blocked for non-superusers in production schemas (opt-in). +static BLOCK_DROP_TABLE: GucSetting = GucSetting::::new(false); +// Comma-separated list of production schemas for DROP TABLE checks. +// When empty and block_drop_table=on, ALL DROP TABLE is blocked for non-superusers. +static PRODUCTION_SCHEMAS: GucSetting> = GucSetting::>::new(None); +// ALTER SYSTEM: blocked for all users including superusers. +static BLOCK_ALTER_SYSTEM: GucSetting = GucSetting::::new(true); +// LOAD: blocked for all users including superusers. +static BLOCK_LOAD: GucSetting = GucSetting::::new(true); +// COPY TO/FROM PROGRAM: blocked for all users including superusers. +static BLOCK_COPY_PROGRAM: GucSetting = GucSetting::::new(true); +// COPY (plain, non-PROGRAM): blocked for non-superusers (opt-in). +static BLOCK_COPY: GucSetting = GucSetting::::new(false); + +// Cross-category settings +// Comma-separated list of roles that are always blocked, including superusers. +static BLOCKED_ROLES: GucSetting> = GucSetting::>::new(None); +// Optional hint message shown to users when their command is blocked. +static HINT: GucSetting> = GucSetting::>::new(None); +// Write every intercepted event to pg_command_fw.audit_log via SPI. +// NOTE: blocked events are written before ERROR is raised and will be rolled back +// when the transaction aborts. The server LOG line is authoritative for blocked events. +static AUDIT_LOG_ENABLED: GucSetting = GucSetting::::new(true); + +static mut PREV_PROCESS_UTILITY_HOOK: pg_sys::ProcessUtility_hook_type = None; + +// Hook argument bundle +struct ProcessUtilityArgs { + pstmt: *mut pg_sys::PlannedStmt, + query_string: *const std::os::raw::c_char, + #[cfg(not(feature = "pg13"))] + read_only_tree: bool, + context: pg_sys::ProcessUtilityContext::Type, + params: pg_sys::ParamListInfo, + query_env: *mut pg_sys::QueryEnvironment, + dest: *mut pg_sys::DestReceiver, + qc: *mut pg_sys::QueryCompletion, +} + +// Firewall decision +struct FirewallDecision { + /// Command category label written to the audit log. + command_type: &'static str, + /// Schema name; populated for DROP_TABLE when a production schema matches. + target_schema: Option, + /// Target object; populated for LOAD (library filename). + target_object: Option, + /// None → command matched but is allowed; Some → reason why it was blocked. + block_reason: Option<&'static str>, +} + +// Firewall logic +/// Returns None if the node is not a category we police. +/// Returns Some(decision) for every matched category (blocked or not). +unsafe fn check_firewall(node: *mut pg_sys::Node, current_user: &str) -> Option { + if !FW_ENABLED.get() { + return None; + } + + let in_blocked_list = is_role_blocked(current_user); + let is_super = pg_sys::superuser(); + + // COPY TO/FROM PROGRAM or plain COPY + if is_a(node, pg_sys::NodeTag::T_CopyStmt) { + let copy_stmt = node as *mut pg_sys::CopyStmt; + if (*copy_stmt).is_program { + let block_reason = if in_blocked_list { + Some("role_listed") + } else if BLOCK_COPY_PROGRAM.get() { + Some("copy_program") + } else { + None + }; + return Some(FirewallDecision { + command_type: "COPY_PROGRAM", + target_schema: None, + target_object: None, + block_reason, + }); + } + // Plain COPY (to/from file or stdout) + if !BLOCK_COPY.get() && !in_blocked_list { + return None; + } + let block_reason = if in_blocked_list { + Some("role_listed") + } else if BLOCK_COPY.get() && !is_super { + Some("copy") + } else { + None + }; + return Some(FirewallDecision { + command_type: "COPY", + target_schema: None, + target_object: None, + block_reason, + }); + } + + // ALTER SYSTEM + if is_a(node, pg_sys::NodeTag::T_AlterSystemStmt) { + let block_reason = if in_blocked_list { + Some("role_listed") + } else if BLOCK_ALTER_SYSTEM.get() { + Some("alter_system") + } else { + None + }; + return Some(FirewallDecision { + command_type: "ALTER_SYSTEM", + target_schema: None, + target_object: None, + block_reason, + }); + } + + // LOAD + if is_a(node, pg_sys::NodeTag::T_LoadStmt) { + let load_stmt = node as *mut pg_sys::LoadStmt; + let filename = if !(*load_stmt).filename.is_null() { + Some( + std::ffi::CStr::from_ptr((*load_stmt).filename) + .to_string_lossy() + .into_owned(), + ) + } else { + None + }; + let block_reason = if in_blocked_list { + Some("role_listed") + } else if BLOCK_LOAD.get() { + Some("load") + } else { + None + }; + return Some(FirewallDecision { + command_type: "LOAD", + target_schema: None, + target_object: filename, + block_reason, + }); + } + + // TRUNCATE (non-superusers) + if is_a(node, pg_sys::NodeTag::T_TruncateStmt) { + let block_reason = if in_blocked_list { + Some("role_listed") + } else if BLOCK_TRUNCATE.get() && !is_super { + Some("truncate_non_superuser") + } else { + None + }; + return Some(FirewallDecision { + command_type: "TRUNCATE", + target_schema: None, + target_object: None, + block_reason, + }); + } + + // DROP TABLE (non-superusers, optionally scoped to production schemas) + if is_a(node, pg_sys::NodeTag::T_DropStmt) { + let drop_stmt = node as *mut pg_sys::DropStmt; + if (*drop_stmt).removeType != pg_sys::ObjectType::OBJECT_TABLE { + return None; + } + + let (blocked_schema, block_reason) = + compute_drop_table_decision(drop_stmt, in_blocked_list, is_super); + + return Some(FirewallDecision { + command_type: "DROP_TABLE", + target_schema: blocked_schema, + target_object: None, + block_reason, + }); + } + + None +} + +/// Compute the DROP TABLE block decision and the schema that triggered it. +unsafe fn compute_drop_table_decision( + drop_stmt: *mut pg_sys::DropStmt, + in_blocked_list: bool, + is_super: bool, +) -> (Option, Option<&'static str>) { + if in_blocked_list { + return (None, Some("role_listed")); + } + if !BLOCK_DROP_TABLE.get() || is_super { + return (None, None); + } + + let prod_schemas_raw = PRODUCTION_SCHEMAS + .get() + .and_then(|cstr| cstr.to_str().ok().map(|s| s.to_owned())); + + match prod_schemas_raw { + // No production_schemas configured → block all DROP TABLE. + None => (None, Some("drop_production_table")), + Some(ref s) if s.is_empty() => (None, Some("drop_production_table")), + Some(schemas_str) => { + // Block only when an explicit schema-qualified name matches. + let prod_list: Vec<&str> = schemas_str.split(',').map(str::trim).collect(); + let explicit_schemas = extract_schemas_from_drop_stmt(drop_stmt); + for schema in explicit_schemas { + if prod_list.contains(&schema.as_str()) { + return (Some(schema), Some("drop_production_table")); + } + } + (None, None) + } + } +} + +/// Collect the explicitly schema-qualified names from a DROP TABLE statement. +/// Unqualified names (single-element inner list) are skipped; name resolution +/// via search_path is deferred to a future version. +unsafe fn extract_schemas_from_drop_stmt(drop_stmt: *mut pg_sys::DropStmt) -> Vec { + let mut schemas = Vec::new(); + let objects = (*drop_stmt).objects; + if objects.is_null() { + return schemas; + } + let n = (*objects).length as isize; + let cells = (*objects).elements; + for i in 0..n { + // Each cell is a pointer to an inner List of name parts. + let inner_list = (*cells.offset(i)).ptr_value as *mut pg_sys::List; + if inner_list.is_null() || (*inner_list).length < 2 { + continue; + } + // First element of the inner list is the schema name. + let schema_cell_ptr = (*(*inner_list).elements.offset(0)).ptr_value; + if schema_cell_ptr.is_null() { + continue; + } + if let Some(name) = extract_string_node(schema_cell_ptr) { + schemas.push(name); + } + } + schemas +} + +/// Extract the string value from a T_String (PG14+) or T_String/Value (PG13) node pointer. +#[cfg(not(feature = "pg13"))] +unsafe fn extract_string_node(ptr: *mut std::os::raw::c_void) -> Option { + let s = ptr as *mut pg_sys::String; + if (*s).sval.is_null() { + return None; + } + std::ffi::CStr::from_ptr((*s).sval) + .to_str() + .ok() + .map(|v| v.to_owned()) +} + +#[cfg(feature = "pg13")] +unsafe fn extract_string_node(ptr: *mut std::os::raw::c_void) -> Option { + let v = ptr as *mut pg_sys::Value; + let str_ptr = (*v).val.str_; + if str_ptr.is_null() { + return None; + } + std::ffi::CStr::from_ptr(str_ptr) + .to_str() + .ok() + .map(|v| v.to_owned()) +} + +// Role helper +fn is_role_blocked(current_user: &str) -> bool { + BLOCKED_ROLES + .get() + .and_then(|cstr| cstr.to_str().ok().map(|s| s.to_owned())) + .map(|list| list.split(',').map(str::trim).any(|r| r == current_user)) + .unwrap_or(false) +} + +// Core hook function +unsafe fn command_firewall_process_utility(args: ProcessUtilityArgs) { + let node = (*args.pstmt).utilityStmt; + + if !node.is_null() { + let current_user = get_current_username().unwrap_or_else(|| "unknown".to_string()); + let session_user = get_session_username().unwrap_or_else(|| "unknown".to_string()); + let query_text = std::ffi::CStr::from_ptr(args.query_string) + .to_str() + .unwrap_or(""); + + if let Some(decision) = check_firewall(node, ¤t_user) { + let blocked = decision.block_reason.is_some(); + + write_audit_log( + &session_user, + ¤t_user, + query_text, + decision.command_type, + decision.target_schema.as_deref(), + decision.target_object.as_deref(), + blocked, + decision.block_reason, + ); + + if blocked { + pgrx::log!( + "blocked {} user={:?} reason={:?}", + decision.command_type, + current_user, + decision.block_reason.unwrap_or(""), + ); + let msg = format!( + "{} command is not allowed", + decision.command_type.replace('_', " ") + ); + let hint = HINT + .get() + .and_then(|cstr| cstr.to_str().ok().map(str::to_owned)); + let mut report = + ErrorReport::new(PgSqlErrorCode::ERRCODE_INSUFFICIENT_PRIVILEGE, msg, ""); + if let Some(h) = hint { + report = report.set_hint(h); + } + report.report(PgLogLevel::ERROR); + } + } + } + + #[cfg(feature = "pg13")] + match PREV_PROCESS_UTILITY_HOOK { + Some(prev) => prev( + args.pstmt, + args.query_string, + args.context, + args.params, + args.query_env, + args.dest, + args.qc, + ), + None => pg_sys::standard_ProcessUtility( + args.pstmt, + args.query_string, + args.context, + args.params, + args.query_env, + args.dest, + args.qc, + ), + } + + #[cfg(not(feature = "pg13"))] + match PREV_PROCESS_UTILITY_HOOK { + Some(prev) => prev( + args.pstmt, + args.query_string, + args.read_only_tree, + args.context, + args.params, + args.query_env, + args.dest, + args.qc, + ), + None => pg_sys::standard_ProcessUtility( + args.pstmt, + args.query_string, + args.read_only_tree, + args.context, + args.params, + args.query_env, + args.dest, + args.qc, + ), + } +} + +// Audit log +/// Write one row to pg_command_fw.audit_log. All errors are silently +/// swallowed so a missing table (library loaded before CREATE EXTENSION) or +/// any SPI problem never interrupts the firewall logic. +fn write_audit_log( + session_user: &str, + current_user: &str, + query_text: &str, + command_type: &str, + target_schema: Option<&str>, + target_object: Option<&str>, + blocked: bool, + block_reason: Option<&str>, +) { + if !AUDIT_LOG_ENABLED.get() { + return; + } + + PgTryBuilder::new(move || { + Spi::connect_mut(|client| { + let args = [ + DatumWithOid::from(session_user), + DatumWithOid::from(current_user), + DatumWithOid::from(query_text), + DatumWithOid::from(command_type), + DatumWithOid::from(target_schema), + DatumWithOid::from(target_object), + DatumWithOid::from(blocked), + DatumWithOid::from(block_reason), + ]; + let _ = client.update( + "INSERT INTO command_fw.audit_log \ + (session_user_name, current_user_name, query_text, command_type, \ + target_schema, target_object, client_addr, application_name, \ + blocked, block_reason) \ + VALUES ($1, $2, $3, $4, $5, $6, \ + inet_client_addr(), \ + current_setting('application_name', true), \ + $7, $8)", + None, + &args, + ); + }); + }) + .catch_others(|_| ()) + .execute(); +} + +// Hook trampolines +#[pg_guard] +#[cfg(feature = "pg13")] +unsafe extern "C-unwind" fn hook_trampoline( + pstmt: *mut pg_sys::PlannedStmt, + query_string: *const std::os::raw::c_char, + context: pg_sys::ProcessUtilityContext::Type, + params: pg_sys::ParamListInfo, + query_env: *mut pg_sys::QueryEnvironment, + dest: *mut pg_sys::DestReceiver, + qc: *mut pg_sys::QueryCompletion, +) { + unsafe { + command_firewall_process_utility(ProcessUtilityArgs { + pstmt, + query_string, + context, + params, + query_env, + dest, + qc, + }); + } +} + +#[pg_guard] +#[cfg(not(feature = "pg13"))] +unsafe extern "C-unwind" fn hook_trampoline( + pstmt: *mut pg_sys::PlannedStmt, + query_string: *const std::os::raw::c_char, + read_only_tree: bool, + context: pg_sys::ProcessUtilityContext::Type, + params: pg_sys::ParamListInfo, + query_env: *mut pg_sys::QueryEnvironment, + dest: *mut pg_sys::DestReceiver, + qc: *mut pg_sys::QueryCompletion, +) { + unsafe { + command_firewall_process_utility(ProcessUtilityArgs { + pstmt, + query_string, + read_only_tree, + context, + params, + query_env, + dest, + qc, + }); + } +} + +// Extension init +#[pg_guard] +pub extern "C-unwind" fn _PG_init() { + GucRegistry::define_bool_guc( + c"pg_command_fw.enabled", + c"Master switch for the command firewall", + c"When on (default), the firewall intercepts and potentially blocks DDL/utility \ + commands according to the per-category settings. Set to off to disable all \ + checks without unloading the extension.", + &FW_ENABLED, + GucContext::Suset, + GucFlags::default(), + ); + + GucRegistry::define_bool_guc( + c"pg_command_fw.block_truncate", + c"Block TRUNCATE for non-superusers", + c"When on (default), TRUNCATE is blocked for all non-superusers. Superusers \ + are exempt unless listed in pg_command_fw.blocked_roles.", + &BLOCK_TRUNCATE, + GucContext::Suset, + GucFlags::default(), + ); + + GucRegistry::define_bool_guc( + c"pg_command_fw.block_drop_table", + c"Block DROP TABLE for non-superusers (opt-in)", + c"When on, DROP TABLE is blocked for non-superusers. If \ + pg_command_fw.production_schemas is set, only drops targeting those schemas \ + are blocked; otherwise all DROP TABLE is blocked.", + &BLOCK_DROP_TABLE, + GucContext::Suset, + GucFlags::default(), + ); + + GucRegistry::define_string_guc( + c"pg_command_fw.production_schemas", + c"Comma-separated list of production schemas for DROP TABLE checks", + c"When set and block_drop_table=on, only DROP TABLE commands that reference an \ + explicitly schema-qualified table in one of these schemas are blocked. \ + Unqualified names are not resolved and will not be matched. \ + When empty (default) and block_drop_table=on, all DROP TABLE is blocked.", + &PRODUCTION_SCHEMAS, + GucContext::Suset, + GucFlags::default(), + ); + + GucRegistry::define_bool_guc( + c"pg_command_fw.block_alter_system", + c"Block ALTER SYSTEM for all users including superusers", + c"When on (default), ALTER SYSTEM is blocked for every role including \ + superusers. This prevents runtime changes to postgresql.conf via SQL.", + &BLOCK_ALTER_SYSTEM, + GucContext::Suset, + GucFlags::default(), + ); + + GucRegistry::define_bool_guc( + c"pg_command_fw.block_load", + c"Block LOAD for all users including superusers", + c"When on (default), LOAD (dynamic library loading) is blocked for every role \ + including superusers.", + &BLOCK_LOAD, + GucContext::Suset, + GucFlags::default(), + ); + + GucRegistry::define_bool_guc( + c"pg_command_fw.block_copy_program", + c"Block COPY TO/FROM PROGRAM for all users including superusers", + c"When on (default), COPY TO/FROM PROGRAM is blocked for every role including \ + superusers. This prevents shell command execution via COPY.", + &BLOCK_COPY_PROGRAM, + GucContext::Suset, + GucFlags::default(), + ); + + GucRegistry::define_bool_guc( + c"pg_command_fw.block_copy", + c"Block plain COPY (non-PROGRAM) for non-superusers (opt-in)", + c"When on, COPY TO/FROM file or stdout is blocked for non-superusers. \ + Superusers are exempt unless listed in pg_command_fw.blocked_roles.", + &BLOCK_COPY, + GucContext::Suset, + GucFlags::default(), + ); + + GucRegistry::define_string_guc( + c"pg_command_fw.blocked_roles", + c"Comma-separated list of roles always blocked from firewall-governed commands", + c"Roles in this list are blocked from any firewall-governed command regardless \ + of superuser status or per-category settings.", + &BLOCKED_ROLES, + GucContext::Suset, + GucFlags::default(), + ); + + GucRegistry::define_string_guc( + c"pg_command_fw.hint", + c"Custom hint shown when a command is blocked", + c"When set, this message is appended as a HINT to the error raised when a \ + command is blocked (e.g. 'Contact your DBA to request access').", + &HINT, + GucContext::Suset, + GucFlags::default(), + ); + + GucRegistry::define_bool_guc( + c"pg_command_fw.audit_log_enabled", + c"Write intercepted events to pg_command_fw.audit_log", + c"When on (default), every intercepted command is recorded in \ + pg_command_fw.audit_log via SPI. Blocked events are best-effort: the \ + INSERT is rolled back when ERROR aborts the transaction, so the server log \ + is authoritative for blocked events. Set to off to disable table writes.", + &AUDIT_LOG_ENABLED, + GucContext::Suset, + GucFlags::default(), + ); + + unsafe { + PREV_PROCESS_UTILITY_HOOK = pg_sys::ProcessUtility_hook; + pg_sys::ProcessUtility_hook = Some(hook_trampoline); + } +} + +// User helper functions +fn get_current_username() -> Option { + unsafe { + let user_oid = pg_sys::GetUserId(); + let name_ptr = pg_sys::GetUserNameFromId(user_oid, true); + if name_ptr.is_null() { + None + } else { + Some( + std::ffi::CStr::from_ptr(name_ptr) + .to_string_lossy() + .into_owned(), + ) + } + } +} -::pgrx::pg_module_magic!(name, version); +fn get_session_username() -> Option { + unsafe { + let user_oid = pg_sys::GetSessionUserId(); + let name_ptr = pg_sys::GetUserNameFromId(user_oid, true); + if name_ptr.is_null() { + None + } else { + Some( + std::ffi::CStr::from_ptr(name_ptr) + .to_string_lossy() + .into_owned(), + ) + } + } +} + +extension_sql_file!(".././sql/hooks.sql"); + +// Test harness +#[cfg(test)] +pub mod pg_test { + pub fn setup(_options: Vec<&str>) {} -#[pg_extern] -fn hello_pg_command_fw() -> &'static str { - "Hello, pg_command_fw" + pub fn postgresql_conf_options() -> Vec<&'static str> { + vec!["shared_preload_libraries = 'pg_command_fw'"] + } } #[cfg(any(test, feature = "pg_test"))] @@ -12,24 +666,232 @@ fn hello_pg_command_fw() -> &'static str { mod tests { use pgrx::prelude::*; + fn show(guc: &str) -> String { + Spi::get_one::(&format!("SHOW {guc}")) + .unwrap() + .unwrap_or_default() + } + + // Pass-through: DML/non-firewalled DDL #[pg_test] - fn test_hello_pg_command_fw() { - assert_eq!("Hello, pg_command_fw", crate::hello_pg_command_fw()); + fn test_select_allowed() { + let val = Spi::get_one::("SELECT 42").unwrap(); + assert_eq!(val, Some(42)); } -} + #[pg_test] + fn test_insert_update_delete_allowed() { + Spi::run("CREATE TEMP TABLE _fw_dml (id int, v text)").unwrap(); + Spi::run("INSERT INTO _fw_dml VALUES (1, 'a'), (2, 'b')").unwrap(); + Spi::run("UPDATE _fw_dml SET v = 'x' WHERE id = 1").unwrap(); + Spi::run("DELETE FROM _fw_dml WHERE id = 2").unwrap(); + let count = Spi::get_one::("SELECT count(*) FROM _fw_dml") + .unwrap() + .unwrap(); + assert_eq!(count, 1); + Spi::run("DROP TABLE _fw_dml").unwrap(); + } -/// This module is required by `cargo pgrx test` invocations. -/// It must be visible at the root of your extension crate. -#[cfg(test)] -pub mod pg_test { - pub fn setup(_options: Vec<&str>) { - // perform one-off initialization when the pg_test framework starts + #[pg_test] + fn test_create_index_allowed() { + Spi::run("CREATE TEMP TABLE _fw_idx (id int)").unwrap(); + Spi::run("CREATE INDEX ON _fw_idx (id)").unwrap(); + Spi::run("DROP TABLE _fw_idx").unwrap(); } - #[must_use] - pub fn postgresql_conf_options() -> Vec<&'static str> { - // return any postgresql.conf settings that are required for your tests - vec![] + // GUC defaults + #[pg_test] + fn test_guc_enabled_default_on() { + assert_eq!(show("pg_command_fw.enabled"), "on"); + } + + #[pg_test] + fn test_guc_block_truncate_default_on() { + assert_eq!(show("pg_command_fw.block_truncate"), "on"); + } + + #[pg_test] + fn test_guc_block_drop_table_default_off() { + assert_eq!(show("pg_command_fw.block_drop_table"), "off"); + } + + #[pg_test] + fn test_guc_production_schemas_default_empty() { + assert_eq!(show("pg_command_fw.production_schemas"), ""); + } + + #[pg_test] + fn test_guc_block_alter_system_default_on() { + assert_eq!(show("pg_command_fw.block_alter_system"), "on"); + } + + #[pg_test] + fn test_guc_block_load_default_on() { + assert_eq!(show("pg_command_fw.block_load"), "on"); + } + + #[pg_test] + fn test_guc_block_copy_program_default_on() { + assert_eq!(show("pg_command_fw.block_copy_program"), "on"); + } + + #[pg_test] + fn test_guc_blocked_roles_default_empty() { + assert_eq!(show("pg_command_fw.blocked_roles"), ""); + } + + #[pg_test] + fn test_guc_audit_log_enabled_default_on() { + assert_eq!(show("pg_command_fw.audit_log_enabled"), "on"); + } + + // GUC round-trips + #[pg_test] + fn test_guc_enabled_roundtrip() { + Spi::run("SET pg_command_fw.enabled = off").unwrap(); + assert_eq!(show("pg_command_fw.enabled"), "off"); + Spi::run("SET pg_command_fw.enabled = on").unwrap(); + assert_eq!(show("pg_command_fw.enabled"), "on"); + } + + #[pg_test] + fn test_guc_block_truncate_roundtrip() { + Spi::run("SET pg_command_fw.block_truncate = off").unwrap(); + assert_eq!(show("pg_command_fw.block_truncate"), "off"); + Spi::run("SET pg_command_fw.block_truncate = on").unwrap(); + assert_eq!(show("pg_command_fw.block_truncate"), "on"); + } + + #[pg_test] + fn test_guc_block_drop_table_roundtrip() { + Spi::run("SET pg_command_fw.block_drop_table = on").unwrap(); + assert_eq!(show("pg_command_fw.block_drop_table"), "on"); + Spi::run("SET pg_command_fw.block_drop_table = off").unwrap(); + assert_eq!(show("pg_command_fw.block_drop_table"), "off"); + } + + #[pg_test] + fn test_guc_production_schemas_roundtrip() { + Spi::run("SET pg_command_fw.production_schemas = 'prod, main'").unwrap(); + assert_eq!(show("pg_command_fw.production_schemas"), "prod, main"); + Spi::run("RESET pg_command_fw.production_schemas").unwrap(); + assert_eq!(show("pg_command_fw.production_schemas"), ""); + } + + #[pg_test] + fn test_guc_block_alter_system_roundtrip() { + Spi::run("SET pg_command_fw.block_alter_system = off").unwrap(); + assert_eq!(show("pg_command_fw.block_alter_system"), "off"); + Spi::run("SET pg_command_fw.block_alter_system = on").unwrap(); + assert_eq!(show("pg_command_fw.block_alter_system"), "on"); + } + + #[pg_test] + fn test_guc_block_load_roundtrip() { + Spi::run("SET pg_command_fw.block_load = off").unwrap(); + assert_eq!(show("pg_command_fw.block_load"), "off"); + Spi::run("SET pg_command_fw.block_load = on").unwrap(); + assert_eq!(show("pg_command_fw.block_load"), "on"); + } + + #[pg_test] + fn test_guc_block_copy_program_roundtrip() { + Spi::run("SET pg_command_fw.block_copy_program = off").unwrap(); + assert_eq!(show("pg_command_fw.block_copy_program"), "off"); + Spi::run("SET pg_command_fw.block_copy_program = on").unwrap(); + assert_eq!(show("pg_command_fw.block_copy_program"), "on"); + } + + #[pg_test] + fn test_guc_block_copy_default_off() { + assert_eq!(show("pg_command_fw.block_copy"), "off"); + } + + #[pg_test] + fn test_guc_block_copy_roundtrip() { + Spi::run("SET pg_command_fw.block_copy = on").unwrap(); + assert_eq!(show("pg_command_fw.block_copy"), "on"); + Spi::run("SET pg_command_fw.block_copy = off").unwrap(); + assert_eq!(show("pg_command_fw.block_copy"), "off"); + } + + #[pg_test] + fn test_guc_blocked_roles_roundtrip() { + Spi::run("SET pg_command_fw.blocked_roles = 'alice, bob'").unwrap(); + assert_eq!(show("pg_command_fw.blocked_roles"), "alice, bob"); + Spi::run("RESET pg_command_fw.blocked_roles").unwrap(); + assert_eq!(show("pg_command_fw.blocked_roles"), ""); + } + + #[pg_test] + fn test_guc_audit_log_enabled_roundtrip() { + Spi::run("SET pg_command_fw.audit_log_enabled = off").unwrap(); + assert_eq!(show("pg_command_fw.audit_log_enabled"), "off"); + Spi::run("SET pg_command_fw.audit_log_enabled = on").unwrap(); + assert_eq!(show("pg_command_fw.audit_log_enabled"), "on"); + } + + // Audit log table structure + #[pg_test] + fn test_audit_log_table_exists() { + let count = Spi::get_one::( + "SELECT count(*) FROM information_schema.tables \ + WHERE table_schema = 'pg_command_fw' AND table_name = 'audit_log'", + ) + .unwrap() + .unwrap(); + assert_eq!(count, 1); + } + + #[pg_test] + fn test_audit_log_expected_columns_exist() { + let expected = [ + "id", + "ts", + "session_user_name", + "current_user_name", + "query_text", + "command_type", + "target_schema", + "target_object", + "client_addr", + "application_name", + "blocked", + "block_reason", + ]; + for col in expected { + let found = Spi::get_one::(&format!( + "SELECT count(*) FROM information_schema.columns \ + WHERE table_schema = 'pg_command_fw' \ + AND table_name = 'audit_log' \ + AND column_name = '{col}'" + )) + .unwrap() + .unwrap(); + assert_eq!(found, 1, "column '{col}' missing from audit_log"); + } + } + + #[pg_test] + fn test_audit_log_is_writable() { + Spi::run( + "INSERT INTO command_fw.audit_log \ + (session_user_name, current_user_name, query_text, \ + command_type, blocked) \ + VALUES ('u', 'u', 'TRUNCATE t', 'TRUNCATE', false)", + ) + .unwrap(); + let count = Spi::get_one::( + "SELECT count(*) FROM command_fw.audit_log \ + WHERE query_text = 'TRUNCATE t'", + ) + .unwrap() + .unwrap(); + assert_eq!(count, 1); + Spi::run( + "DELETE FROM command_fw.audit_log \ + WHERE query_text = 'TRUNCATE t'", + ) + .unwrap(); } } diff --git a/tests/docker/test.sh b/tests/docker/test.sh new file mode 100644 index 0000000..5bf32ba --- /dev/null +++ b/tests/docker/test.sh @@ -0,0 +1,394 @@ +#!/usr/bin/env bash +set -euo pipefail + +PASS=0 +FAIL=0 + +pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } + +# psql as the superuser (postgres) +SU="psql" +# psql as a non-superuser +RU="psql -U testuser" + +echo "=== Setting up ===" +psql -c "CREATE EXTENSION IF NOT EXISTS pg_command_fw;" +psql -c "DROP OWNED BY testuser;" 2>/dev/null || true +psql -c "DROP ROLE IF EXISTS testuser;" +psql -c "CREATE ROLE testuser LOGIN PASSWORD 'testpass';" +psql -c "GRANT CONNECT ON DATABASE testdb TO testuser;" +export PGPASSWORD=postgres + +# Helper: run a query and return a single trimmed value. +q() { psql -t -A -c "$1"; } + +echo "" +echo "=== TRUNCATE ===" + +echo "" +echo "--- Test 1: non-superuser TRUNCATE is blocked ---" +psql -c "CREATE TABLE IF NOT EXISTS trunc_test (id int);" +psql -c "GRANT TRUNCATE ON trunc_test TO testuser;" +out=$(PGPASSWORD=testpass $RU -c "TRUNCATE trunc_test;" 2>&1 || true) +if echo "$out" | grep -q "TRUNCATE command is not allowed"; then + pass "non-superuser TRUNCATE blocked" +else + fail "non-superuser TRUNCATE not blocked; got: $out" +fi + +echo "" +echo "--- Test 2: superuser TRUNCATE is allowed ---" +out=$($SU -c "TRUNCATE trunc_test;" 2>&1) +if ! echo "$out" | grep -q "not allowed"; then + pass "superuser TRUNCATE allowed" +else + fail "superuser TRUNCATE blocked; got: $out" +fi +psql -c "DROP TABLE trunc_test;" + +echo "" +echo "--- Test 3: block_truncate=off -> non-superuser TRUNCATE is allowed ---" +psql -c "CREATE TABLE IF NOT EXISTS trunc_test2 (id int);" +psql -c "GRANT TRUNCATE ON trunc_test2 TO testuser;" +psql -c "ALTER ROLE testuser SET pg_command_fw.block_truncate = off;" +out=$(PGPASSWORD=testpass $RU -c "TRUNCATE trunc_test2;" 2>&1) +if ! echo "$out" | grep -q "not allowed"; then + pass "non-superuser TRUNCATE allowed when block_truncate=off" +else + fail "non-superuser TRUNCATE blocked when block_truncate=off; got: $out" +fi +psql -c "ALTER ROLE testuser RESET pg_command_fw.block_truncate;" +psql -c "DROP TABLE trunc_test2;" + +echo "" +echo "=== ALTER SYSTEM ===" + +echo "" +echo "--- Test 4: superuser ALTER SYSTEM is blocked ---" +out=$($SU -c "ALTER SYSTEM SET work_mem = '8MB';" 2>&1 || true) +if echo "$out" | grep -q "ALTER SYSTEM command is not allowed"; then + pass "superuser ALTER SYSTEM blocked" +else + fail "superuser ALTER SYSTEM not blocked; got: $out" +fi + +echo "" +echo "--- Test 5: non-superuser ALTER SYSTEM is blocked ---" +out=$(PGPASSWORD=testpass $RU -c "ALTER SYSTEM SET work_mem = '8MB';" 2>&1 || true) +if echo "$out" | grep -q "ALTER SYSTEM command is not allowed\|must be superuser"; then + pass "non-superuser ALTER SYSTEM blocked" +else + fail "non-superuser ALTER SYSTEM not blocked; got: $out" +fi + +echo "" +echo "--- Test 6: block_alter_system=off -> superuser ALTER SYSTEM is allowed ---" +# SET is session-scoped; ALTER SYSTEM cannot run inside a transaction block. +# Use a database-level default so the new connection picks it up without SET. +psql -c "ALTER DATABASE testdb SET pg_command_fw.block_alter_system = off;" +out=$($SU -c "ALTER SYSTEM SET work_mem = '8MB';" 2>&1) +psql -c "ALTER DATABASE testdb RESET pg_command_fw.block_alter_system;" +if ! echo "$out" | grep -q "not allowed"; then + pass "superuser ALTER SYSTEM allowed when block_alter_system=off" +else + fail "superuser ALTER SYSTEM blocked when block_alter_system=off; got: $out" +fi +# Clean up +$SU -c "ALTER SYSTEM RESET work_mem;" 2>/dev/null || true + +echo "" +echo "=== COPY PROGRAM ===" + +echo "" +echo "--- Test 7: superuser COPY TO PROGRAM is blocked ---" +out=$($SU -c "COPY (SELECT 1) TO PROGRAM 'cat';" 2>&1 || true) +if echo "$out" | grep -q "COPY PROGRAM command is not allowed"; then + pass "superuser COPY TO PROGRAM blocked" +else + fail "superuser COPY TO PROGRAM not blocked; got: $out" +fi + +echo "" +echo "--- Test 8: non-superuser COPY TO PROGRAM is blocked ---" +out=$(PGPASSWORD=testpass $RU -c "COPY (SELECT 1) TO PROGRAM 'cat';" 2>&1 || true) +if echo "$out" | grep -q "COPY PROGRAM command is not allowed"; then + pass "non-superuser COPY TO PROGRAM blocked" +else + fail "non-superuser COPY TO PROGRAM not blocked; got: $out" +fi + +echo "" +echo "--- Test 9: block_copy_program=off -> superuser COPY TO PROGRAM allowed ---" +out=$($SU -c "SET pg_command_fw.block_copy_program = off; COPY (SELECT 1) TO PROGRAM 'cat > /dev/null';" 2>&1) +if ! echo "$out" | grep -q "not allowed"; then + pass "superuser COPY TO PROGRAM allowed when block_copy_program=off" +else + fail "superuser COPY TO PROGRAM blocked when block_copy_program=off; got: $out" +fi + +echo "" +echo "--- Test 10: plain COPY (not PROGRAM) is unaffected by default ---" +out=$($SU -c "COPY (SELECT 1) TO STDOUT;" 2>&1) +if echo "$out" | grep -q "^1$"; then + pass "plain COPY TO STDOUT unaffected" +else + fail "plain COPY TO STDOUT affected; got: $out" +fi + +echo "" +echo "=== COPY (plain) ===" + +echo "" +echo "--- Test 11: block_copy=off (default) -> non-superuser plain COPY allowed ---" +out=$(PGPASSWORD=testpass $RU -c "COPY (SELECT 1) TO STDOUT;" 2>&1) +if ! echo "$out" | grep -q "not allowed"; then + pass "non-superuser plain COPY allowed when block_copy=off" +else + fail "non-superuser plain COPY blocked when block_copy=off; got: $out" +fi + +echo "" +echo "--- Test 12: block_copy=on -> non-superuser plain COPY blocked ---" +psql -c "ALTER ROLE testuser SET pg_command_fw.block_copy = on;" +out=$(PGPASSWORD=testpass $RU -c "COPY (SELECT 1) TO STDOUT;" 2>&1 || true) +if echo "$out" | grep -q "COPY command is not allowed"; then + pass "non-superuser plain COPY blocked when block_copy=on" +else + fail "non-superuser plain COPY not blocked when block_copy=on; got: $out" +fi +psql -c "ALTER ROLE testuser RESET pg_command_fw.block_copy;" + +echo "" +echo "--- Test 13: block_copy=on -> superuser plain COPY still allowed ---" +out=$($SU -c "SET pg_command_fw.block_copy = on; COPY (SELECT 1) TO STDOUT;" 2>&1) +if ! echo "$out" | grep -q "not allowed"; then + pass "superuser plain COPY allowed when block_copy=on" +else + fail "superuser plain COPY blocked when block_copy=on; got: $out" +fi + +echo "" +echo "--- Test 14: block_copy=on + enabled=off -> non-superuser plain COPY allowed ---" +psql -c "ALTER ROLE testuser SET pg_command_fw.block_copy = on;" +psql -c "ALTER ROLE testuser SET pg_command_fw.enabled = off;" +out=$(PGPASSWORD=testpass $RU -c "COPY (SELECT 1) TO STDOUT;" 2>&1) +if ! echo "$out" | grep -q "not allowed"; then + pass "non-superuser plain COPY allowed when firewall disabled" +else + fail "non-superuser plain COPY blocked when firewall disabled; got: $out" +fi +psql -c "ALTER ROLE testuser RESET pg_command_fw.block_copy;" +psql -c "ALTER ROLE testuser RESET pg_command_fw.enabled;" + +echo "" +echo "=== LOAD ===" + +echo "" +echo "--- Test 15: superuser LOAD is blocked ---" +out=$($SU -c "LOAD 'no_such_lib';" 2>&1 || true) +if echo "$out" | grep -q "LOAD command is not allowed"; then + pass "superuser LOAD blocked" +else + fail "superuser LOAD not blocked; got: $out" +fi + +echo "" +echo "--- Test 16: block_load=off -> LOAD proceeds (may fail for missing lib) ---" +out=$($SU -c "SET pg_command_fw.block_load = off; LOAD 'no_such_lib';" 2>&1 || true) +if ! echo "$out" | grep -q "LOAD command is not allowed"; then + pass "LOAD not blocked by firewall when block_load=off" +else + fail "LOAD still blocked by firewall when block_load=off; got: $out" +fi + +echo "" +echo "=== DROP TABLE ===" + +echo "" +echo "--- Test 17: block_drop_table=off (default) -> non-superuser DROP TABLE allowed ---" +psql -c "CREATE TABLE IF NOT EXISTS drop_test (id int);" +psql -c "GRANT DROP ON TABLE drop_test TO testuser;" 2>/dev/null || \ +psql -c "ALTER TABLE drop_test OWNER TO testuser;" +out=$(PGPASSWORD=testpass $RU -c "DROP TABLE IF EXISTS drop_test;" 2>&1) +if ! echo "$out" | grep -q "not allowed"; then + pass "DROP TABLE allowed when block_drop_table=off" +else + fail "DROP TABLE blocked when block_drop_table=off; got: $out" +fi + +echo "" +echo "--- Test 18: block_drop_table=on -> non-superuser DROP TABLE blocked ---" +psql -c "CREATE TABLE IF NOT EXISTS drop_test (id int);" +psql -c "ALTER TABLE drop_test OWNER TO testuser;" +psql -c "ALTER ROLE testuser SET pg_command_fw.block_drop_table = on;" +out=$(PGPASSWORD=testpass $RU -c "DROP TABLE drop_test;" 2>&1 || true) +if echo "$out" | grep -q "DROP TABLE command is not allowed"; then + pass "non-superuser DROP TABLE blocked when block_drop_table=on" +else + fail "non-superuser DROP TABLE not blocked when block_drop_table=on; got: $out" +fi +psql -c "ALTER ROLE testuser RESET pg_command_fw.block_drop_table;" +psql -c "DROP TABLE IF EXISTS drop_test;" + +echo "" +echo "--- Test 19: production_schemas set -> non-superuser DROP on production schema blocked ---" +psql -c "CREATE SCHEMA IF NOT EXISTS prod;" +psql -c "CREATE TABLE prod.important (id int);" +psql -c "GRANT USAGE ON SCHEMA prod TO testuser;" +psql -c "ALTER TABLE prod.important OWNER TO testuser;" +psql -c "ALTER ROLE testuser SET pg_command_fw.block_drop_table = on;" +psql -c "ALTER ROLE testuser SET pg_command_fw.production_schemas = 'prod';" +out=$(PGPASSWORD=testpass $RU -c "DROP TABLE prod.important;" 2>&1 || true) +if echo "$out" | grep -q "DROP TABLE command is not allowed"; then + pass "non-superuser DROP TABLE on production schema blocked" +else + fail "non-superuser DROP TABLE on production schema not blocked; got: $out" +fi +psql -c "ALTER ROLE testuser RESET pg_command_fw.block_drop_table;" +psql -c "ALTER ROLE testuser RESET pg_command_fw.production_schemas;" +psql -c "DROP TABLE prod.important;" +psql -c "DROP SCHEMA prod;" + +echo "" +echo "=== Master switch ===" + +echo "" +echo "--- Test 20: enabled=off -> TRUNCATE allowed for non-superuser ---" +psql -c "CREATE TABLE IF NOT EXISTS trunc_master (id int);" +psql -c "GRANT TRUNCATE ON trunc_master TO testuser;" +psql -c "ALTER ROLE testuser SET pg_command_fw.enabled = off;" +out=$(PGPASSWORD=testpass $RU -c "TRUNCATE trunc_master;" 2>&1) +if ! echo "$out" | grep -q "not allowed"; then + pass "TRUNCATE allowed when firewall disabled" +else + fail "TRUNCATE blocked when firewall disabled; got: $out" +fi +psql -c "ALTER ROLE testuser RESET pg_command_fw.enabled;" +psql -c "DROP TABLE trunc_master;" + +echo "" +echo "=== blocked_roles ===" + +echo "" +echo "--- Test 21: superuser in blocked_roles is blocked for TRUNCATE ---" +psql -c "CREATE TABLE IF NOT EXISTS blocked_roles_test (id int);" +out=$($SU -c "SET pg_command_fw.blocked_roles = 'postgres'; TRUNCATE blocked_roles_test;" 2>&1 || true) +if echo "$out" | grep -q "TRUNCATE command is not allowed"; then + pass "superuser blocked when listed in blocked_roles" +else + fail "superuser not blocked when listed in blocked_roles; got: $out" +fi +psql -c "DROP TABLE blocked_roles_test;" + +echo "" +echo "=== hint GUC ===" + +echo "" +echo "--- Test 22: hint is shown in error message ---" +psql -c "CREATE TABLE IF NOT EXISTS hint_test (id int);" +psql -c "GRANT TRUNCATE ON hint_test TO testuser;" +psql -c "ALTER ROLE testuser SET pg_command_fw.hint = 'Contact your DBA';" +out=$(PGPASSWORD=testpass $RU -c "TRUNCATE hint_test;" 2>&1 || true) +if echo "$out" | grep -q "Contact your DBA"; then + pass "hint is shown in error" +else + fail "hint not shown in error; got: $out" +fi +psql -c "ALTER ROLE testuser RESET pg_command_fw.hint;" +psql -c "DROP TABLE hint_test;" + +echo "" +echo "=== Audit log ===" + +echo "" +echo "--- Test 23: allowed TRUNCATE creates audit_log row ---" +psql -c "TRUNCATE command_fw.audit_log;" +psql -c "CREATE TABLE IF NOT EXISTS audit_trunc (id int);" +psql -c "TRUNCATE audit_trunc;" +count=$(q "SELECT count(*) FROM command_fw.audit_log WHERE command_type = 'TRUNCATE' AND NOT blocked;") +if [ "$count" = "1" ]; then + pass "allowed TRUNCATE creates audit_log row" +else + fail "expected 1 audit_log row for TRUNCATE, got: $count" +fi +psql -c "DROP TABLE audit_trunc;" + +echo "" +echo "--- Test 24: blocked COPY PROGRAM does not persist in audit_log (tx rollback) ---" +psql -c "TRUNCATE command_fw.audit_log;" +$SU -c "COPY (SELECT 1) TO PROGRAM 'cat';" 2>/dev/null || true +count=$(q "SELECT count(*) FROM command_fw.audit_log WHERE blocked;") +if [ "$count" = "0" ]; then + pass "blocked command does not persist in audit_log (transaction rollback)" +else + fail "expected 0 audit_log rows for blocked command, got: $count" +fi + +echo "" +echo "--- Test 25: audit_log_enabled=off suppresses writes ---" +psql -c "TRUNCATE command_fw.audit_log;" +psql -c "CREATE TABLE IF NOT EXISTS audit_off_test (id int);" +psql -c "SET pg_command_fw.audit_log_enabled = off; TRUNCATE audit_off_test;" +count=$(q "SELECT count(*) FROM command_fw.audit_log;") +if [ "$count" = "0" ]; then + pass "audit_log_enabled=off suppresses audit writes" +else + fail "expected 0 audit_log rows when logging disabled, got: $count" +fi +psql -c "DROP TABLE audit_off_test;" + +echo "" +echo "--- Test 26: audit_log records command_type and blocked correctly ---" +psql -c "TRUNCATE command_fw.audit_log;" +psql -c "CREATE TABLE IF NOT EXISTS audit_type_test (id int);" +psql -c "TRUNCATE audit_type_test;" +row=$(q "SELECT command_type || '|' || blocked FROM command_fw.audit_log ORDER BY id DESC LIMIT 1;") +if [ "$row" = "TRUNCATE|false" ]; then + pass "audit_log records command_type=TRUNCATE, blocked=false" +else + fail "unexpected audit_log content: '$row' (expected 'TRUNCATE|false')" +fi +psql -c "DROP TABLE audit_type_test;" + +echo "" +echo "--- Test 27: audit_log records session_user_name ---" +user=$(q "SELECT session_user_name FROM command_fw.audit_log ORDER BY id DESC LIMIT 1;") +if [ "$user" = "postgres" ]; then + pass "audit_log records session_user_name=postgres" +else + fail "expected session_user_name='postgres', got: '$user'" +fi + +echo "" +echo "=== Regular SQL unaffected ===" + +echo "" +echo "--- Test 28: SELECT works ---" +result=$(psql -t -A -c "SELECT 42;") +if [ "$result" = "42" ]; then + pass "Regular SELECT works" +else + fail "Expected '42', got: '$result'" +fi + +echo "" +echo "--- Test 29: CREATE TABLE / INSERT / SELECT work ---" +count=$(psql -t -A <<'SQL' | tail -1 +CREATE TEMP TABLE _docker_test (id int); +INSERT INTO _docker_test VALUES (1), (2), (3); +SELECT count(*) FROM _docker_test; +SQL +) +if [ "$count" = "3" ]; then + pass "CREATE TABLE / INSERT / SELECT work" +else + fail "Expected count 3, got: '$count'" +fi + +echo "" +echo "================================" +echo "Results: $PASS passed, $FAIL failed" +echo "================================" + +[ "$FAIL" -eq 0 ] diff --git a/tests/pg_regress/expected/setup.out b/tests/pg_regress/expected/setup.out deleted file mode 100644 index adfb69b..0000000 --- a/tests/pg_regress/expected/setup.out +++ /dev/null @@ -1,3 +0,0 @@ --- this setup file is run immediately after the regression database is (re)created --- the file is optional but you likely want to create the extension -CREATE EXTENSION pg_command_fw; diff --git a/tests/pg_regress/sql/setup.sql b/tests/pg_regress/sql/setup.sql deleted file mode 100644 index adfb69b..0000000 --- a/tests/pg_regress/sql/setup.sql +++ /dev/null @@ -1,3 +0,0 @@ --- this setup file is run immediately after the regression database is (re)created --- the file is optional but you likely want to create the extension -CREATE EXTENSION pg_command_fw;