PostgreSQL RLS for Complex Hierarchical SaaS Authorization

16 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.

Beyond Tenant ID: Architecting Granular Authorization with PostgreSQL RLS

In modern multi-tenant SaaS applications, authorization logic often becomes a sprawling, complex beast residing in the application layer. While effective, this approach is prone to bugs, difficult to audit, and can introduce significant performance overhead as every data access point must be meticulously guarded by application-level checks. PostgreSQL's Row-Level Security (RLS) offers a compelling alternative: shifting authorization logic directly into the database, ensuring that security policies are enforced consistently and transparently, regardless of the client application.

This article is not an introduction to RLS. We assume you're familiar with CREATE POLICY and basic tenant isolation. Instead, we will dissect the implementation of a far more complex, hierarchical authorization model—one that mirrors the reality of many B2B SaaS products—and tackle the advanced performance, testing, and operational challenges that arise in production.

Our goal is to build a robust system where a user's access to a resource (e.g., a task) depends on their membership and role within a nested structure: Organization -> Project -> Task. We will go deep into the weeds of policy design, function performance, and interaction with critical infrastructure like connection poolers.

The Scenario: A Hierarchical Project Management SaaS

Let's define our data model. We're building a system where users belong to organizations. Within an organization, users are assigned to projects with specific roles (admin, editor, viewer).

Here is our core schema:

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

-- Organizations are the top-level tenants
CREATE TABLE organizations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name TEXT NOT NULL
);

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

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

-- Tasks belong to a project
CREATE TABLE tasks (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    title TEXT NOT NULL,
    completed BOOLEAN DEFAULT FALSE
);

-- Membership junction table with roles
CREATE TYPE project_role AS ENUM ('admin', 'editor', 'viewer');

CREATE TABLE project_memberships (
    project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role project_role NOT NULL,
    PRIMARY KEY (project_id, user_id)
);

-- Create indexes for performance
CREATE INDEX ON projects (org_id);
CREATE INDEX ON tasks (project_id);
CREATE INDEX ON project_memberships (user_id);

The authorization requirements are:

  • Users can only see organizations they are a member of (indirectly, via project membership).
  • Users can only see projects they are explicitly members of.
  • Users can see all tasks within projects they are a member of.
  • Only users with the admin or editor role can create, update, or delete tasks.
  • Only users with the admin role can create new projects within an organization.
  • Step 1: Establishing Secure Session Context

    RLS policies rely on session-specific context. The most common pattern is to use SET commands to create runtime parameters. We'll define two settings: app.current_user_id and app.current_org_id.

    It's critically important that these settings can only be set by a trusted role at the beginning of a transaction and are not modifiable by the user's session role.

    javascript
    // Node.js with 'pg' library - Setting context for a request
    const { Pool } = require('pg');
    const pool = new Pool(); // assumes standard env vars
    
    async function executeQueryAsUser(userId, orgId, query, params) {
        const client = await pool.connect();
        try {
            // Start a transaction
            await client.query('BEGIN');
    
            // Set the session context securely. Use LOCAL to scope it to the transaction.
            // This is VITAL for connection pooling (more on this later).
            await client.query(`SET LOCAL app.current_user_id = '${userId}'`);
            await client.query(`SET LOCAL app.current_org_id = '${orgId}'`);
    
            // The application role should not have bypassrls privileges.
            // We assume the connection pool connects as a privileged role initially,
            // then sets the role for the transaction.
            await client.query('SET ROLE my_app_user');
    
            const result = await client.query(query, params);
    
            await client.query('COMMIT');
            return result.rows;
        } catch (e) {
            await client.query('ROLLBACK');
            throw e;
        } finally {
            client.release();
        }
    }

    This pattern ensures that the user context is transaction-scoped and reset upon commit or rollback, preventing context leakage in a connection-pooled environment.

    Step 2: Advanced Policy Implementation with Helper Functions

    Writing complex joins inside every policy is repetitive and error-prone. A much cleaner and more maintainable approach is to encapsulate authorization logic within SECURITY DEFINER functions.

    Warning: SECURITY DEFINER functions execute with the privileges of the user who defined them, not the calling user. This is powerful but dangerous. Always SET search_path TO "$user", public, your_schema at the top of these functions to prevent search path hijacking attacks.

    Let's create a helper function to check a user's role in a given project.

    sql
    CREATE OR REPLACE FUNCTION get_project_role(p_project_id UUID, p_user_id UUID) 
    RETURNS project_role AS $$
    DECLARE
        user_role project_role;
    BEGIN
        -- SECURITY DEFINER functions should have a secure search path
        SET search_path = public;
    
        SELECT role INTO user_role
        FROM project_memberships
        WHERE project_id = p_project_id AND user_id = p_user_id;
    
        RETURN user_role;
    END;
    $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;

    Performance Note: We've marked this function as STABLE, which tells the query planner that it returns the same result for the same arguments within a single scan. This is appropriate here. Using IMMUTABLE would be incorrect, as memberships can change. Using VOLATILE (the default) would be a performance disaster, as it would prevent the planner from optimizing calls and could cause the function to be re-evaluated for every row.

    Now, let's implement our RLS policies.

    sql
    -- First, enable RLS on all relevant tables
    ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
    ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
    ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
    ALTER TABLE project_memberships ENABLE ROW LEVEL SECURITY;
    
    -- Helper function to get the current user ID safely
    CREATE OR REPLACE FUNCTION current_user_id() RETURNS UUID AS $$
    BEGIN
        RETURN current_setting('app.current_user_id', true)::UUID;
    EXCEPTION
        -- Return NULL if the setting is not available, preventing query failure
        WHEN OTHERS THEN
            RETURN NULL;
    END;
    $$ LANGUAGE plpgsql STABLE;
    
    -- Policy for `tasks`
    CREATE POLICY select_tasks ON tasks
    FOR SELECT USING (
        -- A user can select a task if they are a member of the project it belongs to.
        EXISTS (
            SELECT 1 FROM project_memberships
            WHERE project_id = tasks.project_id AND user_id = current_user_id()
        )
    );
    
    CREATE POLICY modify_tasks ON tasks
    FOR ALL -- Covers INSERT, UPDATE, DELETE
    USING (
        -- The USING clause applies to existing rows for UPDATE/DELETE
        EXISTS (
            SELECT 1 FROM project_memberships
            WHERE project_id = tasks.project_id AND user_id = current_user_id()
        )
    )
    WITH CHECK (
        -- The WITH CHECK clause applies to new/updated rows for INSERT/UPDATE
        get_project_role(tasks.project_id, current_user_id()) IN ('admin', 'editor')
    );
    
    -- Policy for `projects`
    CREATE POLICY select_projects ON projects
    FOR SELECT USING (
        -- A user can select a project if they are a member of it.
        EXISTS (
            SELECT 1 FROM project_memberships
            WHERE project_id = projects.id AND user_id = current_user_id()
        )
    );
    
    CREATE POLICY insert_projects ON projects
    FOR INSERT WITH CHECK (
        -- To insert a project, the user must be an 'admin' of the *organization*.
        -- This is a more complex check we'll need another helper for.
        is_org_admin(projects.org_id, current_user_id())
    );
    
    -- Let's define `is_org_admin`. For simplicity, we'll say an org admin
    -- is anyone who is an admin on *any* project within that org.
    -- A real-world scenario might have an explicit `organization_memberships` table.
    CREATE OR REPLACE FUNCTION is_org_admin(p_org_id UUID, p_user_id UUID)
    RETURNS BOOLEAN AS $$
    BEGIN
        SET search_path = public;
        RETURN EXISTS (
            SELECT 1
            FROM project_memberships pm
            JOIN projects p ON pm.project_id = p.id
            WHERE p.org_id = p_org_id
              AND pm.user_id = p_user_id
              AND pm.role = 'admin'
        );
    END;
    $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
    
    -- Policy for `organizations`
    CREATE POLICY select_organizations ON organizations
    FOR SELECT USING (
        -- A user can see an org if they are a member of any project within it.
        EXISTS (
            SELECT 1
            FROM projects p
            JOIN project_memberships pm ON p.id = pm.project_id
            WHERE p.org_id = organizations.id AND pm.user_id = current_user_id()
        )
    );

    This setup provides granular, hierarchical control. The logic is co-located with the data, and the application code can now perform simple SELECT * FROM tasks queries, confident that only authorized data will be returned.

    Performance Deep Dive: The Hidden Cost of RLS

    RLS is not free. Every query against an RLS-enabled table has its WHERE clause implicitly modified by the policy. This can have profound effects on the query planner.

    Consider a query: SELECT * FROM tasks WHERE completed = false;

    With RLS enabled, the planner effectively sees:

    sql
    SELECT * FROM tasks
    WHERE completed = false
    AND (
        EXISTS (
            SELECT 1 FROM project_memberships
            WHERE project_id = tasks.project_id AND user_id = '...' -- from current_user_id()
        )
    );

    This subquery will be executed for every potential row scan. If your indexes are not well-designed, this can lead to catastrophic performance degradation.

    Benchmarking RLS Overhead

    Let's analyze the plan for a query to fetch tasks.

    Without RLS:

    EXPLAIN ANALYZE SELECT * FROM tasks WHERE project_id = 'some-project-uuid';

    text
    Index Scan using tasks_project_id_idx on tasks (cost=0.42..8.44 rows=1 width=53) (actual time=0.015..0.016 rows=1 loops=1)
      Index Cond: (project_id = 'some-project-uuid'::uuid)
    Planning Time: 0.086 ms
    Execution Time: 0.033 ms

    With RLS:

    -- (Assume context is set for a user who is a member of 'some-project-uuid')

    EXPLAIN ANALYZE SELECT * FROM tasks WHERE project_id = 'some-project-uuid';

    text
    Index Scan using tasks_project_id_idx on tasks (cost=0.42..16.48 rows=1 width=53) (actual time=0.045..0.046 rows=1 loops=1)
      Index Cond: (project_id = 'some-project-uuid'::uuid)
      Filter: (EXISTS (SELECT 1 FROM project_memberships WHERE ((project_id = tasks.project_id) AND (user_id = current_user_id()))))
      SubPlan 1
        ->  Index Only Scan using project_memberships_pkey on project_memberships (cost=0.42..8.44 rows=1 width=0) (actual time=0.015..0.015 rows=1 loops=1)
              Index Cond: (project_id = tasks.project_id) AND (user_id = 'user-uuid-from-context'::uuid)
              Heap Fetches: 0
    Planning Time: 0.215 ms
    Execution Time: 0.089 ms

    In this simple case, the overhead is minimal because our RLS check is also highly selective and well-indexed. However, imagine a policy that requires a multi-table join. The planner might struggle to find an optimal join order, especially if the policy's logic is opaque (hidden inside a PL/pgSQL function).

    Optimization Strategy: Caching Permissions in Session Context

    For very high-throughput systems, repeatedly checking memberships can become a bottleneck. An advanced pattern is to pre-calculate a user's permissions at the beginning of a session and store them in a custom GUC (Grand Unified Configuration) variable.

    For example, we can store a JSONB array of project IDs the user has access to.

    javascript
    // In the application, at the start of a request
    const client = await pool.connect();
    // ... begin transaction ...
    
    // 1. Fetch user's accessible projects
    const res = await client.query(`
        SELECT json_agg(project_id) as projects
        FROM project_memberships
        WHERE user_id = $1
    `, [userId]);
    const accessibleProjects = res.rows[0].projects || [];
    
    // 2. Set this array in the session context
    await client.query(`SET LOCAL app.accessible_projects = '${JSON.stringify(accessibleProjects)}'`);
    await client.query(`SET LOCAL app.current_user_id = '${userId}'`);
    // ... set role, execute business logic ...

    Now, the RLS policy can be simplified and made much faster:

    sql
    -- Helper to parse the JSONB array from the setting
    CREATE OR REPLACE FUNCTION accessible_projects() RETURNS JSONB AS $$
    BEGIN
        RETURN current_setting('app.accessible_projects', true)::JSONB;
    EXCEPTION
        WHEN OTHERS THEN
            RETURN '[]'::JSONB;
    END;
    $$ LANGUAGE plpgsql STABLE;
    
    -- New, faster policy for `tasks`
    DROP POLICY select_tasks ON tasks;
    CREATE POLICY select_tasks ON tasks
    FOR SELECT USING (
        tasks.project_id::text IN (SELECT value FROM jsonb_array_elements_text(accessible_projects()))
    );

    This approach trades one complex query at the start of the request for much simpler, faster checks on every subsequent query. The jsonb_array_elements_text is less intuitive than an EXISTS subquery, but it avoids repeated joins, which can be a huge win.

    Edge Case: RLS vs. Connection Poolers (PgBouncer)

    This is the single most common pitfall for production RLS deployments. If you use PgBouncer in transaction pooling mode, your SET commands will not work as expected. A connection is returned to the pool after each transaction, and the next transaction for a different user might receive that same connection, inheriting the previous user's app.current_user_id! This is a catastrophic security failure.

    The Solution:

  • Use SET LOCAL: As shown in our Node.js example, SET LOCAL scopes the setting to the current transaction only. When the transaction ends (COMMIT/ROLLBACK), the setting is automatically reverted. This is the bare minimum requirement.
  • Use PgBouncer in Session Pooling Mode: This is the safest option. Each client connection gets a dedicated backend connection until it disconnects. This respects session-level parameters but is less efficient.
  • Application-Level Reset: If you must use transaction pooling, your application must be disciplined about resetting state. A common pattern is to issue a DISCARD ALL or RESET ALL command just before returning a connection to the pool, but this can be fragile. The SET LOCAL approach is strongly preferred.
  • Database-Level Reset Trigger: A more robust, if heavy-handed, approach is to create a trigger on connect (not a standard feature, often requires extensions or creative use of login hooks) to reset custom GUCs. This is complex and generally overkill if SET LOCAL is used correctly.
  • Edge Case: Testing RLS Policies

    Testing RLS is notoriously difficult because the policies are invisible to standard ORMs and query builders. You cannot simply insert data and expect it to be there in the next SELECT if your test runner's role doesn't satisfy the policy.

    A Robust Testing Pattern:

    Use a transactional testing approach where each test impersonates the desired user role.

    sql
    -- test_setup.sql
    
    -- Create a superuser role for the test runner
    CREATE ROLE test_runner LOGIN SUPERUSER PASSWORD '...';
    
    -- Create application roles
    CREATE ROLE my_app_user NOLOGIN;
    -- Grant basic CRUD permissions to the application role
    GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO my_app_user;
    
    -- Create a user for testing
    INSERT INTO users (id, email) VALUES ('a1a1a1a1-...', '[email protected]');
    INSERT INTO users (id, email) VALUES ('b2b2b2b2-...', '[email protected]');
    javascript
    // In your test suite (e.g., using Jest and node-pg)
    
    describe('Task RLS Policies', () => {
        let client;
    
        beforeAll(async () => {
            // Connect as the privileged test_runner
            client = await connectAsTestRunner();
        });
    
        beforeEach(async () => {
            // All tests run inside a transaction that will be rolled back
            await client.query('BEGIN');
            // Seed data as the superuser
            // ... create org, project, tasks, and memberships for admin and viewer
        });
    
        afterEach(async () => {
            await client.query('ROLLBACK');
        });
    
        it('should allow an editor to update a task', async () => {
            // Set the context for the 'editor' user
            await client.query(`SET LOCAL app.current_user_id = '...'`); // editor's UUID
            await client.query('SET ROLE my_app_user');
    
            // This UPDATE should succeed
            const promise = client.query(`UPDATE tasks SET title = 'New Title' WHERE id = '...'`);
            await expect(promise).toResolve();
    
            // Reset role to superuser to verify the change
            await client.query('RESET ROLE');
            const { rows } = await client.query('SELECT title FROM tasks WHERE id = ...');
            expect(rows[0].title).toBe('New Title');
        });
    
        it('should PREVENT a viewer from updating a task', async () => {
            // Set the context for the 'viewer' user
            await client.query(`SET LOCAL app.current_user_id = '...'`); // viewer's UUID
            await client.query('SET ROLE my_app_user');
    
            // This UPDATE should fail due to the RLS WITH CHECK clause
            const promise = client.query(`UPDATE tasks SET title = 'New Title' WHERE id = '...'`);
            await expect(promise).rejects.toThrow('new row violates row-level security policy for table "tasks"');
        });
    });

    This pattern provides perfect isolation:

    * Tests run in parallel without interfering.

    * Data is seeded by a superuser that bypasses RLS.

    * Each test explicitly sets the user context and role to simulate an application request.

    * The transaction rollback ensures a clean slate for the next test.

    Conclusion: RLS as a Foundational Security Layer

    Implementing a hierarchical authorization model with PostgreSQL RLS is a powerful technique for centralizing and enforcing security logic at the data layer. It moves your security posture from a distributed, easy-to-miss application concern to a foundational, non-bypassable database guarantee.

    However, this power demands expertise. Senior engineers adopting RLS must move beyond simple policy creation and master its interaction with the query planner, understand the nuances of function volatility, design leak-proof session context management for connection poolers, and build sophisticated, role-aware testing frameworks.

    By treating RLS as a core architectural component and not an afterthought, you can build highly secure, auditable, and surprisingly performant multi-tenant systems where the database itself becomes the ultimate guardian of your data.

    Found this article helpful?

    Share it with others who might benefit from it.

    More Articles