Postgres RLS Pattern
Concept of the skill
What it is: The database-enforced tenant isolation pattern for Postgres tables in a multi-organization SaaS.
Mental model: The application sets the current organization; Postgres enforces which rows that organization can read or write.
Why it exists: A missed WHERE org_id = ... clause should not become a cross-tenant data leak.
What it is NOT: It is not a service-role migration pattern, admin reporting bypass, or generic SQL optimization guidance.
Adjacent concepts: Row-level policies, session variables, service-role isolation, tenant-bound tables.
One-line analogy: It is a database lock that opens only for the current organization.
Common misconception: Application-level filters are equivalent to RLS; RLS moves the guardrail into the database itself.
Coverage
- The three-part policy triple —
FORCE ROW LEVEL SECURITY, USING (org_id = current_setting('app.current_org_id')::uuid), and WITH CHECK (org_id = current_setting('app.current_org_id')::uuid) — and why omitting any one part leaves a gap
- The
orgQuery(orgId) application wrapper — a single function that opens a transaction, sets app.current_org_id, runs the caller's query, and commits; why setting the variable once at session start is unsafe under connection pooling
- Service role bypass — legitimate cross-org operations (billing cron, admin panel, migration backfills) that must use a connection string that skips RLS, and why those code paths must be isolated from application code
- Policy audit checklist — grepping for
query() calls without a preceding SET app.current_org_id as a CI-safe audit gate
- New-table checklist — steps to add RLS to a table that was created before RLS was enforced on the schema
Philosophy of the skill
Row-level security on Postgres is the difference between "we checked org_id in the WHERE clause" and "the database rejects cross-org reads at the storage layer." Application-level checks are deleted by a single missing WHERE clause; RLS cannot be bypassed unless you use the service role explicitly. The cost is a session variable that must be set before every query and a discipline of never using the service role for application queries. Both costs are cheap relative to the consequence of a cross-tenant data leak.
Schema Pattern
-- 1. Enable and force RLS on every tenant-bound table
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
-- 2. SELECT policy — only rows where org_id matches the session variable
CREATE POLICY orders_org_select ON orders
FOR SELECT
USING (org_id = current_setting('app.current_org_id', true)::uuid);
-- 3. INSERT policy — only allow inserts that match the session variable
CREATE POLICY orders_org_insert ON orders
FOR INSERT
WITH CHECK (org_id = current_setting('app.current_org_id', true)::uuid);
-- 4. UPDATE policy — USING (read filter) AND WITH CHECK (write filter)
CREATE POLICY orders_org_update ON orders
FOR UPDATE
USING (org_id = current_setting('app.current_org_id', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org_id', true)::uuid);
-- 5. DELETE policy
CREATE POLICY orders_org_delete ON orders
FOR DELETE
USING (org_id = current_setting('app.current_org_id', true)::uuid);
Application Wrapper Pattern
// lib/db.ts
import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL!);
/** Tenant-scoped query: sets app.current_org_id for every statement. */
export async function orgQuery<T>(
orgId: string,
fn: (sql: postgres.Sql) => Promise<T>
): Promise<T> {
return sql.begin(async (tx) => {
await tx`SELECT set_config('app.current_org_id', ${orgId}, true)`;
return fn(tx);
});
}
/** System query: bypasses RLS. Use ONLY for cron jobs, migrations, and admin. */
export async function systemQuery<T>(fn: (sql: postgres.Sql) => Promise<T>): Promise<T> {
return fn(sql);
}
Usage in a Server Action:
import { orgQuery } from "@/lib/db";
export async function getOrders(orgId: string) {
return orgQuery(orgId, (tx) => tx`SELECT * FROM orders ORDER BY created_at DESC`);
}
Verification
Do NOT Use When
| Use instead |
When |
systemQuery wrapper |
The query legitimately crosses org boundaries (billing cron, migration backfill, admin panel) |
migrate-orders-to-canonical-schema |
The task is a schema migration that also needs to update RLS policies |
| (a database skill without multi-tenancy scope) |
The application is single-tenant and org_id isolation is not a requirement |
1---2# name: stable skill identifier. Match the skill directory name or the final namespace segment.3# Lowercase letters/numbers with hyphen, slash, or colon separators.4name: postgres-rls-pattern5# description: routing-facing summary of what the skill covers and when it activates.6# Include concrete triggers and an explicit negative boundary; keep routing semantics out of prose-only ambiguity.7description: "Use when writing or reviewing Postgres queries in a multi-tenant SaaS where every table row must be scoped to a single organization. Enforces the FORCE ROW LEVEL SECURITY + USING + WITH CHECK triple on every tenant-bound table, and wraps application queries in an `orgQuery(orgId)` helper that sets `app.current_org_id` before each statement. Do NOT use for cross-org system queries such as billing cron jobs or admin panels (those bypass RLS intentionally via the service role); use a service-role query wrapper instead."89# === v8 Classification (subject + public; polyhierarchy via subjects[]) — see ADR-0017 ===10# subject: primary browse shelf — what the skill teaches. One of twelve closed values:11# backend-engineering / frontend-engineering / software-architecture / data-engineering / agent-ops / ai-engineering /12# quality-assurance / design / reasoning-strategy / software-engineering-method / knowledge-organization / product-domain.13subject: backend-engineering14# public: publishability/private-data gate. Boolean.15# true = publishable/shareable; false = private and excluded from public export.16# Project anchoring is carried separately by non-empty `project[]` plus `grounding`.17public: true18# scope: free-text PRD-style statement of what the skill teaches and what it excludes.19# (v8 required; not an enum). Mirrors Coverage + Do NOT Use When at frontmatter level.20scope: "Row-level-security policy pattern for multi-tenant Postgres in the saas-stripe-postgres example — tenant isolation via RLS policies on every tenant-scoped table. Excludes application-layer authorization logic and the event routing that triggers the writes."21# taxonomy_domain: optional hierarchical sub-path within `subject`. Slash-delimited22# lowercase kebab-case segments. rename of the original v8 `domain`. Remove when the flat23# `subject` is sufficient.24taxonomy_domain: engineering/database2526# stability: lifecycle marker. One of:27# experimental (active development) / stable (production-ready) /28# frozen (no further changes expected) / deprecated.29# When `deprecated`, schema's allOf REQUIRES `superseded_by: <real-skill-name>`.30stability: experimental31# license: SPDX license identifier (e.g., MIT, Apache-2.0).32license: MIT33# compatibility: runtime compatibility object. Prefer structured fields34# (`agent_runtimes`, `node_version`) over free-text `notes`.35compatibility:36 agent_runtimes:37 - node38 node_version: ">=20"39 notes: "Postgres >=14 with row-level security enabled; assumes pg or postgres.js driver."40allowed-tools: Read Grep41# keywords: semantic phrases for fuzzy router activation. v8 cap: max 10.42# Keep terms a user would actually type when starting a task in this skill's domain.43keywords:44 - postgres row level security45 - RLS multi-tenant46 - orgQuery wrapper47 - FORCE ROW LEVEL SECURITY48 - SET app.current_org_id49 - tenant data isolation50 - cross-tenant data leak prevention51 - multi-tenant postgres52 - row security policy53 - using with check policy54# triggers: explicit-match activation phrases the router fires on literally.55# Use when label-based routing is intended; usually keywords + examples are enough.56triggers:57 - postgres-rls-pattern58# paths: glob array of code surfaces this skill governs. Supports gitignore-style59# negation. Each glob should map to ONE canonical skill. Omit if purely conceptual.60paths:61 - "lib/db.ts"62 - "db/schema.sql"63 - "db/migrations/*.sql"64# examples: 2-5 realistic user prompts the skill SHOULD activate for.65# Written in the user's voice. Improves retrieval recall beyond keywords alone.66examples:67 - "how do I prevent one tenant's data from appearing in another tenant's queries?"68 - "write the RLS policy for the orders table in a multi-tenant SaaS"69 - "set up the orgQuery helper so every query is automatically scoped to the current org"70 - "add row level security to a new subscriptions table"71# anti_examples: near-miss prompts that should route ELSEWHERE.72# Pair with relations.suppresses (or legacy boundary alias) to name the confusable territory's owner.73anti_examples:74 - "run a billing cron job that needs to read all orgs"75 - "write a migration that backfills data across all organizations"76 - "query the database without any tenant context for an admin dashboard"77# relations: typed graph edges to sibling skills. Current fields:78# related (adjacency for browse / co-routing expansion) /79# suppresses (exclude listed skills from co-routing when THIS skill wins; write reason80# as "I own this exclusively over X", not "use X instead") /81# boundary (DEPRECATED alias of suppresses, retained for unmigrated skills) /82# verify_with (cross-check; co-loaded as one-hop expansion) /83# depends_on (composition; transitive — A→B→C loads all three) /84# broader / narrower (SKOS-style generalization) /85# disjoint_with (mutual exclusion for incompatible ownership).86relations:87 related:88 - migrate-orders-to-canonical-schema89 - nextjs-server-action-validation90 depends_on: []91 verify_with:92 - migrate-orders-to-canonical-schema93---9495# Postgres RLS Pattern9697## Concept of the skill9899**What it is:** The database-enforced tenant isolation pattern for Postgres tables in a multi-organization SaaS.100**Mental model:** The application sets the current organization; Postgres enforces which rows that organization can read or write.101**Why it exists:** A missed `WHERE org_id = ...` clause should not become a cross-tenant data leak.102**What it is NOT:** It is not a service-role migration pattern, admin reporting bypass, or generic SQL optimization guidance.103**Adjacent concepts:** Row-level policies, session variables, service-role isolation, tenant-bound tables.104**One-line analogy:** It is a database lock that opens only for the current organization.105**Common misconception:** Application-level filters are equivalent to RLS; RLS moves the guardrail into the database itself.106107## Coverage108109- The three-part policy triple — `FORCE ROW LEVEL SECURITY`, `USING (org_id = current_setting('app.current_org_id')::uuid)`, and `WITH CHECK (org_id = current_setting('app.current_org_id')::uuid)` — and why omitting any one part leaves a gap110- The `orgQuery(orgId)` application wrapper — a single function that opens a transaction, sets `app.current_org_id`, runs the caller's query, and commits; why setting the variable once at session start is unsafe under connection pooling111- Service role bypass — legitimate cross-org operations (billing cron, admin panel, migration backfills) that must use a connection string that skips RLS, and why those code paths must be isolated from application code112- Policy audit checklist — grepping for `query()` calls without a preceding `SET app.current_org_id` as a CI-safe audit gate113- New-table checklist — steps to add RLS to a table that was created before RLS was enforced on the schema114115## Philosophy of the skill116117Row-level security on Postgres is the difference between "we checked org_id in the WHERE clause" and "the database rejects cross-org reads at the storage layer." Application-level checks are deleted by a single missing WHERE clause; RLS cannot be bypassed unless you use the service role explicitly. The cost is a session variable that must be set before every query and a discipline of never using the service role for application queries. Both costs are cheap relative to the consequence of a cross-tenant data leak.118119## Schema Pattern120121```sql122-- 1. Enable and force RLS on every tenant-bound table123ALTER TABLE orders ENABLE ROW LEVEL SECURITY;124ALTER TABLE orders FORCE ROW LEVEL SECURITY;125126-- 2. SELECT policy — only rows where org_id matches the session variable127CREATE POLICY orders_org_select ON orders128 FOR SELECT129 USING (org_id = current_setting('app.current_org_id', true)::uuid);130131-- 3. INSERT policy — only allow inserts that match the session variable132CREATE POLICY orders_org_insert ON orders133 FOR INSERT134 WITH CHECK (org_id = current_setting('app.current_org_id', true)::uuid);135136-- 4. UPDATE policy — USING (read filter) AND WITH CHECK (write filter)137CREATE POLICY orders_org_update ON orders138 FOR UPDATE139 USING (org_id = current_setting('app.current_org_id', true)::uuid)140 WITH CHECK (org_id = current_setting('app.current_org_id', true)::uuid);141142-- 5. DELETE policy143CREATE POLICY orders_org_delete ON orders144 FOR DELETE145 USING (org_id = current_setting('app.current_org_id', true)::uuid);146```147148## Application Wrapper Pattern149150```typescript151// lib/db.ts152import postgres from "postgres";153154const sql = postgres(process.env.DATABASE_URL!);155156/** Tenant-scoped query: sets app.current_org_id for every statement. */157export async function orgQuery<T>(158 orgId: string,159 fn: (sql: postgres.Sql) => Promise<T>160): Promise<T> {161 return sql.begin(async (tx) => {162 await tx`SELECT set_config('app.current_org_id', ${orgId}, true)`;163 return fn(tx);164 });165}166167/** System query: bypasses RLS. Use ONLY for cron jobs, migrations, and admin. */168export async function systemQuery<T>(fn: (sql: postgres.Sql) => Promise<T>): Promise<T> {169 return fn(sql);170}171```172173Usage in a Server Action:174175```typescript176import { orgQuery } from "@/lib/db";177178export async function getOrders(orgId: string) {179 return orgQuery(orgId, (tx) => tx`SELECT * FROM orders ORDER BY created_at DESC`);180}181```182183## Verification184185- [ ] Every tenant-bound table has `ENABLE ROW LEVEL SECURITY` AND `FORCE ROW LEVEL SECURITY`186- [ ] Every DML operation (SELECT, INSERT, UPDATE, DELETE) has a corresponding policy on each table187- [ ] `WITH CHECK` is present on INSERT and UPDATE policies (not just `USING`)188- [ ] `orgQuery` sets the variable inside a transaction, not at session start189- [ ] No application code calls `systemQuery` (grep for `systemQuery` in `apps/` and `lib/` — any hit is a finding)190- [ ] Every new migration that adds a table includes the RLS policy triple in the same migration file191192## Do NOT Use When193194| Use instead | When |195|---|---|196| `systemQuery` wrapper | The query legitimately crosses org boundaries (billing cron, migration backfill, admin panel) |197| `migrate-orders-to-canonical-schema` | The task is a schema migration that also needs to update RLS policies |198| (a database skill without multi-tenancy scope) | The application is single-tenant and org_id isolation is not a requirement |