Postgres RLS: Advanced Patterns for Hierarchical Multi-Tenancy
The Illusion of Simplicity in Multi-Tenant RLS
For any non-trivial SaaS platform, PostgreSQL's Row-Level Security (RLS) promises a robust, centralized, and non-bypassable authorization layer. The canonical example, often seen in tutorials, is deceptively simple:
-- The 'hello world' of RLS
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY select_project ON projects FOR SELECT
USING (owner_id = auth.uid());
This works perfectly for flat, user-centric data models. But for senior engineers architecting real-world systems, this model shatters the moment you introduce organizational hierarchy. Consider a typical B2B SaaS structure:
* An Organization is the top-level tenant.
* An Organization has multiple Teams.
* A User can be a member of multiple Organizations and Teams with varying roles (e.g., ADMIN, MEMBER, BILLING_MANAGER).
* A Project belongs to a Team.
A user's right to access a Project is no longer a direct link; it's a derived permission based on their membership and role within the project's parent team and organization. A naive RLS policy attempting to resolve this hierarchy with multiple JOINs or EXISTS subqueries inside the USING clause is a direct path to catastrophic performance degradation. Every single query against the table, from a simple SELECT * ... LIMIT 1 to complex aggregations, would incur the massive overhead of re-evaluating these complex permission checks.
This article dissects a production-proven architecture to solve this problem. We will build a system that is not only secure and correctly models hierarchical permissions but is also highly performant and maintainable. The core pattern involves three key components:
SET LOCAL to pass authenticated user context from the application layer into the database session.SECURITY DEFINER Functions: Abstracting complex permission checks into dedicated, optimized PostgreSQL functions.1. The Foundation: A Rich, Transaction-Local Security Context
The fundamental limitation of auth.uid() or current_user is that they carry a single piece of information. Our system needs to know the current user's ID, their active organization, their roles, and potentially other session-specific claims. The solution is to use PostgreSQL's configuration parameters, which can be set on a transaction-local basis.
This prevents context from one request from ever leaking into another, as the settings are automatically discarded on COMMIT or ROLLBACK.
Application-Layer Middleware
Your application's authentication middleware is the gatekeeper. After validating a JWT or session token, its final duty before passing control to the business logic is to establish the security context for the ensuing database transaction. Here is a conceptual example using Node.js and node-postgres (pg).
// Conceptual middleware (e.g., Express.js)
async function setDatabaseContext(req, res, next) {
if (!req.user) { // Assuming previous middleware decodes JWT to req.user
return next();
}
// Assume req.user = { id: 'user-uuid', organizationId: 'org-uuid', roles: ['ADMIN', 'MEMBER'] }
// Also assume a mechanism to get a DB client for the request, e.g., from a pool.
const client = await getClientForRequest();
req.dbClient = client;
try {
// A single transaction ensures all settings are applied or none are.
await client.query('BEGIN');
// Use SET LOCAL to scope the settings to the current transaction.
// The 'app.' prefix is a namespace to avoid conflicts with standard Postgres settings.
await client.query(`SET LOCAL app.user.id = '${req.user.id}';`);
await client.query(`SET LOCAL app.organization.id = '${req.user.organizationId}';`);
// It's crucial to properly escape the roles array to prevent SQL injection.
// A better approach is to use parameterized queries if the driver supports them for SET.
const rolesString = JSON.stringify(req.user.roles).replace(/'/g, "''");
await client.query(`SET LOCAL app.user.roles = '${rolesString}';`);
next(); // Business logic now runs within this transaction with context set.
} catch (err) {
// Ensure transaction is rolled back on failure
await client.query('ROLLBACK');
client.release();
return res.status(500).send('Failed to set security context.');
}
}
// In your route handler, you'd commit or rollback at the end.
app.post('/api/projects', setDatabaseContext, async (req, res) => {
const client = req.dbClient;
try {
// ... your business logic using the client ...
await client.query('COMMIT');
res.status(201).send({ message: 'Success' });
} catch (err) {
await client.query('ROLLBACK');
res.status(500).send({ message: 'An error occurred.' });
} finally {
client.release();
}
});
A Robust Helper for Accessing Context in SQL
To avoid littering your RLS policies with error-prone calls to current_setting, create a helper function. This centralizes access and provides sane defaults.
CREATE OR REPLACE FUNCTION get_current_user_id() RETURNS uuid AS $$
BEGIN
-- The 'true' flag makes it return NULL instead of throwing an error if the setting is not found.
RETURN current_setting('app.user.id', true)::uuid;
EXCEPTION
-- Handle invalid UUID format gracefully.
WHEN invalid_text_representation THEN
RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE;
CREATE OR REPLACE FUNCTION get_current_user_roles() RETURNS text[] AS $$
DECLARE
roles_json text;
BEGIN
roles_json := current_setting('app.user.roles', true);
IF roles_json IS NULL OR roles_json = '' THEN
RETURN ARRAY[]::text[];
END IF;
RETURN ARRAY(SELECT json_array_elements_text(roles_json::json));
EXCEPTION
WHEN others THEN
-- Catch malformed JSON or other errors.
RETURN ARRAY[]::text[];
END;
$$ LANGUAGE plpgsql STABLE;
Now our RLS policies can use clean, readable functions like get_current_user_id() instead of raw current_setting calls.
2. The Hierarchical Data Model
Let's define the schema that our RLS policies will protect. The key is the membership tables that connect users to organizations and teams, crucially including a role for each membership.
-- Core entities
CREATE TABLE organizations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL
);
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
);
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,
is_public boolean DEFAULT false
);
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL UNIQUE
);
-- Membership/Junction tables with roles
CREATE TYPE org_role AS ENUM ('ADMIN', 'MEMBER', 'BILLING');
CREATE TYPE team_role AS ENUM ('MAINTAINER', 'DEVELOPER', 'VIEWER');
CREATE TABLE organization_members (
organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role org_role NOT NULL,
PRIMARY KEY (organization_id, user_id)
);
CREATE TABLE team_members (
team_id uuid NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role team_role NOT NULL,
PRIMARY KEY (team_id, user_id)
);
3. Advanced RLS Policy Implementation
Our goal is to create a policy on the projects table. A user should be able to see a project if:
ADMIN of the organization that owns the project.- They are a member of the team that owns the project.
The Anti-Pattern: The Monstrous `USING` Clause
Let's first examine the naive approach and understand why it fails at scale.
-- DO NOT USE THIS IN PRODUCTION
CREATE POLICY select_project_naive ON projects FOR SELECT
USING (
EXISTS (
-- Check for org-level admin access
SELECT 1
FROM organization_members om
JOIN teams t ON t.organization_id = om.organization_id
WHERE om.user_id = get_current_user_id()
AND om.role = 'ADMIN'
AND t.id = projects.team_id
)
OR
EXISTS (
-- Check for direct team membership
SELECT 1
FROM team_members tm
WHERE tm.user_id = get_current_user_id()
AND tm.team_id = projects.team_id
)
);
Why this is terrible for performance: For every single row that the query planner considers for projects, it must execute these two complex subqueries. If you query SELECT FROM projects WHERE name LIKE 'A%';, Postgres will find all matching projects and then* run the RLS check for each one. This pattern prevents the planner from using indexes effectively and leads to an explosion of nested loops. The query plan will be a horror show of repeated subquery scans.
The Solution: Encapsulation with `SECURITY DEFINER` Functions
We can encapsulate this complex logic into a single function. This cleans up the policy and, more importantly, gives us a central place to optimize the permission check.
CREATE OR REPLACE FUNCTION can_user_view_project(p_user_id uuid, p_project_id uuid)
RETURNS boolean AS $$
DECLARE
v_team_id uuid;
v_organization_id uuid;
is_org_admin boolean;
is_team_member boolean;
BEGIN
-- If user is not logged in, they can't see anything (unless we add public logic later)
IF p_user_id IS NULL THEN
RETURN false;
END IF;
-- Find the project's hierarchy
SELECT t.id, t.organization_id
INTO v_team_id, v_organization_id
FROM projects p
JOIN teams t ON p.team_id = t.id
WHERE p.id = p_project_id;
-- Short-circuit if project doesn't exist
IF NOT FOUND THEN
RETURN false;
END IF;
-- Check 1: Is the user an admin of the parent organization?
SELECT EXISTS (
SELECT 1 FROM organization_members
WHERE user_id = p_user_id
AND organization_id = v_organization_id
AND role = 'ADMIN'
) INTO is_org_admin;
IF is_org_admin THEN
RETURN true;
END IF;
-- Check 2: Is the user a member of the project's team?
SELECT EXISTS (
SELECT 1 FROM team_members
WHERE user_id = p_user_id
AND team_id = v_team_id
) INTO is_team_member;
RETURN is_team_member;
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
CRITICAL SECURITY DEFINER Explanation:
* STABLE: This tells the planner that the function's result is consistent within a single scan and doesn't modify the database. This allows for better optimization.
SECURITY DEFINER: This is the magic key. It means the function executes with the permissions of the user who defined the function (typically a superuser), not the user who invokes* it. This is essential because the calling user, due to RLS, might not have direct SELECT permissions on teams, organization_members, etc. This function temporarily elevates privileges to perform the check, then returns a simple boolean. This is a powerful tool and must be used with care. Always sanitize inputs (e.g., ensure p_user_id is used correctly) to prevent the function from being exploited.
Now, the RLS policy becomes beautifully simple:
-- The production-ready policy
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY select_project ON projects FOR SELECT
USING ( can_user_view_project(get_current_user_id(), id) );
-- Don't forget policies for other operations!
CREATE POLICY insert_project ON projects FOR INSERT
WITH CHECK ( can_user_create_project_in_team(get_current_user_id(), team_id) ); -- (Function not shown for brevity)
4. Performance Deep Dive and Benchmarking
Even with the function, performance is not guaranteed. The queries inside the function must be optimized. Let's assume our membership tables are populated and run an EXPLAIN ANALYZE on a query against projects.
-- Set the context for our test run
BEGIN;
SET LOCAL app.user.id = '...some_user_uuid...';
-- Analyze the query
EXPLAIN ANALYZE SELECT * FROM projects WHERE id = '...some_project_uuid...';
ROLLBACK;
Initial Observation (Without Proper Indexes):
You would likely see a plan where the lookups inside can_user_view_project are performing slow Sequential Scans on organization_members and team_members.
-- Simplified EXPLAIN output snippet
-> Index Scan using projects_pkey on projects
Filter: can_user_view_project(get_current_user_id(), id)
-- Inside the function execution:
-> Seq Scan on organization_members
-> Seq Scan on team_members
Execution time could be 5-10ms per row check, which is unacceptable.
Optimization 1: Correct Indexing
The queries inside our function filter on user_id and organization_id or team_id. This points directly to the need for composite indexes.
-- Indexes are critical for the function's performance.
CREATE INDEX idx_organization_members_user_id ON organization_members(user_id);
CREATE INDEX idx_team_members_user_id ON team_members(user_id);
With these indexes, the EXPLAIN ANALYZE plan will change dramatically. The Sequential Scans will become fast Index Scans. The execution time for the function might drop to < 0.1ms.
Benchmark Comparison:
| Scenario | Query Time (SELECT one project) | Query Time (SELECT 1000 projects) |
|---|---|---|
Naive USING Clause (No Index) | 25.0 ms | > 5000 ms (unusable) |
| Function-based (No Index) | 8.0 ms | 1500 ms |
| Function-based (With Indexes) | 0.5 ms | 45 ms |
These are representative numbers. Actual performance depends on hardware, data size, and Postgres version.
Optimization 2: Handling Public Data with Partial Indexes
What if some projects are public? Our current RLS forces a function call for every row. We can optimize this by splitting the policy.
-- First, allow access to public projects for everyone, even logged-out users.
CREATE POLICY select_public_projects ON projects FOR SELECT
USING ( is_public = true );
-- The existing policy now only needs to run for non-public projects.
-- We can drop and recreate it.
DROP POLICY select_project ON projects;
CREATE POLICY select_member_projects ON projects FOR SELECT
USING ( is_public = false AND can_user_view_project(get_current_user_id(), id) );
Postgres evaluates policies with OR. A user can see a row if any policy's USING clause returns true. This is more efficient. Furthermore, we can create a partial index to speed up queries specifically looking for public projects.
CREATE INDEX idx_projects_public ON projects(id) WHERE is_public = true;
This index is very small and efficient for queries like SELECT * FROM projects WHERE is_public = true;.
5. Edge Cases and Production Hardening
Super-Admin Bypass
Internal support tools or system administrators often need to bypass RLS entirely. The BYPASSRLS attribute is the correct tool for this.
- Create a dedicated role for super-admins.
CREATE ROLE super_admin BYPASSRLS;
- In your application middleware, if the authenticated user is a system-level admin, instead of setting local variables, you can set the role for the transaction.
// In the middleware, for a verified super-admin
await client.query(`SET LOCAL ROLE super_admin;`);
Any subsequent query in that transaction will now completely bypass all RLS policies.
The `WITH CHECK` Clause for Writes
RLS applies to writes (INSERT, UPDATE, DELETE) as well. A WITH CHECK clause ensures that a user cannot create or modify data in a way that would make it invisible to them.
-- Example for UPDATE
CREATE POLICY update_project ON projects FOR UPDATE
USING ( can_user_view_project(get_current_user_id(), id) ) -- Who can even attempt the update
WITH CHECK ( can_user_edit_project(get_current_user_id(), id) ); -- Does the resulting row satisfy edit permissions?
The WITH CHECK option can be performance-intensive, as it may need to re-evaluate the state of the row after the proposed change. Ensure the functions used in WITH CHECK are also highly optimized.
Testing RLS Policies
Testing RLS is notoriously difficult from outside the database. The best approach is to use a database testing framework like pg_prove or pgTAP, or to write test scripts that operate within transactions.
-- A conceptual test case within a single transaction
BEGIN;
-- Set context for a specific user
SET LOCAL app.user.id = 'user_who_is_team_member_uuid';
-- Assert that this user can see the project
-- (Using a testing function that returns true/false or throws error)
SELECT assert_can_select('SELECT * FROM projects WHERE id = \'project_in_their_team_uuid\'');
-- Change context to a different user
SET LOCAL app.user.id = 'user_in_different_org_uuid';
-- Assert that this user CANNOT see the project
SELECT assert_cannot_select('SELECT * FROM projects WHERE id = \'project_in_their_team_uuid\'');
ROLLBACK; -- The test changes nothing in the database.
Conclusion: RLS as a Strategic Choice
Implementing Row-Level Security for hierarchical multi-tenancy is not a trivial undertaking. It requires moving beyond simple policy definitions and architecting a solution that spans the application middleware and the database. The pattern of using transaction-local context, encapsulating logic in SECURITY DEFINER functions, and meticulously optimizing the underlying queries provides a roadmap to a system that is:
* Secure: Authorization logic is centralized in the database and cannot be bypassed by application-level bugs.
* Performant: By avoiding complex logic directly in the USING clause and leveraging proper indexing, the overhead of RLS can be reduced to a negligible level.
* Maintainable: Permission rules are defined in one place (the SQL functions), not scattered across application code. Changing a rule, like introducing a new role, means updating a single function, and the change is instantly reflected everywhere.
This approach represents a significant investment in your data layer, but for complex SaaS applications, it pays substantial dividends in long-term security, scalability, and maintainability.