Enforce ServiceHub tenant isolation with RLS USING/WITH CHECK policies, restrictive policy composition, FORCE ROW LEVEL SECURITY, and security-invoker/security-barrier views while testing owner, tenant, and bypass boundaries.

Row-Level Security Policies, FORCE RLS, Security Barrier Views, and Tenant Isolation

Enforce ServiceHub tenant isolation with RLS USING/WITH CHECK policies, restrictive policy composition, FORCE ROW LEVEL SECURITY, and security-invoker/security-barrier views while testing owner, tenant, and bypass boundaries.

Intermediate → Advanced180–240 minutesPostgreSQL security engineeringCurrent patched PostgreSQL 18.x; verify current minor at lab timeCore PostgreSQL; OpenSSL used only for the free local TLS labServiceHub disposable objects: app.ch20_* and ch20_* rolesAdmin/superuser required for HBA/TLS/server-role exercisesLocal/free tooling; no paid identity provider requiredLast reviewed: August 2026

Learning outcomes

ServiceHub becomes multi-tenant: tenant A and tenant B share one table, but each application identity must see and modify only its own rows. Ordinary table GRANT cannot express “SELECT only rows whose tenant identity is yours.” PostgreSQL Row-Level Security (RLS) adds policy predicates to normal table access.

01

Enable RLS and understand the default-deny state when no policy grants access.

02

Use USING for row visibility/targeting and WITH CHECK for new row values.

03

Test tenant A and tenant B positive/negative reads and writes.

04

Explain owner, superuser, BYPASSRLS, and FORCE ROW LEVEL SECURITY behavior.

05

Combine permissive/restrictive policies and use security-invoker/security-barrier views without treating them as a complete authorization system.

Mental model

GRANT answers whether a role may run SELECT/INSERT/UPDATE/DELETE on the table at all. RLS then filters which rows that otherwise-authorized command may see or create. The two layers compose; neither replaces the other.

1. Create tenant identities and the shared table

sql · tenant role and table setup
DROP VIEW IF EXISTS app.ch20_open_ticket;DROP TABLE IF EXISTS app.ch20_ticket CASCADE;DROP ROLE IF EXISTS ch20_tenant_a;DROP ROLE IF EXISTS ch20_tenant_b;CREATE ROLE ch20_tenant_a LOGIN;CREATE ROLE ch20_tenant_b LOGIN;-- No passwords are set in this RLS lab; SET ROLE is used for deterministic tests.SET ROLE servicehub_owner;CREATE TABLE app.ch20_ticket (  ticket_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,  tenant_role name NOT NULL,  subject text NOT NULL,  status text NOT NULL CHECK (status IN ('open','closed','sealed')),  created_at timestamptz NOT NULL DEFAULT clock_timestamp());INSERT INTO app.ch20_ticket(tenant_role,subject,status) VALUES('ch20_tenant_a','Pump alarm','open'),('ch20_tenant_a','Billing correction','sealed'),('ch20_tenant_b','Valve inspection','open'),('ch20_tenant_b','Motor report','closed');RESET ROLE;GRANT USAGE ON SCHEMA app TO ch20_tenant_a, ch20_tenant_b;GRANT SELECT, INSERT, UPDATE, DELETEON app.ch20_ticketTO ch20_tenant_a, ch20_tenant_b;GRANT USAGE, SELECTON SEQUENCE app.ch20_ticket_ticket_id_seqTO ch20_tenant_a, ch20_tenant_b;

The tenant roles have LOGIN so Lesson 5 can discuss session_user as a direct-login identity. No password is assigned; all mandatory RLS tests use SET ROLE from an administrator session.

2. Enabling RLS without a policy is default deny

sql · enable policy enforcement
SET ROLE servicehub_owner;ALTER TABLE app.ch20_ticket ENABLE ROW LEVEL SECURITY;RESET ROLE;SET ROLE ch20_tenant_a;SELECT * FROM app.ch20_ticket;-- Expected: zero rows, because RLS is enabled and no policy permits rows.RESET ROLE;

RLS does not automatically infer tenancy from a column name. With row security enabled and no applicable policy, normal access is denied by a default-deny policy.

3. USING controls visible/targetable rows; WITH CHECK controls new row state

sql · tenant isolation policy
SET ROLE servicehub_owner;CREATE POLICY ch20_tenant_isolationON app.ch20_ticketAS PERMISSIVEFOR ALLTO ch20_tenant_a, ch20_tenant_bUSING (tenant_role = current_user)WITH CHECK (tenant_role = current_user);RESET ROLE;

For SELECT, USING is a visibility predicate. For UPDATE/DELETE, it also determines which existing rows can be targeted. WITH CHECK validates rows newly inserted or the new version produced by an update.

4. Tenant A positive/negative tests

sql · tenant A sees only A and can insert only A
SET ROLE ch20_tenant_a;SELECT ticket_id, tenant_role, subject, statusFROM app.ch20_ticketORDER BY ticket_id;INSERT INTO app.ch20_ticket(tenant_role,subject,status)VALUES ('ch20_tenant_a','A new tenant-A ticket','open');INSERT INTO app.ch20_ticket(tenant_role,subject,status)VALUES ('ch20_tenant_b','Cross-tenant injection','open');-- Expected: new row violates row-level security policy.RESET ROLE;
sql · tenant B has a different row set
SET ROLE ch20_tenant_b;SELECT ticket_id, tenant_role, subject, statusFROM app.ch20_ticketORDER BY ticket_id;UPDATE app.ch20_ticketSET subject = 'stolen'WHERE tenant_role = 'ch20_tenant_a';-- Expected: UPDATE 0, because tenant-A rows are not visible/targetable.RESET ROLE;

The zero-row update is an important RLS behavior: invisible rows act as though they are not available to the command. Applications should not disclose whether hidden rows exist through different error messages.

5. Restrictive policies compose with permissive policies

Multiple permissive policies are ORed; restrictive policies are ANDed with the permissive result. ServiceHub can allow tenant rows but additionally hide sealed tickets from normal tenant reads.

sql · add a restrictive read policy
SET ROLE servicehub_owner;CREATE POLICY ch20_hide_sealedON app.ch20_ticketAS RESTRICTIVEFOR SELECTTO ch20_tenant_a, ch20_tenant_bUSING (status <> 'sealed');RESET ROLE;SET ROLE ch20_tenant_a;SELECT ticket_id, subject, statusFROM app.ch20_ticketORDER BY ticket_id;RESET ROLE;

Tenant A's sealed ticket now disappears even though the tenant-isolation policy permits it. At least one permissive policy still has to grant the row before restrictive policies can further narrow it.

6. Owners normally bypass RLS; FORCE changes that

sql · owner bypass before FORCE
SET ROLE servicehub_owner;SELECT current_user, count(*) AS owner_visible_rowsFROM app.ch20_ticket;RESET ROLE;

The table owner normally bypasses RLS and can see all rows. Superusers and roles with BYPASSRLS always bypass RLS.

sql · FORCE subjects the owner to policies
SET ROLE servicehub_owner;ALTER TABLE app.ch20_ticket FORCE ROW LEVEL SECURITY;SELECT current_user, count(*) AS owner_visible_rowsFROM app.ch20_ticket;-- The owner has no matching tenant policy and therefore sees no tenant rows.RESET ROLE;

FORCE ROW LEVEL SECURITY is useful when owner-executed code should obey the same policy model. It does not override superuser or BYPASSRLS bypass.

7. Do not use a caller-controlled custom GUC as your only tenant identity

sql · why a plain custom setting is not authentication
SET ROLE ch20_tenant_a;SET app.tenant_id = 'tenant-b';SELECT current_setting('app.tenant_id');RESET ROLE;

Ordinary custom configuration parameters are not inherently trusted identity assertions. If a policy simply compares tenant_id = current_setting('app.tenant_id') and the caller may freely set that value, the caller can impersonate another tenant. Bind policy identity to authenticated roles or to a carefully hardened security-definer mapping that the caller cannot forge.

8. Security-invoker + security-barrier view

sql · view that preserves caller RLS and protects predicate ordering
SET ROLE servicehub_owner;CREATE OR REPLACE VIEW app.ch20_open_ticketWITH (  security_barrier = true,  security_invoker = true)ASSELECT ticket_id, tenant_role, subject, status, created_atFROM app.ch20_ticketWHERE status = 'open';RESET ROLE;GRANT SELECT ON app.ch20_open_ticketTO ch20_tenant_a, ch20_tenant_b;
sql · tenant view test
SET ROLE ch20_tenant_b;SELECT *FROM app.ch20_open_ticketORDER BY ticket_id;RESET ROLE;

security_invoker=true makes underlying table permissions/RLS use the invoking role rather than the view owner. security_barrier=true limits unsafe predicate reordering across the view boundary; leakproof operators/functions may still be evaluated earlier because they promise not to leak argument information.

9. RLS is not the only authorization layer

RLS protects row access through PostgreSQL queries. It does not replace application entitlement checks, rate limits, workflow state rules, API object validation, secure logging, or protection from a compromised BYPASSRLS/superuser identity. TRUNCATE and some whole-table operations are not row-filtered by RLS.

Production judgment

Keep tenant identity non-forgeable, write both positive and negative policy tests, separate ordinary app roles from BYPASSRLS/superuser operations, consider FORCE RLS for owner-executed paths, and treat policy changes like application authorization code with review and regression tests.

10. Policy/catalog evidence

sql · inspect RLS state and policy definitions
SELECT c.oid::regclass AS relation,       c.relrowsecurity,       c.relforcerowsecurityFROM pg_class AS cWHERE c.oid = 'app.ch20_ticket'::regclass;SELECT schemaname, tablename, policyname,       permissive, roles, cmd, qual, with_checkFROM pg_policiesWHERE schemaname = 'app'  AND tablename = 'ch20_ticket'ORDER BY policyname;

Check your understanding

  1. What happens when RLS is enabled but no policy applies?
  2. What is the difference between USING and WITH CHECK?
  3. Who bypasses RLS even when FORCE ROW LEVEL SECURITY is enabled?
  4. Why is current_setting('app.tenant_id') unsafe as the only identity if callers can SET it?
  5. What do security_invoker and security_barrier solve on the example view?
Review the answers

RLS becomes default deny. USING determines visible/targetable existing rows; WITH CHECK validates inserted/new updated rows. Superusers and BYPASSRLS roles always bypass. A caller-controlled setting is forgeable identity. security_invoker preserves caller permissions/RLS, while security_barrier prevents unsafe predicate pushdown except for leakproof expressions.

Authoritative references

Authentication, TLS, authorization, and policy behavior is security- and version-sensitive. The lesson uses these PostgreSQL 18 primary sources.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.