Advanced RLS Policies for Hierarchical Multi-Tenancy in PostgreSQL

15 min read
Goh Ling Yong
Technology enthusiast and software architect specializing in AI-driven development tools and modern software engineering practices. Passionate about the intersection of artificial intelligence and human creativity in building tomorrow's digital solutions.

The Fragility of Simple RLS in Complex Hierarchies

In the world of multi-tenant SaaS applications, Row-Level Security (RLS) in PostgreSQL is a foundational tool for data isolation. The canonical example is straightforward: a posts table has a tenant_id, and a simple policy ensures users only see their own tenant's data.

sql
-- The classic, simple RLS policy
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

CREATE POLICY select_posts_for_tenant
  ON posts FOR SELECT
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

This pattern is effective for flat tenancy models but shatters when faced with the reality of modern B2B applications: hierarchical permissions. Consider a common structure: an Organization has multiple Teams, and each Team has multiple Projects. A user might be an ORG_ADMIN, granting them access to all projects within their entire organization, or a TEAM_MEMBER, restricting their access to projects within a specific team.

Attempting to model this with the simple policy is untenable. You could try to denormalize the organization_id onto every project, but the policy logic becomes a convoluted mess of OR conditions trying to reconcile a user's various roles. Performance degrades, and security becomes brittle. The fundamental problem is that the access decision for a given row depends not on a single identifier, but on its position within a relational graph.

This article presents a robust, performant, and secure pattern for implementing hierarchical RLS in PostgreSQL. We will build a system that can efficiently answer the question: "Does the current user, with their assigned roles, have access to this specific row through any path in the tenancy hierarchy?"

Step 1: Modeling the Hierarchy

Before writing policies, we need a data model that accurately represents the hierarchy. For this walkthrough, we'll use an Organizations -> Teams -> Projects structure. We also need to model user membership and roles.

Here is our foundational schema:

sql
-- Use pgcrypto for UUIDs
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

-- The top of the hierarchy
CREATE TABLE organizations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL
);

-- Teams belong to an organization
CREATE TABLE teams (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  name TEXT NOT NULL
);

-- Projects belong to a team
CREATE TABLE projects (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  -- For later optimization, we'll denormalize the organization_id
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE
);

-- Standard users table
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email TEXT NOT NULL UNIQUE
);

-- Membership and roles are key
CREATE TYPE membership_role AS ENUM ('ORG_ADMIN', 'TEAM_MEMBER');

CREATE TABLE memberships (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  -- A membership can be at the organization or team level
  organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
  team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
  role membership_role NOT NULL,
  -- Ensure a membership is tied to either an org or a team, but not both
  CONSTRAINT chk_membership_target CHECK (num_nonnulls(organization_id, team_id) = 1)
);

-- Indexes for performance
CREATE INDEX ON teams (organization_id);
CREATE INDEX ON projects (team_id);
CREATE INDEX ON projects (organization_id);
CREATE INDEX ON memberships (user_id);
CREATE INDEX ON memberships (organization_id);
CREATE INDEX ON memberships (team_id);

This schema establishes the relationships. A user's access is defined by their entries in the memberships table. An ORG_ADMIN is linked to an organization_id, while a TEAM_MEMBER is linked to a team_id.

Step 2: The Naive Approach - Recursive Lookups in Policies

Our goal is to create a policy on projects that checks if the current user has access. A direct approach might be to write a function that performs this hierarchical check and call it directly from the policy.

Let's define what access means:

A user can access a project if:

  • They are an ORG_ADMIN of the project's parent organization.
  • They are a TEAM_MEMBER of the project's parent team.
  • We can encapsulate this logic in a function.

    sql
    CREATE OR REPLACE FUNCTION can_user_access_project(p_project_id UUID, p_user_id UUID) 
    RETURNS BOOLEAN AS $$
    BEGIN
      RETURN EXISTS (
        SELECT 1
        FROM projects p
        JOIN teams t ON p.team_id = t.id
        JOIN organizations o ON t.organization_id = o.id
        LEFT JOIN memberships m_org ON m_org.organization_id = o.id AND m_org.role = 'ORG_ADMIN'
        LEFT JOIN memberships m_team ON m_team.team_id = t.id AND m_team.role = 'TEAM_MEMBER'
        WHERE p.id = p_project_id 
          AND (m_org.user_id = p_user_id OR m_team.user_id = p_user_id)
      );
    END;
    $$ LANGUAGE plpgsql STABLE;

    A naive RLS policy would then call this function for every row:

    sql
    -- DO NOT USE THIS IN PRODUCTION
    CREATE POLICY select_projects_hierarchical_naive
      ON projects FOR SELECT
      USING (can_user_access_project(id, current_setting('app.user_id')::uuid));

    Why is this a performance disaster?

    The USING clause is evaluated for every single row of the projects table that the query optimizer considers. If you have a million projects, this function will be invoked up to a million times during a full table scan. The repeated JOINs inside the function create a massive performance bottleneck. This is a classic N+1 query problem, but at the database security layer.

    The query plan for a simple SELECT * FROM projects would be catastrophic, showing repeated executions of the function's logic.

    Step 3: The Core Pattern - Pre-calculation and Session Memoization

    The performant solution is to invert the logic. Instead of checking each row against the user's permissions (row -> user), we should first determine all the entities the user can access and then check if the row belongs to that set (user -> row).

    We can calculate this set of accessible entities once per transaction/request and store the result in a session-level variable using PostgreSQL's Grand Unified Configuration (GUC) variables. RLS policies can then perform a simple, fast check against this memoized data.

    The pattern:

    • At the beginning of an application request, call a single SQL function.
  • This function calculates all organization_ids and team_ids the user can access based on their memberships.
  • It stores these IDs in a custom session variable (e.g., app.accessible_team_ids).
    • The RLS policies on all tables become trivial checks against these pre-populated variables.

    Let's build the context-setting function. This is the heart of our system.

    sql
    -- This function should be run with the privileges of a user who can read the membership tables.
    -- SECURITY DEFINER is necessary if the application user role cannot see these tables directly.
    CREATE OR REPLACE FUNCTION setup_session_context(p_user_id UUID)
    RETURNS void AS $$
    DECLARE
      accessible_org_ids_array UUID[];
      accessible_team_ids_array UUID[];
    BEGIN
      -- Use SECURITY DEFINER safely
      -- SET search_path = 'public'; -- Uncomment and set to your schema if needed
    
      -- 1. Get all teams the user is a direct member of.
      WITH direct_teams AS (
        SELECT team_id
        FROM memberships
        WHERE user_id = p_user_id AND role = 'TEAM_MEMBER' AND team_id IS NOT NULL
      ),
      -- 2. Get all teams belonging to organizations where the user is an admin.
      admin_org_teams AS (
        SELECT t.id
        FROM teams t
        JOIN memberships m ON t.organization_id = m.organization_id
        WHERE m.user_id = p_user_id AND m.role = 'ORG_ADMIN'
      ),
      -- 3. Combine them into a single set of team IDs.
      all_accessible_teams AS (
        SELECT team_id FROM direct_teams
        UNION
        SELECT id FROM admin_org_teams
      )
      SELECT array_agg(team_id) INTO accessible_team_ids_array FROM all_accessible_teams;
    
      -- 4. Get all organizations the user is an admin of.
      SELECT array_agg(organization_id) INTO accessible_org_ids_array
      FROM memberships
      WHERE user_id = p_user_id AND role = 'ORG_ADMIN' AND organization_id IS NOT NULL;
    
      -- 5. Set the custom GUCs. Use `true` for the is_local parameter to scope it to the current transaction.
      -- The `|| '{}'` ensures we don't try to set a NULL value, which would error.
      PERFORM set_config('app.accessible_team_ids', array_to_string(accessible_team_ids_array || '{}', ','), true);
      PERFORM set_config('app.accessible_org_ids', array_to_string(accessible_org_ids_array || '{}', ','), true);
      PERFORM set_config('app.user_id', p_user_id::text, true);
    
    END;
    $$ LANGUAGE plpgsql SECURITY DEFINER;

    Key Implementation Details:

    * SECURITY DEFINER: This is critical. The function runs with the privileges of the user who defined it, not the user who invokes it. This allows a low-privilege application user to call this function, which can then read sensitive tables like memberships to calculate permissions. WARNING: This is a security-sensitive feature. You must prevent SQL injection within the function. Since we are only using a UUID input, the risk is low, but it's good practice to set a safe search_path inside the function.

    * set_config(name, value, is_local): This sets a custom GUC. is_local = true is crucial; it scopes the setting to the current transaction. When the transaction commits or rolls back, the setting vanishes, ensuring no data leakage between requests.

    * array_to_string: GUCs can only store strings. We serialize our UUID arrays into a comma-separated string. The policies will deserialize them.

    Step 4: Implementing Performant RLS Policies

    With our context-setting function in place, the RLS policies become incredibly simple and fast.

    First, let's enable RLS on our target tables.

    sql
    ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
    ALTER TABLE teams ENABLE ROW LEVEL SECURITY;
    ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
    
    -- IMPORTANT: By default, no rows are visible. We must create policies.
    ALTER TABLE projects FORCE ROW LEVEL SECURITY;
    ALTER TABLE teams FORCE ROW LEVEL SECURITY;
    ALTER TABLE organizations FORCE ROW LEVEL SECURITY;

    Now, the policies themselves:

    Policy for projects:

    sql
    CREATE POLICY select_projects_optimized
      ON projects FOR SELECT
      USING (
        team_id = ANY(string_to_array(current_setting('app.accessible_team_ids', true), ',')::UUID[])
      );

    Policy for teams:

    sql
    CREATE POLICY select_teams_optimized
      ON teams FOR SELECT
      USING (
        id = ANY(string_to_array(current_setting('app.accessible_team_ids', true), ',')::UUID[])
      );

    Policy for organizations:

    An organization is visible if the user is an admin of it, OR if they are a member of any team within it.

    sql
    CREATE POLICY select_organizations_optimized
      ON organizations FOR SELECT
      USING (
        -- User is a direct admin of the org
        id = ANY(string_to_array(current_setting('app.accessible_org_ids', true), ',')::UUID[])
        OR
        -- User has access to a team within this org (requires a subquery)
        EXISTS (
          SELECT 1 FROM teams t
          WHERE t.organization_id = organizations.id
          AND t.id = ANY(string_to_array(current_setting('app.accessible_team_ids', true), ',')::UUID[])
        )
      );

    Analysis of the Optimized Policies:

    * current_setting(name, true): The true argument gracefully handles cases where the setting might not exist, returning NULL instead of throwing an error.

    * string_to_array(...)::UUID[]: This efficiently converts our comma-separated string back into a UUID array.

    * = ANY(...): This is a highly optimized way to check for membership in an array. PostgreSQL is excellent at handling this operation.

    Now, the query plan for SELECT * FROM projects is beautiful. It will show a simple index scan or bitmap scan on projects, with a filter condition that is constant for the duration of the query. The expensive hierarchical lookup is gone.

    Step 5: Application Integration

    This database-level setup must be driven by the application. For every incoming authenticated API request, the backend must execute setup_session_context before running any business logic queries.

    Example in a Node.js Express application with node-postgres:

    javascript
    // Middleware to set the RLS context for every request
    app.use(async (req, res, next) => {
      if (req.user && req.user.id) {
        // Get a client from the connection pool
        const client = await pool.getClient();
        req.dbClient = client; // Attach client to the request object
    
        try {
          // This is the crucial step. Set the context for the transaction.
          // We wrap everything in a transaction.
          await client.query('BEGIN');
          await client.query('SELECT setup_session_context($1)', [req.user.id]);
          next();
        } catch (err) {
          await client.query('ROLLBACK');
          client.release();
          next(err);
        }
      } else {
        next();
      }
    });
    
    // In your route handler
    app.get('/api/projects', async (req, res, next) => {
      try {
        // The RLS policies are now automatically enforced for this query
        const { rows } = await req.dbClient.query('SELECT id, name FROM projects');
        await req.dbClient.query('COMMIT'); // Commit the transaction
        res.json(rows);
      } catch (err) {
        await req.dbClient.query('ROLLBACK');
        next(err);
      } finally {
        if (req.dbClient) {
          req.dbClient.release(); // Release the client back to the pool
        }
      }
    });

    This integration ensures that for the lifetime of that database connection serving the request, all queries are correctly and performantly scoped to the user's permissions.

    Step 6: Handling Edge Cases and Write Operations

    A robust RLS implementation must also secure write operations (INSERT, UPDATE, DELETE). This is handled by the WITH CHECK clause in policy definitions.

    INSERT Operations:

    A user should only be able to create a project in a team they have access to.

    sql
    CREATE POLICY insert_projects_optimized
      ON projects FOR INSERT
      WITH CHECK (
        team_id = ANY(string_to_array(current_setting('app.accessible_team_ids', true), ',')::UUID[])
      );

    If a user tries to INSERT a project with a team_id not in their app.accessible_team_ids list, the database will raise a new row violates row-level security policy error.

    UPDATE Operations:

    This is more complex. A user must not be able to move a project to a team they don't have access to. The USING clause controls which rows are visible/updatable, and the WITH CHECK clause validates the new state of the row after the update.

    sql
    CREATE POLICY update_projects_optimized
      ON projects FOR UPDATE
      USING (
        -- User must have access to the project's CURRENT team
        team_id = ANY(string_to_array(current_setting('app.accessible_team_ids', true), ',')::UUID[])
      )
      WITH CHECK (
        -- The project's NEW team must also be accessible
        team_id = ANY(string_to_array(current_setting('app.accessible_team_ids', true), ',')::UUID[])
      );

    DELETE Operations:

    The DELETE policy only needs a USING clause, as it just checks if the user has permission to see (and therefore delete) the row.

    sql
    CREATE POLICY delete_projects_optimized
      ON projects FOR DELETE
      USING (
        team_id = ANY(string_to_array(current_setting('app.accessible_team_ids', true), ',')::UUID[])
      );

    Superuser/Admin Bypass:

    For internal tooling or superadmins, you need a way to bypass RLS. By default, table owners and superusers bypass RLS. However, you often have application-level admins who are not Postgres superusers. A common pattern is to grant them a special role and check for it.

    sql
    -- In your setup_session_context function, you can add a bypass check
    PERFORM set_config('app.is_admin', (SELECT is_admin FROM users WHERE id = p_user_id)::text, true);
    
    -- Then, modify your policies
    CREATE POLICY select_projects_optimized
      ON projects FOR SELECT
      USING (
        current_setting('app.is_admin', true) = 'true'
        OR
        team_id = ANY(string_to_array(current_setting('app.accessible_team_ids', true), ',')::UUID[])
      );

    Step 7: Benchmarking the Performance Gain

    Let's quantify the difference. We'll populate our tables with 1 organization, 100 teams, 1,000,000 projects, and a single user who is an admin of the organization.

    Scenario 1: Naive Function-in-Policy Approach

    Query: EXPLAIN ANALYZE SELECT COUNT(*) FROM projects;

    text
    -- Result from the naive approach
    Aggregate  (cost=100018256.42..100018256.43 rows=1 width=8) (actual time=12543.123..12543.124 rows=1 loops=1)
      ->  Seq Scan on projects  (cost=0.00..100015756.42 rows=1000000 width=0) (actual time=0.231..12489.567 rows=1000000 loops=1)
            Filter: can_user_access_project(id, '...'::uuid)
    Planning Time: 0.156 ms
    Execution Time: 12543.201 ms

    Execution Time: ~12.5 seconds. The planner has to call our expensive function for every single row.

    Scenario 2: Optimized Session Memoization Approach

    First, we run SELECT setup_session_context('...');. Then we run the same query.

    Query: EXPLAIN ANALYZE SELECT COUNT(*) FROM projects;

    text
    -- Result from the optimized approach
    Aggregate  (cost=21915.22..21915.23 rows=1 width=8) (actual time=158.432..158.433 rows=1 loops=1)
      ->  Bitmap Heap Scan on projects  (cost=4427.72..20665.22 rows=500000 width=0) (actual time=35.123..135.876 rows=1000000 loops=1)
            Recheck Cond: (team_id = ANY ('{uuid1,uuid2,...}'::uuid[]))
            ->  Bitmap Index Scan on projects_team_id_idx (cost=0.00..4302.72 rows=500000 width=0) (actual time=33.567..33.568 rows=1000000 loops=1)
                  Index Cond: (team_id = ANY ('{uuid1,uuid2,...}'::uuid[]))
    Planning Time: 0.234 ms
    Execution Time: 158.512 ms

    Execution Time: ~158 milliseconds. This is nearly an 80x performance improvement. The query plan shows an efficient Bitmap Index Scan using the pre-calculated array of team_ids. This is the difference between a production-ready system and one that will fall over under minimal load.

    Conclusion

    Implementing hierarchical Row-Level Security in PostgreSQL requires moving beyond basic patterns. By embracing a strategy of one-time permission calculation and session-level memoization, we can build systems that are simultaneously secure, flexible, and highly performant. The key is to shift the complex, graph-traversal logic from a per-row check into a single, upfront operation at the start of a transaction. This setup_session_context pattern, combined with simple, fast RLS policies reading from custom GUCs, provides a robust foundation for any multi-tenant application with complex permissioning needs. While it requires careful implementation and application-level coordination, the resulting performance and security benefits are essential for building scalable, enterprise-grade software.

    Found this article helpful?

    Share it with others who might benefit from it.

    More Articles