postgres-rls

MANDATORY when touching auth tables, tenant isolation, RLS policies, or multi-tenant database code - enforces Row Level Security best practices and catches common bypass vulnerabilities

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "postgres-rls" with this command: npx skills add troykelly/claude-skills/troykelly-claude-skills-postgres-rls

PostgreSQL Row Level Security

Overview

Row Level Security (RLS) provides defense-in-depth for data isolation. When implemented correctly, it prevents data leaks even if application code misses a filter. When implemented incorrectly, it creates false security confidence while data bleeds between tenants.

Core principle: RLS is your last line of defense, not your only one. Get it wrong and you have a data breach.

Announce at start: "I'm applying postgres-rls to verify Row Level Security implementation."

When This Skill Applies

This skill is MANDATORY when ANY of these patterns are touched:

PatternExamples
**/migrations/**/*tenant*migrations/001_add_tenant_id.sql
**/migrations/**/*rls*migrations/005_enable_rls.sql
**/migrations/**/*policy*migrations/010_create_policies.sql
**/*policy*.sqldb/policies.sql
**/auth/**src/auth/context.ts
**/*tenant*lib/tenant.ts, services/tenantService.ts
**/*multi-tenant*docs/multi-tenant-architecture.md

Check with:

git diff --name-only HEAD~1 | grep -iE '(tenant|rls|policy|auth.*sql|multi.?tenant)'

The Critical Vulnerabilities

1. Superuser Bypass (CRITICAL)

Superusers and roles with BYPASSRLS ignore ALL policies.

-- DANGEROUS: Testing as superuser shows RLS "working" when it's bypassed
SET ROLE postgres;
SELECT * FROM orders;  -- Returns ALL rows, RLS ignored

-- CORRECT: Test as application role
SET ROLE app_user;
SELECT * FROM orders;  -- Returns only permitted rows

Checklist:

  • Application connects as non-superuser role
  • No roles have BYPASSRLS attribute
  • Tests run as application role, NOT superuser

2. Table Owner Bypass (CRITICAL)

Table owners bypass RLS unless FORCE ROW LEVEL SECURITY is set.

-- INCOMPLETE: Owners bypass this
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- COMPLETE: Everyone including owners must obey policies
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;

Checklist:

  • All RLS tables have both ENABLE and FORCE
  • Migration includes both statements

3. View Bypass (CRITICAL)

Views run with creator's privileges by default. Views owned by superusers bypass RLS entirely.

-- DANGEROUS: View owned by superuser bypasses RLS
CREATE VIEW all_orders AS SELECT * FROM orders;

-- SAFE (PostgreSQL 15+): Security invoker respects caller's RLS
CREATE VIEW user_orders
WITH (security_invoker = true)
AS SELECT * FROM orders;

Checklist:

  • All views on RLS tables use security_invoker = true (PG15+)
  • Views not owned by superuser roles
  • Materialized views documented as bypassing RLS

4. USING vs WITH CHECK Mismatch (HIGH)

USING filters reads; WITH CHECK validates writes. Missing WITH CHECK allows inserting data you can't see.

-- INCOMPLETE: User can INSERT rows they can't SELECT
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- COMPLETE: Both read and write protected
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);

Checklist:

  • All policies have both USING and WITH CHECK
  • WITH CHECK logic matches security intent

5. Thread-Local Context Leakage (HIGH)

Connection pooling can leak tenant context between requests.

-- DANGEROUS: Context persists across pooled connections
SET app.tenant_id = 'tenant-123';

-- SAFE: Use SET LOCAL inside transaction (auto-resets)
BEGIN;
SET LOCAL app.tenant_id = 'tenant-123';
-- ... queries ...
COMMIT;  -- Context automatically cleared

Application pattern:

// DANGEROUS: Leaks between requests
await db.query(`SET app.tenant_id = '${tenantId}'`);

// SAFE: Transaction-scoped context
await db.transaction(async (trx) => {
  await trx.raw(`SET LOCAL app.tenant_id = ?`, [tenantId]);
  // ... queries ...
});

Checklist:

  • Always use SET LOCAL not SET
  • Context set inside transactions
  • Post-request handler resets context (defense in depth)

6. SQL Injection in Policy Functions (HIGH)

Functions used in policies can be injection vectors.

-- DANGEROUS: If current_tenant() uses user input unsafely
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_tenant());

-- The function itself must be injection-safe:
CREATE OR REPLACE FUNCTION current_tenant()
RETURNS uuid AS $$
BEGIN
  -- SAFE: Casts to UUID, not string concatenation
  RETURN current_setting('app.tenant_id')::uuid;
END;
$$ LANGUAGE plpgsql STABLE;

7. Materialized Views and Data Export (MEDIUM)

Materialized views don't respect source table RLS. Data exports may bypass policies.

-- DANGEROUS: Contains ALL tenants' data
CREATE MATERIALIZED VIEW order_stats AS
SELECT tenant_id, count(*) FROM orders GROUP BY tenant_id;

-- Background jobs with superuser access can export all data

Checklist:

  • Materialized views documented as security-sensitive
  • Export jobs run as application role
  • Audit log for bulk data access

Performance Considerations

Index Policy Columns

-- Without index: Sequential scan on every query
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- Add index for policy column
CREATE INDEX idx_orders_tenant_id ON orders(tenant_id);

Wrap Functions in Subqueries

Functions called per-row are expensive. Wrap in subquery for single evaluation:

-- SLOW: Function called per row
CREATE POLICY access_check ON documents
  USING (user_has_access(auth.uid(), id));

-- FASTER: Evaluated once, cached
CREATE POLICY access_check ON documents
  USING ((SELECT auth.uid()) = owner_id);

Use SECURITY DEFINER for Complex Checks

Avoid RLS policy chains with SECURITY DEFINER functions:

-- SLOW: RLS on permissions table also evaluated
CREATE POLICY access_check ON documents
  USING (id IN (SELECT document_id FROM permissions WHERE user_id = auth.uid()));

-- FASTER: Bypass RLS chain with SECURITY DEFINER
CREATE OR REPLACE FUNCTION user_document_ids(uid uuid)
RETURNS SETOF uuid AS $$
  SELECT document_id FROM permissions WHERE user_id = uid;
$$ LANGUAGE sql STABLE SECURITY DEFINER;

CREATE POLICY access_check ON documents
  USING (id IN (SELECT * FROM user_document_ids(auth.uid())));

Denormalize for Performance

Store tenant_id on every table, even if "obvious" from joins:

-- SLOW: Must join to get tenant context
CREATE POLICY order_items_policy ON order_items
  USING (order_id IN (
    SELECT id FROM orders WHERE tenant_id = current_setting('app.tenant_id')::uuid
  ));

-- FAST: Direct column check
ALTER TABLE order_items ADD COLUMN tenant_id uuid;
CREATE POLICY order_items_policy ON order_items
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

Migration Pattern

Safe RLS Migration

-- Step 1: Add column (if needed)
ALTER TABLE orders ADD COLUMN IF NOT EXISTS tenant_id uuid;

-- Step 2: Backfill data (batched for large tables)
UPDATE orders SET tenant_id = (
  SELECT tenant_id FROM customers WHERE customers.id = orders.customer_id
) WHERE tenant_id IS NULL;

-- Step 3: Add NOT NULL constraint
ALTER TABLE orders ALTER COLUMN tenant_id SET NOT NULL;

-- Step 4: Create index
CREATE INDEX CONCURRENTLY idx_orders_tenant_id ON orders(tenant_id);

-- Step 5: Enable RLS (both statements!)
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;

-- Step 6: Create policies
CREATE POLICY tenant_isolation ON orders
  FOR ALL
  USING (tenant_id = current_setting('app.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);

-- Step 7: Grant appropriate permissions
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO app_role;

Testing RLS

Required Tests

describe('RLS Policies', () => {
  it('tenant A cannot see tenant B data', async () => {
    // Insert as tenant A
    await setTenantContext('tenant-a');
    await db('orders').insert({ id: 1, tenant_id: 'tenant-a', amount: 100 });

    // Switch to tenant B
    await setTenantContext('tenant-b');

    // Should not see tenant A's data
    const orders = await db('orders').select();
    expect(orders).toHaveLength(0);
  });

  it('cannot insert data for other tenant', async () => {
    await setTenantContext('tenant-a');

    await expect(
      db('orders').insert({ tenant_id: 'tenant-b', amount: 100 })
    ).rejects.toThrow(/violates row-level security/);
  });

  it('superuser role is not used in application', async () => {
    const result = await db.raw('SELECT current_user');
    expect(result.rows[0].current_user).not.toBe('postgres');
  });
});

Test as Non-Superuser

# Create test role
CREATE ROLE test_app_user;
GRANT app_role TO test_app_user;

# Run tests as this role
psql -U test_app_user -d testdb -f tests/rls_tests.sql

RLS Policy Artifact

When implementing RLS, post this artifact to the issue:

<!-- RLS_IMPLEMENTATION:START -->
## Row Level Security Implementation

### Tables with RLS Enabled

| Table | ENABLE | FORCE | Policies | Index |
|-------|--------|-------|----------|-------|
| orders | ✅ | ✅ | tenant_isolation | idx_orders_tenant_id |
| order_items | ✅ | ✅ | tenant_isolation | idx_order_items_tenant_id |
| customers | ✅ | ✅ | tenant_isolation | idx_customers_tenant_id |

### Policy Details

| Table | Policy | USING | WITH CHECK |
|-------|--------|-------|------------|
| orders | tenant_isolation | tenant_id = current_tenant() | tenant_id = current_tenant() |

### Security Verification

- [ ] Application connects as non-superuser role
- [ ] All RLS tables have FORCE ROW LEVEL SECURITY
- [ ] All policies have WITH CHECK clause
- [ ] Context uses SET LOCAL (transaction-scoped)
- [ ] Views use security_invoker = true
- [ ] Policy columns are indexed
- [ ] Cross-tenant tests written and passing

### Application Role
- Role name: `app_service`
- BYPASSRLS: `false`
- Superuser: `false`

**Verified At:** [timestamp]
<!-- RLS_IMPLEMENTATION:END -->

Checklist

Before completing RLS implementation:

  • All tables have ENABLE and FORCE ROW LEVEL SECURITY
  • All policies have both USING and WITH CHECK
  • Application connects as non-superuser, non-BYPASSRLS role
  • Context set with SET LOCAL inside transactions
  • Views use security_invoker = true (PG15+)
  • Policy columns indexed
  • Cross-tenant isolation tests passing
  • RLS artifact posted to issue

Integration

This skill is triggered by:

  • Changes to migration files with tenant/rls/policy patterns
  • Changes to auth-related database code
  • Multi-tenant architecture changes

This skill integrates with:

  • security-review - RLS is part of broader security review
  • database-architecture - RLS decisions are architectural
  • local-service-testing - Must test RLS against real Postgres

References

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Security

security-review

No summary provided by upstream source.

Repository SourceNeeds Review
Security

documentation-audit

No summary provided by upstream source.

Repository SourceNeeds Review
General

pexels-media

No summary provided by upstream source.

Repository SourceNeeds Review
General

conflict-resolution

No summary provided by upstream source.

Repository SourceNeeds Review