All Products
Search
Document Center

ApsaraDB RDS:Isolate data of SaaS tenants based on RLS policies

Last Updated:Mar 28, 2026

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;
Architecture diagram showing tenant isolation with RLS

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:

InstanceVersionQuantitySpecifications
ApsaraDB RDS for PostgreSQL instance15.014 cores, 16 GB, enhanced SSDs (ESSDs)
ECS instanceCentOS 7.8 (64-bit)14 cores, 16 GB

This guide uses four accounts:

UsernameAccount typePurpose
su_userPrivileged accountDatabase administrator
bypassrls_userBYPASSRLSFull-table access for bulk operations and cross-tenant reporting
t1Standard accountTenant 1 application
t2Standard accountTenant 2 application
Create su_user in the ApsaraDB RDS console. Create all other accounts using SQL in the following steps.
  1. Create the database and schema.

    CREATE DATABASE rls_vip;
    CREATE SCHEMA sh_vip;
  2. Create the tenant table with tenant_id as 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)
    );
  3. 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');
  4. 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

  1. 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)
  2. Enable RLS on the table. Until you do this, the policy exists but is not enforced.

    Important

    RLS policies are not enforced until you explicitly enable row level security on the table. Always run ALTER TABLE ... ENABLE ROW LEVEL SECURITY after creating policies on a table.

    ALTER TABLE sh_vip.t_user ENABLE ROW LEVEL SECURITY;

    Verify that RLS is active:

    \d sh_vip.t_user

    After 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 -W

Query: 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 -W
SELECT * 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.

  1. Add the PostgreSQL driver dependency to pom.xml.

    <dependency>
      <groupId>org.postgresql</groupId>
      <artifactId>postgresql</artifactId>
      <version>42.3.1</version>
    </dependency>
  2. Create a SaaSrlsController class. The key is setting app.tenant_id in the connection options property—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 set row_security = off at 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_id column 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.