Skip to content

PostgreSQL row-level security isolation lab

Exercises row-level security, transaction-local tenant context, pooled connections, and negative cross-tenant cases.

Application Security 3 min read

Implementation: Tested

Implementation

001-schema.sql SQL · 40 lines
labs/postgresql-rls/init/001-schema.sql
1\set ON_ERROR_STOP on
2 
3CREATE ROLE tenant_migrator NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;
4CREATE ROLE tenant_runtime NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;
5\getenv tenant_app_password POSTGRES_PASSWORD
6CREATE ROLE tenant_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS
7    PASSWORD :'tenant_app_password';
8GRANT tenant_runtime TO tenant_app;
9\unset tenant_app_password
10 
11CREATE SCHEMA app AUTHORIZATION tenant_migrator;
12GRANT USAGE ON SCHEMA app TO tenant_runtime;
13 
14SET ROLE tenant_migrator;
15 
16CREATE TABLE app.customer_record (
17    id uuid PRIMARY KEY,
18    tenant_id uuid NOT NULL,
19    display_name text NOT NULL,
20    created_at timestamptz NOT NULL DEFAULT clock_timestamp()
21);
22 
23ALTER TABLE app.customer_record ENABLE ROW LEVEL SECURITY;
24ALTER TABLE app.customer_record FORCE ROW LEVEL SECURITY;
25 
26CREATE POLICY tenant_isolation ON app.customer_record
27    FOR ALL
28    TO tenant_runtime, tenant_migrator
29    USING (
30        tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
31    )
32    WITH CHECK (
33        tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
34    );
35 
36GRANT SELECT, INSERT, UPDATE, DELETE ON app.customer_record TO tenant_runtime;
37RESET ROLE;
38 
39COMMENT ON POLICY tenant_isolation ON app.customer_record IS
40    'Fail-closed tenant context: absent context sees no rows; malformed UUID raises an error; writes require the active tenant.';

Run it

  • labs/postgresql-rls/run-tests.sh
  • labs/postgresql-rls/run-tests.ps1

This disposable lab tests a transaction-scoped tenant context, complete USING and WITH CHECK policies, forced row security, and role properties that prevent ordinary application identities from bypassing the boundary.

Prerequisites and tested versions

  • Docker Engine or Docker Desktop with Compose v2.
  • PowerShell 7+ on Windows or a POSIX shell on Linux/macOS.
  • Node.js 22+ and npm for the application integration test.
  • Maintained PostgreSQL client: pg 8.22.0, lockfile pinned.
  • Image: postgres:18.4-alpine3.24, pinned to the PostgreSQL 18.4 patch release checked on 2026-07-21. For a long-lived environment, pin the image digest in the deployment's own dependency process.

Run

PowerShell:

./labs/postgresql-rls/run-tests.ps1

POSIX shell:

./labs/postgresql-rls/run-tests.sh

The scripts generate an ephemeral local password, install the lockfile-pinned pg client, start the service, run SQL runtime/boundary/catalog suites plus the pooled-client suite, and remove containers and volumes. The password is not persisted in a tracked file. Pass -Keep or --keep to retain the service for inspection.

Expected output includes PASS messages for cross-tenant reads and writes, mutation of a visible row's tenant key, missing and malformed context, connection reuse, forced RLS for the table owner, catalog flags, non-bypass role attributes, policy count, policy roles, and complete policy expressions. Any SQL error or failed assertion returns a nonzero status.

Security properties exercised

  • Tenant identity is set with transaction-local set_config(..., true); it does not leak into the next transaction on a pooled connection.
  • Missing context matches no rows and fails writes. Malformed UUID context raises an error instead of falling back to a broader scope.
  • Both row visibility and new row values are constrained, including an attempted update that moves a visible row into a different tenant.
  • FORCE ROW LEVEL SECURITY is exercised while running as the table owner.
  • Catalog tests reject missing RLS flags, bypass-capable application roles, extra policies, unexpected policy roles, and absent or trivially true expressions.

Application integration boundary

Every request and background job must establish the tenant inside the same database transaction as the protected queries. A background worker is not implicitly a system-wide tenant: it should process one explicit tenant per transaction, or use a separate narrowly scoped maintenance role and separately reviewed policy. Do not place a connection in a pool after a session-scoped tenant setting.

Limitations

The test connects as the local database administrator and uses SET ROLE to exercise non-bypass identities. It does not model managed-service administrator roles, network controls, connection-pool middleware, migration orchestration, replication, backups, side channels, or application authorization. PostgreSQL superusers and roles with BYPASSRLS bypass RLS by design; they require separate administrative controls and monitoring.

References