Postgres rls pattern
Skill jacob-balslev/skill-graph/examples/projects/saas-stripe-postgres/skills/postgres-rls-pattern
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.From its SKILL.md
npx -y skills add jacob-balslev/skill-graph --skill postgres-rls-patternAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its file declares
Copied from the file, not written here
The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
10.4 KB, ~1.2k tokens by cl100k_base, as published. Nobody here has run it
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), andWITH 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, setsapp.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 precedingSET app.current_org_idas 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
- Every tenant-bound table has
ENABLE ROW LEVEL SECURITYANDFORCE ROW LEVEL SECURITY - Every DML operation (SELECT, INSERT, UPDATE, DELETE) has a corresponding policy on each table
-
WITH CHECKis present on INSERT and UPDATE policies (not justUSING) -
orgQuerysets the variable inside a transaction, not at session start - No application code calls
systemQuery(grep forsystemQueryinapps/andlib/— any hit is a finding) - Every new migration that adds a table includes the RLS policy triple in the same migration file
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 |
What ships with it: 1 file
349 B alongside SKILL.md
- audit-state.json349 B