| name | postgres-rls |
| description | 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 |
| allowed-tools | ["Read","Grep","Glob","Bash","mcp__github__*"] |
| model | opus |
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:
| Pattern | Examples |
|---|
**/migrations/**/*tenant* | migrations/001_add_tenant_id.sql |
**/migrations/**/*rls* | migrations/005_enable_rls.sql |
**/migrations/**/*policy* | migrations/010_create_policies.sql |
**/*policy*.sql | db/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.
SET ROLE postgres;
SELECT * FROM orders;
SET ROLE app_user;
SELECT * FROM orders;
Checklist:
2. Table Owner Bypass (CRITICAL)
Table owners bypass RLS unless FORCE ROW LEVEL SECURITY is set.
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
Checklist:
3. View Bypass (CRITICAL)
Views run with creator's privileges by default. Views owned by superusers bypass RLS entirely.
CREATE VIEW all_orders AS SELECT * FROM orders;
CREATE VIEW user_orders
WITH (security_invoker = true)
AS SELECT * FROM orders;
Checklist:
4. USING vs WITH CHECK Mismatch (HIGH)
USING filters reads; WITH CHECK validates writes. Missing WITH CHECK allows inserting data you can't see.
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::uuid);
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:
5. Thread-Local Context Leakage (HIGH)
Connection pooling can leak tenant context between requests.
SET app.tenant_id = 'tenant-123';
BEGIN;
SET LOCAL app.tenant_id = 'tenant-123';
COMMIT;
Application pattern:
await db.query(`SET app.tenant_id = '${tenantId}'`);
await db.transaction(async (trx) => {
await trx.raw(`SET LOCAL app.tenant_id = ?`, [tenantId]);
});
Checklist:
6. SQL Injection in Policy Functions (HIGH)
Functions used in policies can be injection vectors.
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_tenant());
CREATE OR REPLACE FUNCTION current_tenant()
RETURNS uuid AS $$
BEGIN
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.
CREATE MATERIALIZED VIEW order_stats AS
SELECT tenant_id, count(*) FROM orders GROUP BY tenant_id;
Checklist:
Performance Considerations
Index Policy Columns
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::uuid);
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:
CREATE POLICY access_check ON documents
USING (user_has_access(auth.uid(), id));
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:
CREATE POLICY access_check ON documents
USING (id IN (SELECT document_id FROM permissions WHERE user_id = auth.uid()));
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:
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
));
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
ALTER TABLE orders ADD COLUMN IF NOT EXISTS tenant_id uuid;
UPDATE orders SET tenant_id = (
SELECT tenant_id FROM customers WHERE customers.id = orders.customer_id
) WHERE tenant_id IS NULL;
ALTER TABLE orders ALTER COLUMN tenant_id SET NOT NULL;
CREATE INDEX CONCURRENTLY idx_orders_tenant_id ON orders(tenant_id);
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
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);
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 () => {
await setTenantContext('tenant-a');
await db('orders').insert({ id: 1, tenant_id: 'tenant-a', amount: 100 });
await setTenantContext('tenant-b');
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 ROLE test_app_user;
GRANT app_role TO test_app_user;
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:
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