Row-level security (RLS) in PostgreSQL lets each tenant in a shared database see and modify only their own rows—without requiring separate databases or schemas per tenant. This guide walks through setting up RLS-based tenant isolation on ApsaraDB RDS for PostgreSQL, from policy creation to application-layer integration.
How it works
Each tenant is assigned a unique tenant ID. When a tenant's application connects to the database, it sets a session variable (app.tenant_id) to that tenant's ID. The RLS policy then filters every query automatically, so tenants can only read or write rows where tenant_id matches their session variable.
Think of an RLS policy as a WHERE clause that PostgreSQL silently appends to every query:
-- RLS policy:
CREATE POLICY sh_vip_t_user ON sh_vip.t_user
FOR ALL TO PUBLIC
USING (tenant_id = (current_setting('app.tenant_id'::text))::bigint);
-- Equivalent to adding this WHERE clause to every query:
SELECT * FROM sh_vip.t_user
WHERE tenant_id = current_setting('app.tenant_id')::bigint;Key concepts
RLS policy scope
An RLS policy applies to SELECT, INSERT, UPDATE, DELETE, or ALL statements, and can be attached to one or more roles. If multiple policies apply to the same table, they are combined with logical OR—a row is visible if any one policy returns TRUE.
USING vs. WITH CHECK
USING clause: Filters existing rows. Rows where the expression evaluates to FALSE are invisible to the query.
WITH CHECK clause: Validates new or updated rows before they are written. If omitted, the USING expression is also used as the WITH CHECK expression.
BYPASSRLS
Roles with the BYPASSRLS attribute bypass all RLS policies. Use this for administrative accounts that need full-table access, such as bulk updates or cross-tenant reporting.
Statements not covered by RLS
TRUNCATE and REFERENCES are not subject to RLS policies. DELETE and UPDATE statements that operate across all rows without a WHERE clause are also not subject to RLS. Revoke these permissions from tenant accounts.
Prerequisites
Before you begin, ensure that you have:
An ApsaraDB RDS for PostgreSQL instance (PostgreSQL 15.0 or later)
An Elastic Compute Service (ECS) instance in the same region and virtual private cloud (VPC) as the RDS instance
The private IP address of the ECS instance added to the RDS instance whitelist
The PostgreSQL client installed on the ECS instance (download)
Set up tenant isolation
Step 1: Prepare accounts and test data
Connect to the RDS instance as the privileged account (su_user). For instructions, see Create an account and Connect to an ApsaraDB RDS for PostgreSQL instance.
The example environment uses the following resources:
| Instance | Version | Quantity | Specifications |
|---|---|---|---|
| ApsaraDB RDS for PostgreSQL instance | 15.0 | 1 | 4 cores, 16 GB, enhanced SSDs (ESSDs) |
| ECS instance | CentOS 7.8 (64-bit) | 1 | 4 cores, 16 GB |
This guide uses four accounts:
| Username | Account type | Purpose |
|---|---|---|
| su_user | Privileged account | Database administrator |
| bypassrls_user | BYPASSRLS | Full-table access for bulk operations and cross-tenant reporting |
| t1 | Standard account | Tenant 1 application |
| t2 | Standard account | Tenant 2 application |
Create su_user in the ApsaraDB RDS console. Create all other accounts using SQL in the following steps.Create the database and schema.
CREATE DATABASE rls_vip; CREATE SCHEMA sh_vip;Create the tenant table with
tenant_idas part of the primary key.CREATE TABLE sh_vip.t_user( tenant_id bigint, user_id bigint, tenant_name varchar(50), PRIMARY KEY(tenant_id, user_id) );Insert test data for two tenants.
INSERT INTO sh_vip.t_user VALUES (1, 10, 'Zhang San'); INSERT INTO sh_vip.t_user VALUES (2, 20, 'Li Si');Create the tenant accounts and grant permissions.
-- Create tenant accounts CREATE USER t1 WITH PASSWORD 'TestPW123!'; CREATE USER t2 WITH PASSWORD 'TestPW123!'; -- Grant permissions on the schema and table GRANT ALL ON SCHEMA sh_vip TO t1; GRANT ALL ON TABLE sh_vip.t_user TO t1; GRANT ALL ON SCHEMA sh_vip TO t2; GRANT ALL ON TABLE sh_vip.t_user TO t2;
Step 2: Create and enable the RLS policy
Create an RLS policy that restricts each tenant to rows matching their session variable.
CREATE POLICY sh_vip_t_user ON sh_vip.t_user FOR ALL TO PUBLIC USING (tenant_id = (current_setting('app.tenant_id'::text))::bigint);Verify the policy was created:
SELECT * FROM pg_policies;Expected output:
schemaname | tablename | policyname | permissive | roles | cmd | qual | with_check ------------+-----------+---------------+------------+----------+-----+----------------------------------------------------------------+------------ sh_vip | t_user | sh_vip_t_user | PERMISSIVE | {public} | ALL | (tenant_id = (current_setting('app.tenant_id'::text))::bigint) | (1 row)Enable RLS on the table. Until you do this, the policy exists but is not enforced.
ImportantRLS policies are not enforced until you explicitly enable row level security on the table. Always run
ALTER TABLE ... ENABLE ROW LEVEL SECURITYafter creating policies on a table.ALTER TABLE sh_vip.t_user ENABLE ROW LEVEL SECURITY;Verify that RLS is active:
\d sh_vip.t_userAfter enabling, the output shows
Policies:without the(row security disabled)qualifier:Table "sh_vip.t_user" Column | Type | Collation | Nullable | Default -------------+-----------------------+-----------+----------+--------- tenant_id | bigint | | not null | user_id | bigint | | not null | tenant_name | character varying(50) | | | Indexes: "t_user_pkey" PRIMARY KEY, btree (tenant_id, user_id) Policies: POLICY "sh_vip_t_user" USING ((tenant_id = (current_setting('app.tenant_id'::text))::bigint))
Step 3: Verify tenant isolation
Connect as tenant t1 with app.tenant_id=1 set as a connection option:
env PGOPTIONS="-c app.tenant_id=1" psql -h pgm-****.pg.rds.aliyuncs.com -d rls_vip -U t1 -p 5432 -WQuery: Tenant t1 sees only its own rows.
SELECT * FROM sh_vip.t_user; tenant_id | user_id | tenant_name
-----------+---------+-------------
1 | 10 | Zhang San
(1 row)Insert: Inserting a row with tenant_id=1 succeeds; inserting with another tenant's ID fails.
-- Succeeds
INSERT INTO sh_vip.t_user VALUES (1, 11, 'Leo');
-- Fails with: new row violates row-level security policy for table "t_user"
INSERT INTO sh_vip.t_user VALUES (2, 21, 'Darren');Update: Modifying t1's own rows succeeds; modifying another tenant's rows fails silently (0 rows updated).
-- Succeeds
UPDATE sh_vip.t_user SET tenant_name = 'Leo1' WHERE user_id = 11;
-- Fails silently (0 rows affected)
UPDATE sh_vip.t_user SET tenant_name = 'Leo1' WHERE user_id = 20;Delete: Deleting t1's own rows succeeds; deleting another tenant's rows fails silently.
-- Succeeds
DELETE FROM sh_vip.t_user WHERE user_id = 11;
-- Fails silently (0 rows affected)
DELETE FROM sh_vip.t_user WHERE user_id = 20;To confirm isolation is symmetric, connect as tenant t2 and verify it sees only its own data:
env PGOPTIONS="-c app.tenant_id=2" psql -h pgm-****.pg.rds.aliyuncs.com -d rls_vip -U t2 -p 5432 -WSELECT * FROM sh_vip.t_user; tenant_id | user_id | tenant_name
-----------+---------+-------------
2 | 20 | Li Si
(1 row)Tenant t2 sees only its own row and cannot access tenant t1's data.
Step 4: Create an admin account for global data access
Standard tenant accounts can only access their own data. To update data across tenants—for example, for backfills or reporting—create an account with the BYPASSRLS attribute.
CREATE USER bypassrls_user BYPASSRLS PASSWORD 'TestPW123!';
GRANT ALL ON SCHEMA sh_vip TO bypassrls_user;
GRANT ALL ON TABLE sh_vip.t_user TO bypassrls_user;bypassrls_user can query and modify all rows in the table, regardless of tenant_id.
Java example
The following example shows how to set app.tenant_id at the JDBC connection level using Maven and the PostgreSQL JDBC driver.
Add the PostgreSQL driver dependency to
pom.xml.<dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <version>42.3.1</version> </dependency>Create a
SaaSrlsControllerclass. The key is settingapp.tenant_idin the connectionoptionsproperty—PostgreSQL applies RLS filtering for every statement on this connection.import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.Properties; public class SaaSrlsController { public static void main(String[] args) { try { Class.forName("org.postgresql.Driver"); } catch (ClassNotFoundException e) { e.printStackTrace(); } String hostname = "pgm-****.pg.rds.aliyuncs.com"; // RDS instance endpoint int port = 5432; String dbname = "rls_vip"; String username = "t1"; String password = "*****"; String dbUrl = "jdbc:postgresql://" + hostname + ":" + port + "/" + dbname; try { Properties props = new Properties(); props.setProperty("user", username); props.setProperty("password", password); // Set the tenant ID for this connection — RLS enforces isolation automatically props.setProperty("options", "-c app.tenant_id=1"); Connection connection = DriverManager.getConnection(dbUrl, props); Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery("SELECT * FROM sh_vip.t_user;"); while (rs.next()) { System.out.println(rs.getString("tenant_id") + "," + rs.getString("tenant_name")); } } catch (SQLException e) { e.printStackTrace(); } } }
Usage notes
Disabling RLS: Use
ALTER TABLE table_name SET (row_security = off)to disable RLS, or setrow_security = offat the session level. When RLS is disabled, all rows are accessible regardless of any policies. Restrict who can disable RLS to prevent accidental data exposure.Managing policies: Use CREATE POLICY, ALTER POLICY, and DROP POLICY to manage RLS policies. Policy names must be unique per table but can be reused across different tables.
Policy evaluation: When multiple policies apply to a table, they are combined with logical OR. A row is accessible if any one policy returns TRUE.
Statements not covered: TRUNCATE and REFERENCES are not subject to RLS. Revoke these permissions from tenant accounts to prevent cross-tenant data access.
What's next
Add an index on the
tenant_idcolumn to avoid full-table scans when RLS filters rows at scale.Create a PostgreSQL event trigger that automatically enables row level security whenever a new table is created, so new tables are never accidentally left unprotected.