Postgres Row-Level Security skill for Codex
Tenant isolation that holds when a WHERE is forgotten. Codex sets up Postgres RLS so a query that forgets its tenant filter still returns only that tenant's rows. It separates owner and app roles, sets tenant context per transaction so poolers cannot leak it, writes USING and WITH CHECK policies that fail closed, fixes per-row policy cost with InitPlans and tenant-leading indexes, and adds tests that run as the real app role.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- postgres-row-level-security Installs to ~/.agents/skills/postgres-row-level-security/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install postgres-row-level-security The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project postgres-row-level-security Installs to .agents/skills/postgres-row-level-security/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/postgres-row-level-security.zipunzip -o postgres-row-level-security.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) postgres-row-level-security PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $postgres-row-level-security, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Implements and audits PostgreSQL row-level security for multi-tenant and per-user isolation - ENABLE and FORCE ROW LEVEL SECURITY, CREATE POLICY with USING and WITH CHECK, tenant context via set_config and SET LOCAL behind connection poolers, role design without BYPASSRLS, security_invoker views, and RLS query performance. Includes Supabase auth.uid() patterns and isolation tests. Use when adding tenant isolation in the database, writing or reviewing RLS policies, debugging rows that vanish or leak across tenants, or when RLS made queries slow.
Use it when
- Enforcing tenant or per-user isolation in the database itself
- Writing or reviewing CREATE POLICY statements
- Rows disappear or leak across tenants after enabling RLS
- RLS-enabled queries became slow
Use something else when
- You are choosing a tenancy model (schema per tenant, database per tenant) at the app level: use multi-tenancy
- Broader Supabase setup beyond policies (auth, storage, edge functions): use supabase-backend
How to check the result
Tests running as the application role with SET LOCAL ROLE show zero cross-tenant rows, rejected cross-tenant inserts and updates, and zero rows when tenant context is unset.
The mistake it prevents
Connecting the app as the table owner or a BYPASSRLS role, which makes every policy silently inert.
RLS is correct when a session that forgets its WHERE tenant_id = ... still sees only
its own rows, when no application role can silently bypass policies, and when tests
prove cross-tenant reads and writes fail. Policies are security code: review them like
auth code, and test them with the same role the application uses.
Before writing policies
- Read
AGENTS.mdand the connection setup: which database role the app connects as, whether a pooler (PgBouncer transaction mode, Supabase Supavisor, RDS Proxy) sits in between, and how the current tenant or user is known per request. - List roles and their bypass power:
SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname !~ '^pg_';
SELECT relname, relrowsecurity, relforcerowsecurity
FROM pg_class WHERE relnamespace = 'public'::regnamespace AND relkind IN ('r','p');
Superusers and roles with BYPASSRLS ignore every policy. Table owners ignore policies
unless the table has FORCE ROW LEVEL SECURITY. If the app connects as the owner
(common with ORMs that run migrations as the same user), RLS does nothing until fixed.
Role design
CREATE ROLE app_owner NOLOGIN; -- owns tables, runs migrations
CREATE ROLE app_user LOGIN PASSWORD '...' NOBYPASSRLS; -- the application
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
Keep the password out of migrations; take it from the secret store the repo already uses.
Background jobs that legitimately work across tenants get a separate role with
BYPASSRLS, used only by those jobs, never by request handlers.
Tenant context
Set the tenant per transaction, never per session, so a pooled connection cannot carry one request’s tenant into the next:
BEGIN;
SELECT set_config('app.tenant_id', '42', true); -- true = local to this transaction
-- queries...
COMMIT;
SET LOCAL app.tenant_id = '42' is equivalent but cannot take a bind parameter;
set_config($1, $2, true) can, so use it from application code. Wire it into the one
place every request opens a transaction (middleware, unit-of-work, ORM hook), not into
individual queries.
Read it in policies through a helper that fails closed:
CREATE FUNCTION app.current_tenant_id() RETURNS bigint
LANGUAGE sql STABLE AS $$
SELECT nullif(current_setting('app.tenant_id', true), '')::bigint
$$;
current_setting(..., true) returns NULL when unset; a NULL tenant matches no rows,
so a request that forgot to set context sees nothing instead of everything.
Policies
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY invoices_tenant_isolation ON invoices
AS PERMISSIVE FOR ALL TO app_user
USING (tenant_id = (SELECT app.current_tenant_id()))
WITH CHECK (tenant_id = (SELECT app.current_tenant_id()));
USINGfilters rows a command can see (SELECT, and the rows UPDATE/DELETE target).WITH CHECKvalidates new row versions (INSERT, and UPDATE results). WithoutWITH CHECKon an UPDATE policy, a user can move a row into another tenant.- Policies for the same command are OR-ed when PERMISSIVE and AND-ed with RESTRICTIVE ones. Use a RESTRICTIVE policy for a global rule (tenant isolation) and PERMISSIVE policies for role-specific access within the tenant.
- No policy for a command means deny for that command once RLS is enabled.
- Separate policies per command (
FOR SELECT,FOR INSERT…) when read and write rules differ, for example members read all projects but only admins update. - Policies on child tables must enforce tenancy themselves; a policy on
ordersdoes not protectorder_items. Puttenant_idon child tables rather than joining to the parent inside the policy.
Supabase specifics
- Client requests run as the
anonorauthenticatedrole;service_rolebypasses RLS and must never reach the browser. - Use
(select auth.uid())rather than bareauth.uid()in policies so it is evaluated once per statement, and addTO authenticatedto skip evaluation for anon. - Tables created through SQL in the
publicschema are exposed through the Data API; enable RLS on every one. The Supabase security advisor flags tables without it.
Performance
| Problem | Fix |
|---|---|
| Function in policy evaluated per row | Wrap as (SELECT fn()) so it becomes an InitPlan evaluated once; mark helpers STABLE |
| Seq Scan on large tenant table | Index with tenant_id as the leading column of the indexes queries use |
Policy with subquery (EXISTS (SELECT 1 FROM memberships ...)) per row | Index the membership lookup; or precompute allowed ids into a setting or a SECURITY DEFINER function returning an array |
| Planner cannot push user filters below the policy | Non-LEAKPROOF functions in queries are evaluated after the policy filter; this is by design, avoid exotic functions in hot WHERE clauses |
Compare EXPLAIN ANALYZE as app_user with and without the policy (as owner). A good
policy adds a cheap index condition, not a filter over every row.
Gotchas
- Views run with the view owner’s privileges by default and can bypass RLS. Create
them
WITH (security_invoker = true)(PG 15+). SECURITY DEFINERfunctions run as their owner; if the owner bypasses RLS, so does the function. Setsearch_pathexplicitly on them.- Unique constraints and foreign keys are checked without RLS. An insert that fails on
a unique key reveals that another tenant’s row exists. Scope unique keys with
tenant_id. pg_dumpsetsrow_security = offand errors if policies would hide rows; run backups as a superuser or a role with BYPASSRLS.TRUNCATEis not subject to RLS; do not grant it to the app role.- Session-level
SET app.tenant_idwith a transaction-mode pooler leaks context between clients. Only transaction-local settings are safe there. - Materialized views do not apply RLS from base tables at query time.
Testing isolation
Write tests that run as the application role, not as a superuser:
BEGIN;
SET LOCAL ROLE app_user;
SELECT set_config('app.tenant_id', '1', true);
SELECT count(*) FROM invoices WHERE tenant_id = 2; -- expect 0
SAVEPOINT s1;
INSERT INTO invoices (tenant_id, ...) VALUES (2, ...); -- expect: new row violates row-level security policy
ROLLBACK TO SAVEPOINT s1;
UPDATE invoices SET tenant_id = 2 WHERE id = <tenant 1 row>; -- expect the same error
ROLLBACK TO SAVEPOINT s1;
SELECT set_config('app.tenant_id', '', true);
SELECT count(*) FROM invoices; -- expect 0 (fail closed)
ROLLBACK;
An error aborts the transaction, so each expected failure needs its own savepoint (as above) or its own transaction.
Turn these into automated tests (pgTAP, or the app’s integration test suite using
$testcontainers) covering every RLS table. A script that lists tables with
relrowsecurity = false in tenant-owned schemas makes a good CI check.
Verification
- App role is not owner, not superuser,
NOBYPASSRLS - Every tenant-owned table has RLS enabled and forced, with USING and WITH CHECK
- Tenant context set with
set_config(..., true)inside each transaction - Missing context returns zero rows
- Cross-tenant read, insert, update and delete tests fail as expected
- Hot queries’ plans as app role still use tenant-leading indexes
Output template
## RLS: <schema or feature>
**Roles:** <role -> owner/bypass/login>
**Context:** <how tenant/user is set, where in code>
| Table | RLS on | Forced | Policies (cmd: rule) | Index on tenant_id |
|---|---|---|---|---|
**Tests:** <file, cases covered>
**Known gaps:** <views, definer functions, jobs with BYPASSRLS> What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Postgres Row-Level Security"
short_description: "Tenant isolation that holds when a WHERE is forgotten"
default_prompt: "$postgres-row-level-security Add row-level security so each tenant only sees its own rows in these tables" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.