diff --git a/Cargo.toml b/Cargo.toml index 3d9cbe1..baa251c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "block_copy_command" -version = "0.1.4" +version = "0.1.5" edition = "2021" description = "PostgreSQL extension that blocks COPY commands via a configurable ProcessUtility hook" authors = ["RustWizard"] diff --git a/META.json b/META.json index bece32e..2ddc2c9 100644 --- a/META.json +++ b/META.json @@ -2,14 +2,14 @@ "name": "block_copy_command", "abstract": "PostgreSQL extension that blocks COPY commands via a configurable ProcessUtility hook", "description": "A PostgreSQL security extension that intercepts and blocks COPY commands using a ProcessUtility hook. Supports direction-specific blocking (COPY TO / COPY FROM), COPY PROGRAM blocking, per-role blocklists, superuser bypass, audit logging, and custom error hints.", - "version": "0.1.4", + "version": "0.1.5", "maintainer": "RustWizard ", "license": "bsd", "provides": { "block_copy_command": { "abstract": "Block COPY commands via a configurable ProcessUtility hook", "file": "block_copy_command.control", - "version": "0.1.4" + "version": "0.1.5" } }, "resources": { diff --git a/README.md b/README.md index 75fad27..ca934f8 100644 --- a/README.md +++ b/README.md @@ -215,14 +215,75 @@ ALTER ROLE alice SET block_copy_command.blocked_roles = 'alice'; ALTER ROLE alice RESET block_copy_command.blocked_roles; ``` -### Audit logging +### GUC: `block_copy_command.audit_log_enabled` -When a COPY command is blocked, the current username is written to the PostgreSQL server log at `LOG` level: +Controls whether intercepted `COPY` events are written to `block_copy_command.audit_log`. Only superusers can change this setting. +| Value | Effect | +|-------|--------| +| `on` (default) | Every intercepted COPY is recorded in the audit table | +| `off` | No rows are written (server log is unaffected) | + +```sql +-- Disable audit writes for a specific role: +ALTER ROLE etl_user SET block_copy_command.audit_log_enabled = off; +``` + +### Audit log table + +Every intercepted `COPY` command (allowed and blocked) is recorded in `block_copy_command.audit_log`: + +| Column | Type | Description | +|--------|------|-------------| +| `id` | `bigserial` | Auto-incrementing primary key | +| `ts` | `timestamptz` | Wall-clock time of the event (`clock_timestamp()`) | +| `session_user_name` | `text` | Role that authenticated (stable across `SET ROLE`) | +| `current_user_name` | `text` | Effective role at the time of COPY | +| `query_text` | `text` | Full query string | +| `copy_direction` | `text` | `'TO'` or `'FROM'` | +| `copy_is_program` | `bool` | `true` for `COPY … PROGRAM` statements | +| `client_addr` | `inet` | Client IP address (`NULL` for Unix-socket connections) | +| `application_name` | `text` | `application_name` GUC of the client session | +| `blocked` | `bool` | `true` if the command was blocked | +| `block_reason` | `text` | `NULL` when allowed; one of `role_listed`, `program_blocked`, `direction_blocked` when blocked | + +> **Note on blocked events:** the audit row is inserted before the `ERROR` is raised, so it is rolled back when the transaction aborts. For blocked commands the server `LOG` line is the authoritative record; audit rows are reliable only for allowed commands. + +**Example queries:** + +```sql +-- All COPY events in the last hour +SELECT ts, current_user_name, copy_direction, blocked, block_reason +FROM block_copy_command.audit_log +WHERE ts > now() - interval '1 hour' +ORDER BY ts DESC; + +-- Blocked events only +SELECT * +FROM block_copy_command.audit_log +WHERE blocked +ORDER BY ts DESC; + +-- Activity per user +SELECT current_user_name, count(*) AS total, + count(*) FILTER (WHERE blocked) AS blocked_count +FROM block_copy_command.audit_log +GROUP BY current_user_name +ORDER BY total DESC; +``` + +The schema and table are locked down by default; grant `SELECT` to monitoring roles as needed: + +```sql +GRANT USAGE ON SCHEMA block_copy_command TO monitoring_role; +GRANT SELECT ON block_copy_command.audit_log TO monitoring_role; ``` -LOG: current_user = "someuser" + +When a COPY command is blocked, the event is also written to the PostgreSQL server log at `LOG` level: + +``` +LOG: blocked COPY TO program=false user="someuser" reason="direction_blocked" ERROR: COPY TO command is not allowed -HINT: Contact DBA to request access ``` ## Testing @@ -244,6 +305,10 @@ This builds the extension inside Docker, starts a PostgreSQL 17 instance with th - GUC `block_copy_command.enabled` toggle - GUC `block_copy_command.blocked_roles` blocks specific roles including superusers - DDL, DML, and regular queries unaffected +- Audit log: allowed COPY creates a row; correct `copy_direction`, `copy_is_program`, `blocked`, `block_reason` values +- Audit log: `session_user_name` and `current_user_name` both recorded +- `audit_log_enabled=off` suppresses writes +- Blocked COPY does not persist in audit log (transaction rollback) ### With pgrx test runner diff --git a/sql/block_copy_command--0.1.4--0.1.5.sql b/sql/block_copy_command--0.1.4--0.1.5.sql new file mode 100644 index 0000000..f2761f0 --- /dev/null +++ b/sql/block_copy_command--0.1.4--0.1.5.sql @@ -0,0 +1,26 @@ +-- Upgrade block_copy_command from 0.1.4 to 0.1.5 +-- Adds the audit_log table and block_copy_command.audit_log_enabled GUC. + +CREATE SCHEMA IF NOT EXISTS block_copy_command; + +CREATE TABLE block_copy_command.audit_log ( + id bigserial NOT NULL, + ts timestamptz NOT NULL DEFAULT clock_timestamp(), + session_user_name text NOT NULL, + current_user_name text NOT NULL, + query_text text NOT NULL, + copy_direction text NOT NULL, + copy_is_program bool NOT NULL DEFAULT false, + client_addr inet, + application_name text, + blocked bool NOT NULL, + block_reason text, + PRIMARY KEY (id) +); + +CREATE INDEX ON block_copy_command.audit_log (ts); +CREATE INDEX ON block_copy_command.audit_log (current_user_name); +CREATE INDEX ON block_copy_command.audit_log (ts) WHERE blocked; + +REVOKE ALL ON SCHEMA block_copy_command FROM PUBLIC; +REVOKE ALL ON block_copy_command.audit_log FROM PUBLIC; diff --git a/sql/hooks.sql b/sql/hooks.sql index 033953f..fc6cdfd 100644 --- a/sql/hooks.sql +++ b/sql/hooks.sql @@ -1,4 +1,47 @@ -- This extension registers a ProcessUtility hook in _PG_init to block COPY commands. --- No SQL-level objects are created. -- To activate for all connections, add to postgresql.conf: -- shared_preload_libraries = 'block_copy_command' + +CREATE SCHEMA IF NOT EXISTS block_copy_command; + +-- Audit log for all intercepted COPY 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 COPY ...) is the authoritative record for blocked events. +-- Allowed COPY commands commit normally and their rows persist here. +CREATE TABLE block_copy_command.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 COPY. + session_user_name text NOT NULL, + current_user_name text NOT NULL, + query_text text NOT NULL, + copy_direction text NOT NULL, -- 'TO' | 'FROM' + copy_is_program bool NOT NULL DEFAULT false, + -- 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', 'program_blocked', + -- 'direction_blocked' when blocked. + block_reason text, + PRIMARY KEY (id) +); + +-- Index for time-range queries and dashboards. +CREATE INDEX ON block_copy_command.audit_log (ts); +-- Index for per-user audits. +CREATE INDEX ON block_copy_command.audit_log (current_user_name); +-- Partial index: fast scan of blocked-only events (typically a small fraction). +CREATE INDEX ON block_copy_command.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 block_copy_command FROM PUBLIC; +REVOKE ALL ON block_copy_command.audit_log FROM PUBLIC; diff --git a/src/lib.rs b/src/lib.rs index 18eab7e..9711232 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ use pgrx::guc::{GucContext, GucFlags, GucRegistry, GucSetting}; use pgrx::is_a; use pgrx::pg_sys; +use pgrx::datum::DatumWithOid; use pgrx::pg_sys::panic::ErrorReport; use pgrx::prelude::*; use std::ffi::CString; @@ -19,6 +20,10 @@ static BLOCK_FROM: GucSetting = GucSetting::::new(true); static BLOCK_PROGRAM: GucSetting = GucSetting::::new(true); // Optional hint message shown to users when their COPY command is blocked. static HINT: GucSetting> = GucSetting::>::new(None); +// Write every intercepted COPY to block_copy_command.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; @@ -41,11 +46,17 @@ unsafe fn block_copy_process_utility(args: ProcessUtilityArgs) { let is_from = (*copy_stmt).is_from; let is_program = (*copy_stmt).is_program; - let username = get_current_username().unwrap_or_else(|| "unknown".to_string()); + 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(""); + let in_blocked_list = 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 == username)) + .map(|list| list.split(',').map(str::trim).any(|r| r == current_user)) .unwrap_or(false); // COPY TO/FROM PROGRAM is blocked for everyone (including superusers) when block_program=on. @@ -57,15 +68,44 @@ unsafe fn block_copy_process_utility(args: ProcessUtilityArgs) { } else { BLOCK_TO.get() }; - let should_block = in_blocked_list - || program_blocked - || (BLOCK_COPY_ENABLED.get() && !pg_sys::superuser() && direction_blocked); + + // Derive the reason first; should_block follows from it. + let block_reason: Option<&str> = if in_blocked_list { + Some("role_listed") + } else if program_blocked { + Some("program_blocked") + } else if BLOCK_COPY_ENABLED.get() && !pg_sys::superuser() && direction_blocked { + Some("direction_blocked") + } else { + None + }; + let should_block = block_reason.is_some(); + let copy_direction = if is_from { "FROM" } else { "TO" }; + + // Write to audit_log before raising the error so the record exists at + // the SPI level. For blocked commands it will be rolled back when ERROR + // aborts the transaction; the LOG line below is the reliable record in + // that case. + write_audit_log( + &session_user, + ¤t_user, + query_text, + copy_direction, + is_program, + should_block, + block_reason, + ); if should_block { - pgrx::log!("current_user = {:?}", username); - let direction = if is_from { "FROM" } else { "TO" }; + pgrx::log!( + "blocked COPY {} program={} user={:?} reason={:?}", + copy_direction, + is_program, + current_user, + block_reason.unwrap_or(""), + ); let suffix = if is_program { " PROGRAM" } else { "" }; - let msg = format!("COPY {}{} command is not allowed", direction, suffix); + let msg = format!("COPY {}{} command is not allowed", copy_direction, suffix); let hint = HINT .get() .and_then(|cstr| cstr.to_str().ok().map(str::to_owned)); @@ -128,6 +168,57 @@ unsafe fn block_copy_process_utility(args: ProcessUtilityArgs) { } } +// Write one row to block_copy_command.audit_log. All errors are silently +// swallowed so a missing table (library loaded before CREATE EXTENSION) or any +// other SPI problem never breaks the main blocking logic. +fn write_audit_log( + session_user: &str, + current_user: &str, + query_text: &str, + copy_direction: &str, + copy_is_program: bool, + blocked: bool, + block_reason: Option<&str>, +) { + if !AUDIT_LOG_ENABLED.get() { + return; + } + + // Catch any PostgreSQL error (e.g. relation does not exist) so we never + // propagate audit failures to the caller. The closure captures only + // UnwindSafe types (&str, bool, Option<&str>) so PgTryBuilder is happy. + PgTryBuilder::new(move || { + Spi::connect_mut(|client| { + // DatumWithOid::from uses T::type_oid() automatically; Option<&str> + // produces a NULL datum when None. + let args = [ + DatumWithOid::from(session_user), + DatumWithOid::from(current_user), + DatumWithOid::from(query_text), + DatumWithOid::from(copy_direction), + DatumWithOid::from(copy_is_program), + DatumWithOid::from(blocked), + DatumWithOid::from(block_reason), + ]; + // client_addr and application_name are read via SQL functions so we + // don't need unsafe access to MyProcPort. + let _ = client.update( + "INSERT INTO block_copy_command.audit_log \ + (session_user_name, current_user_name, query_text, copy_direction, \ + copy_is_program, client_addr, application_name, blocked, block_reason) \ + VALUES ($1, $2, $3, $4, $5, \ + inet_client_addr(), \ + current_setting('application_name', true), \ + $6, $7)", + None, + &args, + ); + }); + }) + .catch_others(|_| ()) + .execute(); +} + #[pg_guard] #[cfg(feature = "pg13")] unsafe extern "C-unwind" fn hook_trampoline( @@ -234,6 +325,18 @@ pub extern "C-unwind" fn _PG_init() { GucFlags::default(), ); + GucRegistry::define_bool_guc( + c"block_copy_command.audit_log_enabled", + c"Write intercepted COPY events to block_copy_command.audit_log", + c"When on (default), every intercepted COPY command is recorded in \ + block_copy_command.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); @@ -257,6 +360,25 @@ fn get_current_username() -> Option { } } +// Like get_current_username but returns the session-level user (the role that +// actually authenticated). This differs from current_user when SET ROLE has +// been used in the session. +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"); /// Required by pgrx to configure the test PostgreSQL instance. @@ -334,6 +456,11 @@ mod tests { assert_eq!(show("block_copy_command.blocked_roles"), ""); } + #[pg_test] + fn test_guc_audit_log_enabled_default_on() { + assert_eq!(show("block_copy_command.audit_log_enabled"), "on"); + } + // GUC round-trips (SET → SHOW → restore) // These run as the pgrx test superuser, so Suset GUCs are writable. @@ -369,6 +496,14 @@ mod tests { assert_eq!(show("block_copy_command.blocked_roles"), ""); } + #[pg_test] + fn test_guc_audit_log_enabled_roundtrip() { + Spi::run("SET block_copy_command.audit_log_enabled = off").unwrap(); + assert_eq!(show("block_copy_command.audit_log_enabled"), "off"); + Spi::run("SET block_copy_command.audit_log_enabled = on").unwrap(); + assert_eq!(show("block_copy_command.audit_log_enabled"), "on"); + } + // GUC independence: changing one direction does not affect the other #[pg_test] @@ -382,7 +517,78 @@ mod tests { Spi::run("SET block_copy_command.block_from = on").unwrap(); } - // COPY blocking itself is tested via pg_regress (tests/pg_regress/sql/copy_blocked.sql) - // because SPI explicitly rejects COPY before ProcessUtility is ever called, - // making it untestable through Spi::run. + // audit_log table structure + // Full audit log behaviour (writes on COPY, blocked-tx rollback, etc.) is + // tested in tests/docker/test.sh because SPI rejects COPY before + // ProcessUtility is ever called, making hook-level writes untestable here. + + #[pg_test] + fn test_audit_log_table_exists() { + let count = Spi::get_one::( + "SELECT count(*) FROM information_schema.tables \ + WHERE table_schema = 'block_copy_command' AND table_name = 'audit_log'", + ) + .unwrap() + .unwrap(); + assert_eq!(count, 1); + } + + #[pg_test] + fn test_audit_log_expected_columns_exist() { + // Verify every column name is present; data-type mismatches would cause + // the SPI INSERT in write_audit_log to fail at runtime. + let expected = [ + "id", + "ts", + "session_user_name", + "current_user_name", + "query_text", + "copy_direction", + "copy_is_program", + "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 = 'block_copy_command' \ + 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() { + // Direct INSERT must succeed so we know the schema is correct and the + // table is accessible to the extension's superuser context. + Spi::run( + "INSERT INTO block_copy_command.audit_log \ + (session_user_name, current_user_name, query_text, \ + copy_direction, copy_is_program, blocked) \ + VALUES ('u', 'u', 'COPY t TO STDOUT', 'TO', false, false)", + ) + .unwrap(); + let count = Spi::get_one::( + "SELECT count(*) FROM block_copy_command.audit_log \ + WHERE query_text = 'COPY t TO STDOUT'", + ) + .unwrap() + .unwrap(); + assert_eq!(count, 1); + Spi::run( + "DELETE FROM block_copy_command.audit_log \ + WHERE query_text = 'COPY t TO STDOUT'", + ) + .unwrap(); + } + + // COPY blocking itself is tested via tests/docker/test.sh because SPI + // rejects COPY before ProcessUtility is ever called, making hook-level + // behaviour untestable through Spi::run. } diff --git a/tests/docker/test.sh b/tests/docker/test.sh index d3f0df4..38b2c7f 100755 --- a/tests/docker/test.sh +++ b/tests/docker/test.sh @@ -207,6 +207,112 @@ else fail "Expected count 3, got: '$count'" fi +echo "" +echo "=== Audit log ===" + +# Helper: run a query and return a single trimmed value. +q() { psql -t -A -c "$1"; } + +echo "" +echo "--- Test 16: superuser COPY TO creates an audit_log row ---" +psql -c "TRUNCATE block_copy_command.audit_log;" +psql -c "COPY (SELECT 1) TO STDOUT;" > /dev/null +count=$(q "SELECT count(*) FROM block_copy_command.audit_log;") +if [ "$count" = "1" ]; then + pass "superuser COPY TO creates audit_log row" +else + fail "expected 1 audit_log row after superuser COPY TO, got: $count" +fi + +echo "" +echo "--- Test 17: audit_log row has correct content (COPY TO) ---" +# Expected: direction=TO, not a program, not blocked, no block_reason. +row=$(q "SELECT copy_direction || '|' || copy_is_program || '|' || blocked || '|' \ + || COALESCE(block_reason, 'NULL') \ + FROM block_copy_command.audit_log ORDER BY id DESC LIMIT 1;") +if [ "$row" = "TO|false|false|NULL" ]; then + pass "audit_log row: direction=TO, is_program=false, blocked=false, reason=NULL" +else + fail "unexpected audit_log content: '$row' (expected 'TO|false|false|NULL')" +fi + +echo "" +echo "--- Test 18: current_user_name is recorded correctly ---" +user=$(q "SELECT current_user_name FROM block_copy_command.audit_log ORDER BY id DESC LIMIT 1;") +if [ "$user" = "postgres" ]; then + pass "audit_log records current_user_name=postgres" +else + fail "expected current_user_name='postgres', got: '$user'" +fi + +echo "" +echo "--- Test 19: COPY FROM STDIN creates audit_log row with direction=FROM ---" +psql -c "TRUNCATE block_copy_command.audit_log;" +psql -c "CREATE TABLE IF NOT EXISTS _audit_from_test (id int);" +# Feed one data row and the COPY terminator via stdin. +printf '%s\n%s\n' '42' '\.' | psql -c "COPY _audit_from_test FROM STDIN;" > /dev/null +psql -c "DROP TABLE _audit_from_test;" +row=$(q "SELECT copy_direction || '|' || blocked \ + FROM block_copy_command.audit_log ORDER BY id DESC LIMIT 1;") +if [ "$row" = "FROM|false" ]; then + pass "COPY FROM creates audit_log row with direction=FROM, blocked=false" +else + fail "unexpected audit_log content for COPY FROM: '$row' (expected 'FROM|false')" +fi + +echo "" +echo "--- Test 20: copy_is_program=true recorded for COPY TO PROGRAM ---" +psql -c "TRUNCATE block_copy_command.audit_log;" +# block_program must be off so the superuser is not blocked. +psql -c "SET block_copy_command.block_program = off; \ + COPY (SELECT 1) TO PROGRAM 'cat > /dev/null';" > /dev/null +is_prog=$(q "SELECT copy_is_program FROM block_copy_command.audit_log ORDER BY id DESC LIMIT 1;") +if [ "$is_prog" = "t" ]; then + pass "audit_log records copy_is_program=true for COPY TO PROGRAM" +else + fail "expected copy_is_program=true, got: '$is_prog'" +fi + +echo "" +echo "--- Test 21: audit_log_enabled=off suppresses writes ---" +psql -c "TRUNCATE block_copy_command.audit_log;" +# SET is connection-scoped; both statements run in the same session via -c. +psql -c "SET block_copy_command.audit_log_enabled = off; \ + COPY (SELECT 1) TO STDOUT;" > /dev/null +count=$(q "SELECT count(*) FROM block_copy_command.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 + +echo "" +echo "--- Test 22: blocked COPY does not persist in audit_log (tx rollback) ---" +# When the hook raises ERROR the current transaction aborts, rolling back the +# SPI-level INSERT. The server log is the authoritative record for blocked events. +psql -c "TRUNCATE block_copy_command.audit_log;" +PGPASSWORD=testpass psql -U testuser -c "COPY (SELECT 1) TO STDOUT;" 2>&1 || true +count=$(q "SELECT count(*) FROM block_copy_command.audit_log;") +if [ "$count" = "0" ]; then + pass "blocked COPY does not persist in audit_log (transaction rollback)" +else + fail "expected 0 audit_log rows after blocked COPY, got: $count" +fi + +echo "" +echo "--- Test 23: session_user_name and current_user_name are both recorded ---" +psql -c "TRUNCATE block_copy_command.audit_log;" +psql -c "COPY (SELECT 1) TO STDOUT;" > /dev/null +same=$(q "SELECT (session_user_name = current_user_name) \ + FROM block_copy_command.audit_log ORDER BY id DESC LIMIT 1;") +su_name=$(q "SELECT session_user_name \ + FROM block_copy_command.audit_log ORDER BY id DESC LIMIT 1;") +if [ "$same" = "t" ] && [ "$su_name" = "postgres" ]; then + pass "audit_log records both session_user_name and current_user_name" +else + fail "unexpected user columns: session_user_name='$su_name', same='$same'" +fi + echo "" echo "================================" echo "Results: $PASS passed, $FAIL failed"