Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 6 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"]
Expand All @@ -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" ]
Expand Down
39 changes: 39 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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/
163 changes: 162 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,162 @@
# pg_command_fw
# 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;
```
27 changes: 27 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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"]
2 changes: 1 addition & 1 deletion pg_command_fw.control
Original file line number Diff line number Diff line change
@@ -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
Expand Down
53 changes: 53 additions & 0 deletions sql/hooks.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading