Row-Level Security in a Multi-Tenant PostgreSQL Reporting Stack
Meridian HR is a multi-tenant SaaS with 400 tenants in one PostgreSQL 15 cluster. Every tenant-scoped table carries tenant_id and has row-level security enabled. The reporting service connects with role svc_reporting, which owns the tables because migrations run under it; queries set app.tenant_id via SET LOCAL. A BI tool generates SQL with joins to mv_payroll_summary, a materialized view refreshed hourly by a pg_cron job running as svc_reporting. Last week a customer received 1,200 rows belonging to another tenant after a report joined the view to a base table. Support engineers use a shared support_ro login to reproduce customer issues; last month one exported 40,000 rows to a personal drive. Card numbers are tokenized by a payment processor, but the last four digits and expiry are stored in payroll_deductions. Backups are pg_dump files in object storage without server-side encryption. pgaudit logs DDL and role changes only.
1. Diagnose why row-level security did not prevent the 1,200-row cross-tenant leak.
Read the worked answer
RLS was never actually applied to the query path. Several mechanisms explain the leak:
- Owner bypass. In PostgreSQL, a table owner bypasses row security unless
ALTER TABLE ... FORCE ROW LEVEL SECURITYis set.svc_reportingowns the tables because migrations run as that role, so the policies on the base tables were bypassed for its sessions.SET LOCAL app.tenant_idhad no effect because the policy was not enforced. - Materialized views. A materialized view stores a snapshot; querying it does not re-evaluate RLS on the underlying tables.
mv_payroll_summaryis owned bysvc_reporting, contains all tenants' rows, and has no RLS of its own. The join therefore had an unfiltered side, so rows from one tenant could combine with rows from another. - View semantics. A normal view runs with the owner's privileges unless
security_invoker = true(PostgreSQL 15+), and even then RLS depends on the owner not bypassing it. Asecurity_barrierview prevents predicate pushdown that could leak rows through functions, but it does not add tenant filtering by itself. - Pooling and
SET LOCAL.SET LOCALis transaction-scoped. If the pool returns a session to the pool with a stale setting, or the query runs outside a transaction, the tenant context can be wrong or absent. Useset_config('app.tenant_id', ..., true)inside an explicit transaction, and reset on checkout. - Functions. SECURITY DEFINER functions run as the owner; if they are used in the reporting path, they can bypass RLS unless carefully written.
The fix is not to add another policy to the same role; it is to break the ownership/BYPASSRLS path.
2. Redesign the role and privilege model for the reporting service and for support access.
Read the worked answer
Separate ownership from runtime.
- Schema owner: a NOLOGIN role or a tightly controlled migration role that owns tables and runs DDL. No application connects as it.
- Application role (
svc_reporting): owns nothing; granted SELECT on tenant-scoped views and required DML on tables.FORCE ROW LEVEL SECURITYon every tenant table; policyUSING (tenant_id = current_setting('app.tenant_id')::uuid)andWITH CHECKfor writes. The reporting service should be read-only. - Reporting views: tenant-filtered views with
security_invoker = true, or dedicated tenant-keyed materialized views refreshed per tenant. Do not grant SELECT on the base tables or on the cross-tenant matview. If a matview is unavoidable, includetenant_id, index it, and filter in the view; refresh as a separate role. - Support access: eliminate the shared
support_rologin. Use named accounts with just-in-time elevation: a support role without BYPASSRLS by default, and a break-glass role that requires an approved ticket, is time-bound (for example 60 minutes), and is fully audited with session recording. Support should reproduce issues on anonymised or masked tenant copies, not production data. - Column privileges: restrict
last4and expiry to roles that need them; mask in reporting; consider not storing expiry at all if it is not needed for payroll deduction reporting. Tokenisation covers the PAN, but last-4 plus expiry is still cardholder-adjacent data and personal data. - Connection hygiene: set tenant context inside a transaction, reset on pool return, and reject queries with no tenant context.
3. What logging, detection, and backup protections would you add, and how would you handle the 40,000-row export?
Read the worked answer
Detection. Enable pgaudit for DML and DDL, not just DDL; log connections, disconnections, and statements from the reporting and support roles. Ship logs to a SIEM and alert on:
- queries returning rows with more than one
tenant_id; - use of the break-glass or any BYPASSRLS role;
COPY TO, large result sets, or exports above a baseline (for example more than 5,000 rows by a support user);- repeated failed tenant-context lookups;
- matview refreshes and schema changes.
Add a canary tenant with synthetic rows; if those rows appear in another tenant's report, there is a cross-tenant defect. Reconcile row counts per tenant after each matview refresh.
Backups. pg_dump files in object storage must use SSE-KMS with a customer-managed key and, preferably, client-side encryption before upload; restrict the bucket with least-privilege policies, block public access, enable versioning and MFA delete, and log access. Test restore into an isolated environment.
Response. For the 1,200-row incident, preserve query logs, identify the exact join and view version, notify the affected tenants per contract and applicable law, and add a regression test that joins the view with a base table for two tenants and asserts zero cross-tenant rows. Treat the 40,000-row export as a separate insider incident: revoke the shared account, recover the personal drive, review what was accessed, and assess notification obligations.