PostgreSQL RLS for Dynamic, Hierarchy-Based Multi-Tenancy

18 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 Application-Layer Tenancy

For any multi-tenant SaaS application, data isolation is not a feature; it's a foundational requirement. The most common implementation pattern involves diligently adding a WHERE organization_id = :current_org_id clause to every single database query. While simple to grasp, this approach is dangerously fragile. A single forgotten WHERE clause in a deeply nested repository method or a complex reporting query can lead to a catastrophic data leak, exposing one tenant's data to another. This is a constant source of anxiety in code reviews and a significant vector for security vulnerabilities.

Application-layer enforcement also struggles with complex tenancy models. Simple siloed tenants are one thing, but what about hierarchical structures? Consider a scenario where a parent corporation needs access to the data of its subsidiaries, but the subsidiaries cannot see the parent's or siblings' data. Or what if user roles within a tenancy dictate access to specific data subsets? Implementing this logic consistently across a large codebase is an exercise in futility, destined to become an unmaintainable web of conditional checks.

This is where PostgreSQL's Row-Level Security (RLS) becomes a game-changer. RLS moves the security boundary from the application into the database itself. It allows you to define fine-grained access policies directly on your tables, which PostgreSQL then automatically and transparently applies to every query. It's a non-bypassable enforcement mechanism. In this post, we will go deep into implementing a sophisticated, hierarchy-aware multi-tenancy model using RLS, covering the advanced patterns and performance considerations required for a production system.


Setting the Stage: The Hierarchical Data Model

To explore RLS in a realistic context, we first need a data model that represents hierarchical tenancy. Our model will consist of organizations that can be nested, users who belong to these organizations with specific roles, and a resource table (projects) that is owned by an organization.

Here is the core schema we'll be working with:

sql
-- Enable necessary extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "ltree";

-- Organizations table with self-referencing parent_id for hierarchy
CREATE TABLE organizations (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    parent_organization_id UUID REFERENCES organizations(id),
    name TEXT NOT NULL,
    path LTREE, -- For efficient hierarchy queries
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Create an index for ltree path operations
CREATE INDEX idx_organizations_path ON organizations USING GIST (path);

-- A trigger to automatically maintain the ltree path
CREATE OR REPLACE FUNCTION update_organization_path() RETURNS TRIGGER AS $$
DECLARE
    parent_path LTREE;
BEGIN
    IF NEW.parent_organization_id IS NULL THEN
        NEW.path = to_char(NEW.id, 'FMU9999999999999999999999999999999999999')::ltree;
    ELSE
        SELECT path INTO parent_path FROM organizations WHERE id = NEW.parent_organization_id;
        IF NOT FOUND THEN
            RAISE EXCEPTION 'Invalid parent_organization_id %', NEW.parent_organization_id;
        END IF;
        NEW.path = parent_path || to_char(NEW.id, 'FMU999999999999999999999999999999999')::ltree;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_update_organization_path
BEFORE INSERT OR UPDATE OF parent_organization_id ON organizations
FOR EACH ROW EXECUTE FUNCTION update_organization_path();

-- Users table
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    email TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Junction table for user roles within organizations
CREATE TYPE organization_role AS ENUM ('admin', 'member');

CREATE TABLE organization_memberships (
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    role organization_role NOT NULL DEFAULT 'member',
    PRIMARY KEY (user_id, organization_id)
);

-- The resource table we want to protect
CREATE TABLE projects (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    data JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Critical index for RLS performance
CREATE INDEX idx_projects_organization_id ON projects(organization_id);

We've intentionally included the ltree extension and a trigger to maintain the path column. While a recursive Common Table Expression (CTE) can also query hierarchies, it's often a performance bottleneck in RLS policies that run on every query. The ltree extension provides highly optimized operators for hierarchy path queries, which will be crucial for performance, as we'll see later.

The Core RLS Mechanism: Transaction-Scoped Context

RLS policies need a way to know who is making the request. The most robust and secure way to pass this context from your application to PostgreSQL is via session-level configuration parameters. We'll define our own custom parameters to hold the current user's identity and their active organization context.

Crucially, we must use SET LOCAL within a transaction. In a typical web application using a connection pool, the same database connection will be reused for requests from different users. Using SET (without LOCAL) would cause the setting to persist on the connection, creating a massive security hole where one user's context could leak into another's request. SET LOCAL ensures the setting is automatically discarded at the end of the transaction.

Here’s a production-grade pattern for a Node.js application using the pg library to manage this context safely:

typescript
import { Pool, PoolClient } from 'pg';

// Assume pool is configured and exported elsewhere
const pool = new Pool({
    // ... connection config
});

interface UserContext {
    userId: string;
    organizationId: string;
    userRole: 'admin' | 'member';
}

// This is the key function that wraps all database interactions
async function withDBClient<T>(
    context: UserContext,
    callback: (client: PoolClient) => Promise<T>
): Promise<T> {
    const client = await pool.connect();

    try {
        // Start a transaction
        await client.query('BEGIN');

        // Set the session variables for RLS. Use SET LOCAL!
        // We use pg's parameterized query feature to prevent SQL injection.
        await client.query(`SET LOCAL app.current_user_id = $1`, [context.userId]);
        await client.query(`SET LOCAL app.current_organization_id = $1`, [context.organizationId]);
        await client.query(`SET LOCAL app.current_user_role = $1`, [context.userRole]);

        // Execute the actual business logic
        const result = await callback(client);

        // Commit the transaction
        await client.query('COMMIT');

        return result;
    } catch (error) {
        // Rollback on error
        await client.query('ROLLBACK');
        throw error;
    } finally {
        // Release the client back to the pool
        client.release();
    }
}

// Example usage in a service layer
async function getProjectsForUser(context: UserContext): Promise<any[]> {
    return withDBClient(context, async (client) => {
        // This query does NOT need a `WHERE organization_id = ...` clause.
        // RLS will handle it automatically.
        const res = await client.query('SELECT id, name FROM projects');
        return res.rows;
    });
}

This withDBClient wrapper is the cornerstone of our RLS integration. It guarantees that every query executed within its callback runs inside a transaction with the correct, non-leaking user context.

Implementing Hierarchical and Role-Based Policies

With our context mechanism in place, we can now define the RLS policies. The strategy is to start with a default-deny rule and then layer on specific ALLOW policies.

  • Enable RLS and Set Default-Deny: This is a critical first step. Without a default-deny policy, if no other policy matches, access is implicitly allowed, which is not a secure default.
  • sql
        -- First, force all existing and future roles to be subject to RLS
        ALTER TABLE projects FORCE ROW LEVEL SECURITY;
    
        -- Enable RLS on the table
        ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
    
        -- This is our safety net. If no other policy grants access, this one denies it.
        CREATE POLICY deny_all ON projects
        FOR ALL
        USING (false);
  • Create the Core SELECT Policy: This is the most complex policy. It must grant access if the project's organization_id belongs to the current user's organization or any of its descendants.
  • sql
        CREATE POLICY select_projects ON projects
        FOR SELECT
        USING (
            EXISTS (
                SELECT 1
                FROM organizations
                WHERE 
                    -- The project's organization is a descendant of (or is) the user's current organization
                    organizations.id = projects.organization_id
                    AND organizations.path <@ (
                        SELECT path FROM organizations WHERE id = current_setting('app.current_organization_id')::uuid
                    )
            )
        );

    Here, the ltree operator <@ is doing the heavy lifting. It efficiently checks if one path is an ancestor of another. This single line replaces a costly recursive CTE, making the policy performant enough for production use.

  • Create Policies for INSERT, UPDATE, and DELETE: These policies often have different logic.
  • * INSERT: A user should only be able to insert projects into organizations they are a direct member of. We use the WITH CHECK clause, which enforces the condition on new or updated rows.

    sql
            CREATE POLICY insert_projects ON projects
            FOR INSERT
            WITH CHECK (
                organization_id = current_setting('app.current_organization_id')::uuid
            );

    * UPDATE: A user can update projects they have SELECT access to, but perhaps only if they are a member of that specific project's organization.

    sql
            CREATE POLICY update_projects ON projects
            FOR UPDATE
            USING (
                -- The USING clause for UPDATE determines which rows can be updated.
                -- We can reuse the same logic as SELECT for visibility.
                EXISTS (
                    SELECT 1 FROM organizations
                    WHERE organizations.id = projects.organization_id
                    AND organizations.path <@ (SELECT path FROM organizations WHERE id = current_setting('app.current_organization_id')::uuid)
                )
            )
            WITH CHECK (
                -- The WITH CHECK clause ensures you can't move the project to an org you don't have access to.
                organization_id = current_setting('app.current_organization_id')::uuid
            );

    * DELETE: Deletion is a destructive action, so let's restrict it to users with the admin role. This demonstrates how to incorporate role-based logic.

    sql
            CREATE POLICY delete_projects_for_admins ON projects
            FOR DELETE
            USING (
                -- Check for admin role from our session context
                current_setting('app.current_user_role', true) = 'admin'
                AND EXISTS (
                    SELECT 1 FROM organizations
                    WHERE organizations.id = projects.organization_id
                    AND organizations.path <@ (SELECT path FROM organizations WHERE id = current_setting('app.current_organization_id')::uuid)
                )
            );

    Note the true in current_setting('...', true). This tells PostgreSQL not to error if the setting is missing, which can be useful for flexibility.

    Performance Deep Dive: The Cost of Security

    RLS is not a free lunch. PostgreSQL implements RLS by rewriting your query and appending the policy conditions as a WHERE or WITH CHECK clause. A poorly written policy can devastate performance.

    Let's analyze our SELECT policy's performance. Consider the query SELECT * FROM projects WHERE name = 'Project X';

    Without RLS, the planner might use an index on the name column. With RLS, the query effectively becomes:

    sql
    SELECT * FROM projects 
    WHERE name = 'Project X' AND (
        EXISTS (
            SELECT 1
            FROM organizations
            WHERE organizations.id = projects.organization_id
            AND organizations.path <@ (
                SELECT path FROM organizations WHERE id = '...' -- from current_setting
            )
        )
    );

    The query planner now has a much more complex job. Here’s how to ensure it remains fast:

  • ltree vs. Recursive CTE: As mentioned, our use of ltree is the single most important optimization. A EXPLAIN ANALYZE on a policy using a recursive CTE would likely show a high cost and sequential scan, whereas the ltree version can use the GIST index on the path column for near-instantaneous lookups.
  • Indexing for Policy Conditions: The planner needs to efficiently evaluate organizations.id = projects.organization_id. This means the projects.organization_id column must be indexed. Our schema already includes idx_projects_organization_id, but its importance cannot be overstated.
  • Policy Function Volatility: If you encapsulate logic in a PL/pgSQL function, be mindful of its volatility setting. The default, VOLATILE, forces the function to be re-evaluated for every row. If your function is deterministic based on its inputs (which RLS context functions usually are), declaring it as STABLE or IMMUTABLE can allow the planner to cache its result within a single query, providing a significant performance boost.
  • sql
        CREATE OR REPLACE FUNCTION get_current_org_path() RETURNS ltree AS $$
        BEGIN
            RETURN (SELECT path FROM organizations WHERE id = current_setting('app.current_organization_id')::uuid);
        END;
        $$ LANGUAGE plpgsql STABLE;
        
        -- Updated policy using the STABLE function
        CREATE POLICY select_projects_optimized ON projects
        FOR SELECT
        USING (
            EXISTS (
                SELECT 1
                FROM organizations
                WHERE organizations.id = projects.organization_id
                AND organizations.path <@ get_current_org_path()
            )
        );

    Edge Cases and Super-User Scenarios

    A production system has needs beyond typical user access. Here’s how to handle common edge cases.

    1. The Superuser / DBA

    Database administrators and migration scripts often need to bypass RLS entirely. You can grant this permission to a specific role using the BYPASSRLS attribute.

    sql
    -- Create a role for database administration tasks
    CREATE ROLE db_admin LOGIN PASSWORD '...';
    
    -- Grant the bypass privilege
    ALTER ROLE db_admin BYPASSRLS;

    Any user logged in as db_admin will now ignore all RLS policies. Use this privilege with extreme caution.

    2. Service Accounts and Internal Tools

    Background workers, reporting services, or internal admin panels might need broader access.

    * Pattern 1 (The Sledgehammer): Run the service as a role with BYPASSRLS. This is simple but risky, as a compromise of the service grants unrestricted data access.

    * Pattern 2 (The Impersonator): The service authenticates as a specific user for the duration of a task. For a job processing data for Organization X, it would set its RLS context to that organization. This is much more secure as it adheres to the defined policies.

    * Pattern 3 (The Explicit Grant): Create a dedicated policy for the service role.

    sql
        -- Example for a reporting service that needs read-only access to all projects
        CREATE ROLE reporting_service LOGIN PASSWORD '...';
        
        CREATE POLICY allow_reporting_service ON projects
        FOR SELECT
        USING (current_user = 'reporting_service');

    Because policies are additive (access is granted if any ALLOW policy passes), a user logged in as reporting_service would be granted SELECT access by this policy, even if they fail the tenancy checks of other policies.

    3. Explicit Cross-Tenant Sharing

    This is the hardest problem. What if a user in Org A needs access to a single project in Org B? Our current model doesn't allow this. The solution is to create an explicit sharing table.

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

    Now, we must update our core SELECT policy to account for these explicit shares. This is typically done with an OR condition.

    sql
    -- Drop the old policy first
    DROP POLICY select_projects ON projects;
    
    -- Create the new, more complex policy
    CREATE POLICY select_projects_with_sharing ON projects
    FOR SELECT
    USING (
        -- Condition 1: User has hierarchical access via their organization
        EXISTS (
            SELECT 1
            FROM organizations
            WHERE organizations.id = projects.organization_id
            AND organizations.path <@ (SELECT path FROM organizations WHERE id = current_setting('app.current_organization_id')::uuid)
        )
        OR
        -- Condition 2: The project has been explicitly shared with the current user
        EXISTS (
            SELECT 1
            FROM project_shares
            WHERE project_shares.project_id = projects.id
            AND project_shares.user_id = current_setting('app.current_user_id')::uuid
        )
    );

    Performance Warning: OR conditions can be challenging for the query planner. Ensure that you have indexes on project_shares(project_id) and project_shares(user_id) to give the planner the best chance of executing this complex policy efficiently.

    Conclusion: Robust Security at a Price

    Implementing multi-tenancy with PostgreSQL's Row-Level Security is a significant step up in security and robustness compared to application-layer enforcement. It provides a centralized, non-bypassable, and highly flexible mechanism for enforcing complex data access rules directly at the data layer. By leveraging advanced features like the ltree extension and carefully managing transactional context, you can build a system that securely handles sophisticated hierarchical tenancy models.

    However, this power comes with complexity and a performance cost. RLS is not a simple drop-in solution. It requires a deep understanding of PostgreSQL's execution planner, careful schema design, strategic indexing, and disciplined application-level integration. For applications where data isolation is a paramount security concern, the investment is well worth it, providing a level of assurance that application-layer checks can never truly match.

    Found this article helpful?

    Share it with others who might benefit from it.

    More Articles