Row-Level Security (RLS) Policy Patterns
Version: 1.0 Last Updated: 2025-10-25 Applies To: All versions (MVP.0+)
Table of Contents
- Overview
- Multi-Tenancy Isolation Pattern
- Common RLS Patterns
- Implementation Examples
- Testing RLS Policies
- Performance Considerations
- Troubleshooting
Overview
What is Row-Level Security (RLS)?
Row-Level Security (RLS) is a PostgreSQL feature that allows database administrators to define policies that control which rows individual users or applications can access in queries. RLS policies are enforced at the database level, providing defense-in-depth security.
Key Characteristics:
- Policies are automatically applied to all queries (SELECT, INSERT, UPDATE, DELETE)
- Enforced regardless of application code
- Cannot be bypassed by application logic errors
- Evaluated for each row in the result set
Why Use RLS?
Security Benefits:
- Defense in Depth: Database-level enforcement protects against application bugs
- Data Isolation: Prevents cross-tenant data leaks in multi-tenant systems
- Audit Compliance: Provides verifiable data access controls
- Simplified Code: Reduces need for repetitive WHERE clauses in application code
Use Cases:
- Multi-tenant SaaS applications (org/account isolation)
- Role-based access control (RBAC)
- Privacy-sensitive data (user-owned records)
- Time-based access restrictions (embargo, expiration)
- Hierarchical data access (parent-child relationships)
When NOT to Use RLS
Avoid RLS for:
- High-throughput tables with complex policies (performance overhead)
- Tables with no sensitive data requiring isolation
- Read-only reference data (e.g., lookup tables, enums)
- Background jobs requiring full table access (use bypasses carefully)
Multi-Tenancy Isolation Pattern
Architecture Overview
TVL uses a 3-tier multi-tenancy model requiring strict isolation:
Organization (org_id) → Account (account_id) → User (user_id)
        ↓                       ↓                      ↓
    Tenant Boundary      Sub-tenant/Actor        Person Identity
RLS Policy Goals:
- Org Isolation: Prevent cross-tenant data leaks (highest priority)
- Account Access: Control within-tenant access based on membership
- User Context: Apply per-user permissions (role-based)
Org-Level Isolation Policy
Purpose: Block all cross-tenant access at the database level.
Pattern:
-- Enable RLS on the table
ALTER TABLE spaces ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only see rows in their current org
CREATE POLICY org_isolation ON spaces
  USING (org_id = current_setting('app.current_org_id')::uuid);
How It Works:
- Application sets app.current_org_idsession variable before queries
- PostgreSQL evaluates policy for each row
- Rows not matching org_idare filtered out automatically
- Policy applies to SELECT, UPDATE, DELETE (not INSERT)
Complete Example:
-- Table with org_id
CREATE TABLE spaces (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id UUID NOT NULL REFERENCES organizations(id),
  account_id UUID NOT NULL REFERENCES accounts(id),
  name VARCHAR(255) NOT NULL,
  status VARCHAR(50) DEFAULT 'active',
  created_at TIMESTAMPTZ DEFAULT now()
);
-- Enable RLS
ALTER TABLE spaces ENABLE ROW LEVEL SECURITY;
-- Org isolation policy (applies to all operations)
CREATE POLICY org_isolation ON spaces
  USING (org_id = current_setting('app.current_org_id')::uuid);
-- Insert policy (require org_id match)
CREATE POLICY org_insert ON spaces
  FOR INSERT
  WITH CHECK (org_id = current_setting('app.current_org_id')::uuid);
Application Integration:
// Set session context before queries
async function setOrgContext(client, org_id) {
  await client.query(
    `SET LOCAL app.current_org_id = $1`,
    [org_id]
  );
}
// Usage in transaction
async function getSpaces(org_id) {
  return db.transaction(async (client) => {
    await setOrgContext(client, org_id);
    // RLS automatically filters to current org
    const result = await client.query('SELECT * FROM spaces');
    return result.rows;
  });
}
Account-Level Isolation Policy
Purpose: Control access within an organization based on account membership.
Pattern:
-- Policy: Users see rows for their account OR org-wide access
CREATE POLICY account_access ON spaces
  USING (
    -- Exact account match
    account_id = current_setting('app.current_account_id')::uuid
    OR
    -- Org-wide membership (account_id IS NULL)
    EXISTS (
      SELECT 1 FROM memberships m
      WHERE m.user_id = current_setting('app.current_user_id')::uuid
        AND m.org_id = spaces.org_id
        AND m.account_id IS NULL  -- Org-wide flag
        AND m.status = 'active'
    )
  );
Complete Multi-Tier Example:
-- Enable RLS
ALTER TABLE spaces ENABLE ROW LEVEL SECURITY;
-- Policy 1: Org isolation (mandatory)
CREATE POLICY org_isolation ON spaces
  USING (org_id = current_setting('app.current_org_id')::uuid);
-- Policy 2: Account access (within org)
CREATE POLICY account_access ON spaces
  USING (
    account_id = current_setting('app.current_account_id')::uuid
    OR EXISTS (
      SELECT 1 FROM memberships m
      WHERE m.user_id = current_setting('app.current_user_id')::uuid
        AND m.org_id = spaces.org_id
        AND m.account_id IS NULL
        AND m.status = 'active'
    )
  );
-- Policy 3: Insert requires account match
CREATE POLICY account_insert ON spaces
  FOR INSERT
  WITH CHECK (
    org_id = current_setting('app.current_org_id')::uuid
    AND account_id = current_setting('app.current_account_id')::uuid
  );
-- Policy 4: Update requires ownership
CREATE POLICY account_update ON spaces
  FOR UPDATE
  USING (
    org_id = current_setting('app.current_org_id')::uuid
    AND (
      account_id = current_setting('app.current_account_id')::uuid
      OR EXISTS (
        SELECT 1 FROM memberships m
        WHERE m.user_id = current_setting('app.current_user_id')::uuid
          AND m.org_id = spaces.org_id
          AND m.account_id IS NULL
          AND m.status = 'active'
      )
    )
  );
Application Context Setup:
async function setSessionContext(client, session) {
  const { org_id, account_id, user_id } = session;
  await client.query(
    `SET LOCAL app.current_org_id = $1;
     SET LOCAL app.current_account_id = $2;
     SET LOCAL app.current_user_id = $3;`,
    [org_id, account_id, user_id]
  );
}
// Middleware to inject context
async function withRLSContext(req, res, next) {
  req.dbContext = async (callback) => {
    return db.transaction(async (client) => {
      await setSessionContext(client, req.session);
      return callback(client);
    });
  };
  next();
}
// Usage in route handler
app.get('/api/spaces', async (req, res) => {
  const spaces = await req.dbContext(async (client) => {
    const result = await client.query('SELECT * FROM spaces');
    return result.rows;
  });
  res.json(spaces);
});
Common RLS Patterns
Pattern 1: Owner-Only Access
Use Case: User-owned resources (profiles, preferences, private notes)
CREATE TABLE user_profiles (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id),
  display_name VARCHAR(255),
  bio TEXT,
  avatar_url TEXT
);
ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY;
-- Users can only see/edit their own profile
CREATE POLICY owner_only ON user_profiles
  USING (user_id = current_setting('app.current_user_id')::uuid)
  WITH CHECK (user_id = current_setting('app.current_user_id')::uuid);
With Org Context:
-- Owner-only within org boundary
CREATE POLICY owner_only_in_org ON user_settings
  USING (
    org_id = current_setting('app.current_org_id')::uuid
    AND user_id = current_setting('app.current_user_id')::uuid
  );
Pattern 2: Team Member Access
Use Case: Shared team resources, collaborative documents
CREATE TABLE team_documents (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id UUID NOT NULL REFERENCES organizations(id),
  team_id UUID NOT NULL REFERENCES teams(id),
  title VARCHAR(255),
  content TEXT,
  created_by UUID REFERENCES users(id)
);
ALTER TABLE team_documents ENABLE ROW LEVEL SECURITY;
-- Access for team members
CREATE POLICY team_member_access ON team_documents
  USING (
    org_id = current_setting('app.current_org_id')::uuid
    AND EXISTS (
      SELECT 1 FROM team_memberships tm
      WHERE tm.team_id = team_documents.team_id
        AND tm.user_id = current_setting('app.current_user_id')::uuid
        AND tm.status = 'active'
    )
  );
Pattern 3: Public Read, Authenticated Write
Use Case: Public listings, published content with authenticated editing
CREATE TABLE property_listings (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id UUID NOT NULL REFERENCES organizations(id),
  account_id UUID NOT NULL REFERENCES accounts(id),
  title VARCHAR(255),
  description TEXT,
  is_published BOOLEAN DEFAULT false,
  published_at TIMESTAMPTZ
);
ALTER TABLE property_listings ENABLE ROW LEVEL SECURITY;
-- Policy 1: Anyone can read published listings
CREATE POLICY public_read ON property_listings
  FOR SELECT
  USING (is_published = true);
-- Policy 2: Owners can read all their listings
CREATE POLICY owner_read_all ON property_listings
  FOR SELECT
  USING (
    org_id = current_setting('app.current_org_id')::uuid
    AND account_id = current_setting('app.current_account_id')::uuid
  );
-- Policy 3: Only owners can insert
CREATE POLICY owner_insert ON property_listings
  FOR INSERT
  WITH CHECK (
    org_id = current_setting('app.current_org_id')::uuid
    AND account_id = current_setting('app.current_account_id')::uuid
  );
-- Policy 4: Only owners can update
CREATE POLICY owner_update ON property_listings
  FOR UPDATE
  USING (
    org_id = current_setting('app.current_org_id')::uuid
    AND account_id = current_setting('app.current_account_id')::uuid
  );
Pattern 4: Time-Based Access
Use Case: Embargoed content, scheduled releases, expired subscriptions
CREATE TABLE subscription_content (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id UUID NOT NULL REFERENCES organizations(id),
  title VARCHAR(255),
  content TEXT,
  available_from TIMESTAMPTZ NOT NULL,
  available_until TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE subscription_content ENABLE ROW LEVEL SECURITY;
-- Policy: Content visible only within time window
CREATE POLICY time_based_access ON subscription_content
  USING (
    org_id = current_setting('app.current_org_id')::uuid
    AND available_from <= now()
    AND (available_until IS NULL OR available_until > now())
  );
Pattern 5: Hierarchical Access
Use Case: Manager access to subordinate data, parent-child relationships
CREATE TABLE employee_records (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id UUID NOT NULL REFERENCES organizations(id),
  employee_user_id UUID NOT NULL REFERENCES users(id),
  manager_user_id UUID REFERENCES users(id),
  title VARCHAR(255),
  salary_info JSONB
);
ALTER TABLE employee_records ENABLE ROW LEVEL SECURITY;
-- Policy: Employees see own records + manager sees team
CREATE POLICY hierarchical_access ON employee_records
  USING (
    org_id = current_setting('app.current_org_id')::uuid
    AND (
      -- Own record
      employee_user_id = current_setting('app.current_user_id')::uuid
      OR
      -- Manager's direct reports
      manager_user_id = current_setting('app.current_user_id')::uuid
    )
  );
Pattern 6: Role-Based Access
Use Case: Admin overrides, role-specific visibility
CREATE TABLE sensitive_logs (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id UUID NOT NULL REFERENCES organizations(id),
  log_level VARCHAR(50),
  message TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE sensitive_logs ENABLE ROW LEVEL SECURITY;
-- Policy: Admins see all, users see only warnings/errors
CREATE POLICY role_based_logs ON sensitive_logs
  USING (
    org_id = current_setting('app.current_org_id')::uuid
    AND (
      -- Admins see everything
      EXISTS (
        SELECT 1 FROM memberships m
        JOIN roles r ON r.id = m.role_id
        WHERE m.user_id = current_setting('app.current_user_id')::uuid
          AND m.org_id = sensitive_logs.org_id
          AND r.name = 'admin'
          AND m.status = 'active'
      )
      OR
      -- Non-admins see only warnings/errors
      log_level IN ('warning', 'error', 'critical')
    )
  );
Implementation Examples
PostgreSQL CREATE POLICY Syntax
Basic Syntax:
CREATE POLICY policy_name ON table_name
  [ FOR { ALL | SELECT | INSERT | UPDATE | DELETE } ]
  [ TO { role_name | PUBLIC } ]
  [ USING ( using_expression ) ]
  [ WITH CHECK ( check_expression ) ];
Key Components:
- 
FOR clause: Operation type - ALL(default): All operations
- SELECT: Read-only policy
- INSERT: Insert policy (uses WITH CHECK only)
- UPDATE: Update policy (uses both USING and WITH CHECK)
- DELETE: Delete policy (uses USING only)
 
- 
USING clause: Filter which rows are visible - Applied to SELECT, UPDATE, DELETE
- Boolean expression evaluated per row
- Return true = row visible, false = filtered out
 
- 
WITH CHECK clause: Validate new/updated rows - Applied to INSERT, UPDATE
- Boolean expression evaluated for new values
- Return true = allowed, false = rejected with error
 
Examples:
-- SELECT only
CREATE POLICY select_published ON articles
  FOR SELECT
  USING (published = true);
-- INSERT only (no USING needed)
CREATE POLICY insert_own_org ON articles
  FOR INSERT
  WITH CHECK (org_id = current_setting('app.current_org_id')::uuid);
-- UPDATE (both USING and WITH CHECK)
CREATE POLICY update_own_articles ON articles
  FOR UPDATE
  USING (author_id = current_setting('app.current_user_id')::uuid)
  WITH CHECK (author_id = current_setting('app.current_user_id')::uuid);
-- DELETE only
CREATE POLICY delete_own_comments ON comments
  FOR DELETE
  USING (user_id = current_setting('app.current_user_id')::uuid);
Setting Session Variables (SET LOCAL)
Session Variables:
- Stored per-connection or per-transaction
- Accessed via current_setting('var_name')
- Must be cast to appropriate type (::uuid, ::integer, etc.)
SET LOCAL vs SET:
-- SET: Persists for entire connection
SET app.current_org_id = '123e4567-e89b-12d3-a456-426614174000';
-- SET LOCAL: Reverts after transaction ends (SAFER)
SET LOCAL app.current_org_id = '123e4567-e89b-12d3-a456-426614174000';
Best Practice: Always use SET LOCAL in transactions
async function executeWithContext(session, callback) {
  return db.transaction(async (client) => {
    // Set context variables (transaction-scoped)
    await client.query(
      `SET LOCAL app.current_org_id = $1;
       SET LOCAL app.current_account_id = $2;
       SET LOCAL app.current_user_id = $3;`,
      [session.org_id, session.account_id, session.user_id]
    );
    // Execute queries with RLS enforced
    return callback(client);
  });
  // Context variables automatically cleared after transaction
}
Application Middleware Integration
Express.js Middleware:
const { Pool } = require('pg');
const pool = new Pool(/* config */);
// Middleware to inject RLS context
function withRLSContext(req, res, next) {
  // Extract session data (from JWT, session store, etc.)
  const { org_id, account_id, user_id } = req.user || {};
  if (!org_id || !user_id) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  // Provide helper method for RLS-aware queries
  req.dbQuery = async (query, params = []) => {
    const client = await pool.connect();
    try {
      await client.query('BEGIN');
      // Set RLS context
      await client.query(
        `SET LOCAL app.current_org_id = $1;
         SET LOCAL app.current_account_id = $2;
         SET LOCAL app.current_user_id = $3;`,
        [org_id, account_id, user_id]
      );
      // Execute query
      const result = await client.query(query, params);
      await client.query('COMMIT');
      return result;
    } catch (err) {
      await client.query('ROLLBACK');
      throw err;
    } finally {
      client.release();
    }
  };
  next();
}
// Apply middleware
app.use(withRLSContext);
// Route handler (RLS automatically applied)
app.get('/api/spaces', async (req, res) => {
  const result = await req.dbQuery('SELECT * FROM spaces WHERE status = $1', ['active']);
  res.json(result.rows);
});
NestJS Injectable Service:
import { Injectable } from '@nestjs/common';
import { Pool, PoolClient } from 'pg';
interface SessionContext {
  org_id: string;
  account_id: string;
  user_id: string;
}
@Injectable()
export class DatabaseService {
  constructor(private pool: Pool) {}
  async withRLS<T>(
    context: SessionContext,
    callback: (client: PoolClient) => Promise<T>
  ): Promise<T> {
    const client = await this.pool.connect();
    try {
      await client.query('BEGIN');
      // Set RLS context
      await client.query(
        `SET LOCAL app.current_org_id = $1;
         SET LOCAL app.current_account_id = $2;
         SET LOCAL app.current_user_id = $3;`,
        [context.org_id, context.account_id, context.user_id]
      );
      const result = await callback(client);
      await client.query('COMMIT');
      return result;
    } catch (error) {
      await client.query('ROLLBACK');
      throw error;
    } finally {
      client.release();
    }
  }
}
// Usage
@Injectable()
export class SpacesService {
  constructor(private db: DatabaseService) {}
  async getSpaces(context: SessionContext): Promise<Space[]> {
    return this.db.withRLS(context, async (client) => {
      const result = await client.query('SELECT * FROM spaces');
      return result.rows;
    });
  }
}
Testing RLS Policies
Unit Tests for Policies
Test Structure:
- Create test data across multiple orgs/accounts
- Set session context for different users
- Verify data visibility/invisibility
- Assert correct error handling
Example Test Suite (Jest + pg):
const { Pool } = require('pg');
const pool = new Pool(/* test database config */);
describe('RLS Policies - Spaces Table', () => {
  let org1, org2, account1, account2, user1, user2;
  beforeAll(async () => {
    // Setup test data
    org1 = await createOrg('Org 1');
    org2 = await createOrg('Org 2');
    account1 = await createAccount(org1.id, 'Account 1');
    account2 = await createAccount(org2.id, 'Account 2');
    user1 = await createUser('user1@test.com');
    user2 = await createUser('user2@test.com');
    await createMembership(user1.id, org1.id, account1.id);
    await createMembership(user2.id, org2.id, account2.id);
  });
  afterAll(async () => {
    await cleanupTestData();
    await pool.end();
  });
  test('org isolation: user cannot see other org spaces', async () => {
    // Create space in org1
    const space1 = await createSpace(org1.id, account1.id, 'Villa A');
    // Query as user2 (org2 context)
    const client = await pool.connect();
    try {
      await client.query('BEGIN');
      await client.query(`SET LOCAL app.current_org_id = $1`, [org2.id]);
      await client.query(`SET LOCAL app.current_account_id = $1`, [account2.id]);
      await client.query(`SET LOCAL app.current_user_id = $1`, [user2.id]);
      const result = await client.query('SELECT * FROM spaces WHERE id = $1', [space1.id]);
      // Should return 0 rows (RLS filtered)
      expect(result.rows.length).toBe(0);
      await client.query('COMMIT');
    } finally {
      client.release();
    }
  });
  test('account isolation: user sees only their account spaces', async () => {
    const space1 = await createSpace(org1.id, account1.id, 'Villa A');
    const account3 = await createAccount(org1.id, 'Account 3');
    const space2 = await createSpace(org1.id, account3.id, 'Villa B');
    const client = await pool.connect();
    try {
      await client.query('BEGIN');
      await client.query(`SET LOCAL app.current_org_id = $1`, [org1.id]);
      await client.query(`SET LOCAL app.current_account_id = $1`, [account1.id]);
      await client.query(`SET LOCAL app.current_user_id = $1`, [user1.id]);
      const result = await client.query('SELECT * FROM spaces');
      // Should only see space1 (same account)
      expect(result.rows.length).toBe(1);
      expect(result.rows[0].id).toBe(space1.id);
      await client.query('COMMIT');
    } finally {
      client.release();
    }
  });
  test('org-wide access: admin sees all accounts', async () => {
    const adminUser = await createUser('admin@test.com');
    await createMembership(adminUser.id, org1.id, null); // account_id = NULL
    const space1 = await createSpace(org1.id, account1.id, 'Villa A');
    const account3 = await createAccount(org1.id, 'Account 3');
    const space2 = await createSpace(org1.id, account3.id, 'Villa B');
    const client = await pool.connect();
    try {
      await client.query('BEGIN');
      await client.query(`SET LOCAL app.current_org_id = $1`, [org1.id]);
      await client.query(`SET LOCAL app.current_account_id = $1`, [account1.id]);
      await client.query(`SET LOCAL app.current_user_id = $1`, [adminUser.id]);
      const result = await client.query('SELECT * FROM spaces ORDER BY name');
      // Should see both spaces (org-wide access)
      expect(result.rows.length).toBe(2);
      await client.query('COMMIT');
    } finally {
      client.release();
    }
  });
  test('insert policy: cannot insert into other org', async () => {
    const client = await pool.connect();
    try {
      await client.query('BEGIN');
      await client.query(`SET LOCAL app.current_org_id = $1`, [org2.id]);
      await client.query(`SET LOCAL app.current_account_id = $1`, [account2.id]);
      await client.query(`SET LOCAL app.current_user_id = $1`, [user2.id]);
      // Attempt to insert into org1 (should fail)
      await expect(
        client.query(
          `INSERT INTO spaces (org_id, account_id, name)
           VALUES ($1, $2, $3)`,
          [org1.id, account1.id, 'Malicious Space']
        )
      ).rejects.toThrow(/new row violates row-level security policy/);
      await client.query('ROLLBACK');
    } finally {
      client.release();
    }
  });
});
Testing with Different Roles
Role-Based Test Matrix:
const roles = ['admin', 'editor', 'viewer'];
describe('Role-Based RLS', () => {
  roles.forEach(roleName => {
    describe(`As ${roleName}`, () => {
      let user, org, account;
      beforeEach(async () => {
        org = await createOrg('Test Org');
        account = await createAccount(org.id, 'Test Account');
        user = await createUser(`${roleName}@test.com`);
        await assignRole(user.id, org.id, account.id, roleName);
      });
      test('can read spaces', async () => {
        const space = await createSpace(org.id, account.id, 'Villa');
        const result = await queryAsUser(user, 'SELECT * FROM spaces');
        expect(result.rows.length).toBeGreaterThan(0);
      });
      test(`can ${canWrite(roleName) ? '' : 'NOT '}update spaces`, async () => {
        const space = await createSpace(org.id, account.id, 'Villa');
        if (canWrite(roleName)) {
          await expect(
            queryAsUser(user, 'UPDATE spaces SET name = $1 WHERE id = $2', ['New Name', space.id])
          ).resolves.not.toThrow();
        } else {
          await expect(
            queryAsUser(user, 'UPDATE spaces SET name = $1 WHERE id = $2', ['New Name', space.id])
          ).rejects.toThrow();
        }
      });
    });
  });
});
function canWrite(role) {
  return ['admin', 'editor'].includes(role);
}
Performance Considerations
Index Requirements
Rule: Always index columns used in RLS policies.
Critical Indexes:
-- Org-level policies: Index org_id first
CREATE INDEX idx_spaces_org_id ON spaces(org_id);
-- Multi-column policies: Composite index
CREATE INDEX idx_spaces_org_account ON spaces(org_id, account_id);
-- Membership lookups in policies
CREATE INDEX idx_memberships_user_org ON memberships(user_id, org_id);
CREATE INDEX idx_memberships_user_org_account ON memberships(user_id, org_id, account_id);
-- Status filters in policies
CREATE INDEX idx_memberships_status ON memberships(user_id, org_id) WHERE status = 'active';
Index Strategy:
- Index all foreign keys used in session variable comparisons
- Create composite indexes matching policy filter order
- Use partial indexes for common WHERE conditions
- Monitor query plans with EXPLAIN ANALYZE
Query Plan Analysis
EXPLAIN with RLS:
-- Set context as RLS would
SET LOCAL app.current_org_id = '123e4567-e89b-12d3-a456-426614174000';
SET LOCAL app.current_account_id = '987e6543-e89b-12d3-a456-426614174000';
SET LOCAL app.current_user_id = '456e7890-e89b-12d3-a456-426614174000';
-- Analyze query with RLS policies
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT * FROM spaces WHERE status = 'active';
Good Plan:
Index Scan using idx_spaces_org_account on spaces
  Index Cond: (org_id = '...'::uuid AND account_id = '...'::uuid)
  Filter: (status = 'active'::text)
  Rows: 15 (actual)
Bad Plan:
Seq Scan on spaces  ← Sequential scan = missing index
  Filter: (org_id = '...'::uuid AND status = 'active'::text)
  Rows Removed by Filter: 50000  ← Scanning all orgs!
Performance Benchmarks:
| Scenario | Without Index | With Index | Improvement | 
|---|---|---|---|
| Single-org query (1M rows) | 1200ms | 8ms | 150x | 
| Account filter (100K rows) | 450ms | 3ms | 150x | 
| Membership EXISTS (50K rows) | 2300ms | 45ms | 51x | 
Query Optimization Tips
- 
Avoid Complex Subqueries in Policies: -- BAD: Nested subqueries
 CREATE POLICY complex_policy ON spaces
 USING (
 EXISTS (
 SELECT 1 FROM memberships m
 WHERE m.user_id = current_setting('app.current_user_id')::uuid
 AND EXISTS (
 SELECT 1 FROM teams t WHERE t.id = m.team_id
 )
 )
 );
 -- GOOD: Simplified join
 CREATE POLICY simple_policy ON spaces
 USING (
 EXISTS (
 SELECT 1 FROM memberships m
 WHERE m.user_id = current_setting('app.current_user_id')::uuid
 AND m.org_id = spaces.org_id
 )
 );
- 
Cache Membership Checks: // Cache user's org membership in session
 req.session.has_org_access = await checkOrgAccess(user_id, org_id);
 // Use cached value in policy
 await client.query(`SET LOCAL app.has_org_access = $1`, [req.session.has_org_access]);
- 
Use Partial Indexes for Active Records: CREATE INDEX idx_active_spaces ON spaces(org_id, account_id)
 WHERE status = 'active';
Troubleshooting
Common Mistakes
Mistake 1: Forgetting to Set Session Context
Symptom: Queries return 0 rows or error "unrecognized configuration parameter"
// ❌ WRONG: No context set
const result = await client.query('SELECT * FROM spaces');
// Returns 0 rows (RLS blocks everything)
// ✅ CORRECT: Set context first
await client.query(`SET LOCAL app.current_org_id = $1`, [org_id]);
await client.query(`SET LOCAL app.current_account_id = $1`, [account_id]);
const result = await client.query('SELECT * FROM spaces');
Mistake 2: Using SET Instead of SET LOCAL
Symptom: Context leaks between transactions/requests
// ❌ WRONG: Persists across transactions
await client.query(`SET app.current_org_id = $1`, [org_id]);
// ✅ CORRECT: Transaction-scoped
await client.query('BEGIN');
await client.query(`SET LOCAL app.current_org_id = $1`, [org_id]);
await client.query('COMMIT');
// Context automatically cleared
Mistake 3: Missing WITH CHECK Policy
Symptom: Can SELECT but not INSERT/UPDATE
-- ❌ WRONG: Only USING clause
CREATE POLICY spaces_policy ON spaces
  USING (org_id = current_setting('app.current_org_id')::uuid);
-- ✅ CORRECT: Add WITH CHECK for inserts
CREATE POLICY spaces_select ON spaces
  FOR SELECT
  USING (org_id = current_setting('app.current_org_id')::uuid);
CREATE POLICY spaces_insert ON spaces
  FOR INSERT
  WITH CHECK (org_id = current_setting('app.current_org_id')::uuid);
Mistake 4: RLS Not Enabled on Table
Symptom: Policies exist but not enforced
-- Check if RLS is enabled
SELECT tablename, rowsecurity FROM pg_tables
WHERE schemaname = 'public' AND tablename = 'spaces';
-- Enable if needed
ALTER TABLE spaces ENABLE ROW LEVEL SECURITY;
Debugging RLS Issues
Debug Step 1: Check Policy Definitions
-- View all policies on a table
SELECT schemaname, tablename, policyname, permissive, roles, cmd, qual, with_check
FROM pg_policies
WHERE tablename = 'spaces';
Debug Step 2: Test Policy in Isolation
-- Manually test policy expression
SET LOCAL app.current_org_id = '123e4567-e89b-12d3-a456-426614174000';
-- Test USING clause
SELECT id, org_id,
  (org_id = current_setting('app.current_org_id')::uuid) AS passes_policy
FROM spaces;
Debug Step 3: Bypass RLS for Debugging
-- Temporarily disable RLS (testing only!)
ALTER TABLE spaces DISABLE ROW LEVEL SECURITY;
-- Run query
SELECT * FROM spaces;
-- Re-enable
ALTER TABLE spaces ENABLE ROW LEVEL SECURITY;
WARNING: Never disable RLS in production!
Debug Step 4: Check Session Variables
-- View current session variables
SHOW app.current_org_id;
SHOW app.current_account_id;
SHOW app.current_user_id;
-- Or
SELECT current_setting('app.current_org_id', true);
Debug Step 5: Log Policy Violations
// Wrap queries with error logging
async function queryWithLogging(client, query, params) {
  try {
    return await client.query(query, params);
  } catch (err) {
    if (err.message.includes('row-level security policy')) {
      console.error('RLS Policy Violation:', {
        query,
        params,
        context: {
          org_id: await client.query(`SELECT current_setting('app.current_org_id', true)`),
          account_id: await client.query(`SELECT current_setting('app.current_account_id', true)`),
          user_id: await client.query(`SELECT current_setting('app.current_user_id', true)`)
        }
      });
    }
    throw err;
  }
}
Error Messages
| Error | Cause | Solution | 
|---|---|---|
| new row violates row-level security policy | INSERT/UPDATE blocked by WITH CHECK | Verify session context matches row values | 
| unrecognized configuration parameter: "app.current_org_id" | Session variable not set | Call SET LOCAL before queries | 
| invalid input syntax for type uuid: "" | Empty string passed instead of NULL | Use current_setting('var', true)to return NULL if unset | 
| permission denied for table | User lacks table-level permissions | GRANT SELECT/INSERT/UPDATE on table | 
Related Documents
- Multi-Tenancy Implementation Guide
- Identity & Tenancy Specification
- Authorization & Access Specification
End of Document