Row-level security

PostgreSQL · Advanced · 12 min

RLS gives multi-tenant isolation that a forgotten WHERE clause in application code can't break.

Row-level security (RLS) adds per-row filters enforced by PostgreSQL itself. Enable it on a table with ALTER TABLE t ENABLE ROW LEVEL SECURITY; from then on, roles subject to it see no rows until a policy allows them.

A policy has:

  • a command (FOR SELECT, INSERT, UPDATE, DELETE or ALL),
  • target roles (TO app),
  • USING (expr): which existing rows are visible or can be updated/deleted,
  • WITH CHECK (expr): which new or modified rows are allowed to be written.

Policies are combined with OR (permissive, the default) or AND (AS RESTRICTIVE).

A common multi-tenant pattern: the application sets a session variable per request, SET app.tenant_id = '42' (or set_config('app.tenant_id', '42', true) inside the transaction), and the policy compares tenant_id = current_setting('app.tenant_id')::int. With a connection pool, use the transaction-local form so a value can't leak to the next request.

Important exemptions: superusers and roles with BYPASSRLS ignore RLS, and so does the table owner unless you add FORCE ROW LEVEL SECURITY. So the application must not connect as the owner. Index the columns used in policies; the policy predicate is added to every query, and functions in it should be simple and leakproof-friendly.

Example

CREATE TABLE note (id int PRIMARY KEY, tenant_id int NOT NULL, body text);
INSERT INTO note VALUES (1, 1, 'tenant one'), (2, 2, 'tenant two');

CREATE ROLE web NOLOGIN;
GRANT SELECT, INSERT ON note TO web;
ALTER TABLE note ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON note TO web
    USING (tenant_id = current_setting('app.tenant_id')::int)
    WITH CHECK (tenant_id = current_setting('app.tenant_id')::int);

SET ROLE web;
SET app.tenant_id = '1';
SELECT id, body FROM note;                  -- 1 | tenant one   (only)
INSERT INTO note VALUES (3, 2, 'sneaky');
-- ERROR: new row violates row-level security policy for table "note"
RESET ROLE;

Remember

  • ENABLE ROW LEVEL SECURITY + policies filter rows for every query
  • USING filters visible rows; WITH CHECK validates written rows
  • Owners and superusers bypass RLS unless FORCE is set

Watch out for

  • Testing RLS as the table owner or a superuser and concluding it works (or doesn't).

Check yourself

  1. RLS is enabled with a USING policy, but the app still sees every tenant's rows. What's the most likely cause?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.postgresql.org/docs/current/ddl-rowsecurity.html

Builds on

Unlocks

Nothing builds on this yet.