[{"data":1,"prerenderedAt":1428},["ShallowReactive",2],{"article/postgres-rls-multi-tenant-saas":3},{"_path":4,"_draft":5,"_partial":5,"_locale":6,"title":7,"description":8,"featured":5,"author":9,"categories":10,"slug":11,"image":12,"imageAlt":22,"published":23,"draft":5,"createdAt":24,"updatedAt":25,"faqs":26,"body":42,"_type":1427,"isInteractive":5,"interactiveConfig":-1},"/articles/react/postgres-rls-multi-tenant-saas",false,"","Postgres RLS for Multi-Tenant SaaS, the Production Pattern","Ship row level security on a real Node app with set_config, GUC-backed CREATE POLICY, Drizzle and Prisma middleware, negative tests, and a PR checklist.","Thomas Findlay","Postgres, Typescript, Javascript, React","postgres-rls-multi-tenant-saas",[13,14,15,16,17,18,19,20,21],"/images/articles/postgres-rls-multi-tenant-saas/postgres-rls-multi-tenant-saas-640w.avif","/images/articles/postgres-rls-multi-tenant-saas/postgres-rls-multi-tenant-saas-1024w.avif","/images/articles/postgres-rls-multi-tenant-saas/postgres-rls-multi-tenant-saas-1920w.avif","/images/articles/postgres-rls-multi-tenant-saas/postgres-rls-multi-tenant-saas-640w.webp","/images/articles/postgres-rls-multi-tenant-saas/postgres-rls-multi-tenant-saas-1024w.webp","/images/articles/postgres-rls-multi-tenant-saas/postgres-rls-multi-tenant-saas-1920w.webp","/images/articles/postgres-rls-multi-tenant-saas/postgres-rls-multi-tenant-saas-640w.png","/images/articles/postgres-rls-multi-tenant-saas/postgres-rls-multi-tenant-saas-1024w.png","/images/articles/postgres-rls-multi-tenant-saas/postgres-rls-multi-tenant-saas-1920w.png","A single Postgres database with two tenant lanes flowing in. A policy gate sits in the middle. Tenant A passes through, Tenant B bounces off.",true,"2026-07-03T00:00:00","2026-08-01T00:00:00",[27,30,33,36,39],{"question":28,"answer":29},"Is Postgres row level security production ready for multi-tenant SaaS?","Yes. Postgres ships RLS as a first-party feature and the pattern is stable when you set the tenant inside a transaction with set_config and write CREATE POLICY against that GUC. The gotchas are connection pooling and SECURITY DEFINER functions, not the RLS engine itself. Postgres has supported RLS since 9.5, and every supported major from 14 onward handles the GUC pattern the same way.",{"question":31,"answer":32},"How do you set the tenant ID from a Node app without the Supabase client?","Open a transaction, call set_config('app.tenant_id', $1, true) as the first statement, then run the query. The third argument, true, scopes the GUC to the transaction so a pooled connection cannot leak the value to the next request. Drizzle and Prisma both expose hooks for this, and raw node-postgres works the same way once you wrap every request in a transaction.",{"question":34,"answer":35},"Does row level security slow down queries?","RLS adds a predicate to every plan, so any column referenced in a policy needs an index. With tenant_id indexed and the policy written as a simple equality check, the planner usually folds the predicate into the index scan and the overhead lands in the low single digit percent. Without the index the cost can be an order of magnitude. The fix is always to add the index, never to weaken the policy.",{"question":37,"answer":38},"Can I use RLS with Drizzle ORM or Prisma?","Both work. Drizzle has first-party RLS helpers and you wire set_config inside db.transaction. Prisma supports it through client extensions or interactive transactions where the first statement sets the GUC. The pattern is the same. The wiring differs.",{"question":40,"answer":41},"What happens if I forget to set the tenant for a request?","The policy denies every row. The session GUC defaults to an empty string, the policy compares it to a real tenant id, and nothing matches. That is the right failure mode for a security control, and the wrong failure mode for a feature, which is why every request path needs a middleware that opens the transaction and sets the GUC before the handler runs.",{"type":43,"children":44,"toc":1406},"root",[45,78,85,138,143,180,185,191,219,252,265,271,276,283,296,305,317,362,367,373,426,434,445,471,477,514,522,531,580,585,591,613,633,647,652,658,670,676,706,714,723,797,805,814,827,841,864,872,881,900,908,917,961,966,972,985,993,1002,1014,1019,1025,1039,1047,1056,1093,1106,1111,1117,1136,1141,1149,1158,1194,1207,1212,1218,1231,1396,1401],{"type":46,"tag":47,"props":48,"children":49},"element","p",{},[50,53,60,62,68,70,76],{"type":51,"value":52},"text","The Supabase ",{"type":46,"tag":54,"props":55,"children":57},"code",{"className":56},[],[58],{"type":51,"value":59},"tenant_id = auth.uid()",{"type":51,"value":61}," policy falls apart the moment you leave Supabase. Postgres row level security for a multi-tenant SaaS on self-hosted Postgres is one transaction, one ",{"type":46,"tag":54,"props":63,"children":65},{"className":64},[],[66],{"type":51,"value":67},"set_config('app.tenant_id', $1, true)",{"type":51,"value":69},", and one ",{"type":46,"tag":54,"props":71,"children":73},{"className":72},[],[74],{"type":51,"value":75},"CREATE POLICY",{"type":51,"value":77}," against that GUC. This article walks the pattern on a Node app, the connection pool trap, the Drizzle and Prisma wiring, and the negative tests that prove cross-tenant reads are denied.",{"type":46,"tag":79,"props":80,"children":82},"h2",{"id":81},"tldr-the-four-line-rls-pattern-that-survives-an-audit",[83],{"type":51,"value":84},"TL;DR, the four-line RLS pattern that survives an audit",{"type":46,"tag":47,"props":86,"children":87},{},[88,90,96,98,104,106,112,114,120,122,128,130,136],{"type":51,"value":89},"Row level security for a multi-tenant SaaS is four lines of SQL wrapped around every request. Open a transaction, set a ",{"type":46,"tag":91,"props":92,"children":93},"em",{},[94],{"type":51,"value":95},"GUC",{"type":51,"value":97}," (grand unified configuration parameter) called ",{"type":46,"tag":54,"props":99,"children":101},{"className":100},[],[102],{"type":51,"value":103},"app.tenant_id",{"type":51,"value":105}," with ",{"type":46,"tag":54,"props":107,"children":109},{"className":108},[],[110],{"type":51,"value":111},"set_config(..., true)",{"type":51,"value":113}," so the value is scoped to the transaction, run the actual query, then commit. The policy on every tenant-scoped table reads ",{"type":46,"tag":54,"props":115,"children":117},{"className":116},[],[118],{"type":51,"value":119},"current_setting('app.tenant_id', true)::uuid",{"type":51,"value":121}," and matches it against the row's ",{"type":46,"tag":54,"props":123,"children":125},{"className":124},[],[126],{"type":51,"value":127},"tenant_id",{"type":51,"value":129}," column. Pooled connections that leak the GUC across requests are the single highest-impact risk, and the ",{"type":46,"tag":54,"props":131,"children":133},{"className":132},[],[134],{"type":51,"value":135},"true",{"type":51,"value":137}," argument is what prevents it.",{"type":46,"tag":47,"props":139,"children":140},{},[141],{"type":51,"value":142},"The four lines, in order:",{"type":46,"tag":144,"props":145,"children":146},"ol",{},[147,157,166,171],{"type":46,"tag":148,"props":149,"children":150},"li",{},[151],{"type":46,"tag":54,"props":152,"children":154},{"className":153},[],[155],{"type":51,"value":156},"BEGIN;",{"type":46,"tag":148,"props":158,"children":159},{},[160],{"type":46,"tag":54,"props":161,"children":163},{"className":162},[],[164],{"type":51,"value":165},"SELECT set_config('app.tenant_id', $1, true);",{"type":46,"tag":148,"props":167,"children":168},{},[169],{"type":51,"value":170},"The actual query.",{"type":46,"tag":148,"props":172,"children":173},{},[174],{"type":46,"tag":54,"props":175,"children":177},{"className":176},[],[178],{"type":51,"value":179},"COMMIT;",{"type":46,"tag":47,"props":181,"children":182},{},[183],{"type":51,"value":184},"Four lines. One audit-safe boundary.",{"type":46,"tag":79,"props":186,"children":188},{"id":187},"why-tenant_id-columns-alone-are-not-enough",[189],{"type":51,"value":190},"Why tenant_id columns alone are not enough",{"type":46,"tag":47,"props":192,"children":193},{},[194,196,201,203,209,211,217],{"type":51,"value":195},"Every multi-tenant app starts the same way. Each table gets a ",{"type":46,"tag":54,"props":197,"children":199},{"className":198},[],[200],{"type":51,"value":127},{"type":51,"value":202}," column, every query gets a ",{"type":46,"tag":54,"props":204,"children":206},{"className":205},[],[207],{"type":51,"value":208},"WHERE tenant_id = $1",{"type":51,"value":210},", and code review is supposed to catch the one place a junior developer forgets the predicate. The problem is that code review does not catch every one. A single ",{"type":46,"tag":54,"props":212,"children":214},{"className":213},[],[215],{"type":51,"value":216},"SELECT * FROM invoices WHERE id = $1",{"type":51,"value":218}," in a debug script, a forgotten join in an admin endpoint, or an aggregate over an internal report is enough to cross-tenant a row into the wrong customer's response. The pain compounds in audits: a SOC 2 reviewer asks \"what control prevents tenant A from reading tenant B's data\" and the honest answer is \"the developers remembered to add a WHERE clause\", which is not a control.",{"type":46,"tag":47,"props":220,"children":221},{},[222,224,233,235,241,243,250],{"type":51,"value":223},"RLS moves that control into the database. The policy runs on every read, every write, every join, even queries the application layer never wrote, and the database refuses to return rows that do not match. As a canonical reference, the ",{"type":46,"tag":225,"props":226,"children":230},"a",{"href":227,"rel":228},"https://supabase.com/docs/guides/database/postgres/row-level-security",[229],"nofollow",[231],{"type":51,"value":232},"Supabase row level security guide",{"type":51,"value":234}," is excellent at the basics. The pattern it teaches relies on Supabase-managed JWT claims through ",{"type":46,"tag":54,"props":236,"children":238},{"className":237},[],[239],{"type":51,"value":240},"auth.uid()",{"type":51,"value":242},", which works inside Supabase and does not exist on a self-hosted Postgres. ",{"type":46,"tag":225,"props":244,"children":247},{"href":245,"rel":246},"https://www.crunchydata.com/blog/row-level-security-for-tenants-in-postgres",[229],[248],{"type":51,"value":249},"Crunchy Data's tenant RLS post",{"type":51,"value":251}," covers the same idea on plain Postgres and is the closest second source to the pattern this article extends. The argument for RLS is not that it replaces application-layer checks; it is that it makes a forgotten check non-fatal. Defense in depth, one layer down.",{"type":46,"tag":47,"props":253,"children":254},{},[255,257,263],{"type":51,"value":256},"A ",{"type":46,"tag":54,"props":258,"children":260},{"className":259},[],[261],{"type":51,"value":262},"WHERE",{"type":51,"value":264}," clause is a convention. A policy is a contract.",{"type":46,"tag":79,"props":266,"children":268},{"id":267},"the-production-rls-pattern-step-by-step",[269],{"type":51,"value":270},"The production RLS pattern, step by step",{"type":46,"tag":47,"props":272,"children":273},{},[274],{"type":51,"value":275},"The pattern has four pieces and they all matter together: enable RLS on the table, set the tenant inside a transaction, write the policy against the GUC, and respect the connection pool. Each piece is one line of code or one SQL statement. Skip any one and the others stop protecting you.",{"type":46,"tag":277,"props":278,"children":280},"h3",{"id":279},"enabling-row-level-security-on-the-table",[281],{"type":51,"value":282},"Enabling row level security on the table",{"type":46,"tag":47,"props":284,"children":285},{},[286,288,294],{"type":51,"value":287},"Two ",{"type":46,"tag":54,"props":289,"children":291},{"className":290},[],[292],{"type":51,"value":293},"ALTER TABLE",{"type":51,"value":295}," statements turn the feature on. The first enables RLS, the second forces it for table owners.",{"type":46,"tag":47,"props":297,"children":298},{},[299],{"type":46,"tag":300,"props":301,"children":302},"strong",{},[303],{"type":51,"value":304},"src/db/schema.sql",{"type":46,"tag":306,"props":307,"children":312},"pre",{"className":308,"code":310,"language":311,"meta":6},[309],"language-sql","CREATE TABLE invoices (\n  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n  tenant_id uuid NOT NULL,\n  customer text NOT NULL,\n  total numeric(12, 2) NOT NULL,\n  created_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE INDEX invoices_tenant_id_idx ON invoices (tenant_id);\n\nALTER TABLE invoices ENABLE ROW LEVEL SECURITY;\nALTER TABLE invoices FORCE ROW LEVEL SECURITY;\n","sql",[313],{"type":46,"tag":54,"props":314,"children":315},{"__ignoreMap":6},[316],{"type":51,"value":310},{"type":46,"tag":47,"props":318,"children":319},{},[320,322,328,330,336,338,345,347,353,355,360],{"type":51,"value":321},"The ",{"type":46,"tag":54,"props":323,"children":325},{"className":324},[],[326],{"type":51,"value":327},"ENABLE",{"type":51,"value":329}," line activates policies for normal roles. ",{"type":46,"tag":54,"props":331,"children":333},{"className":332},[],[334],{"type":51,"value":335},"FORCE",{"type":51,"value":337}," is the line most tutorials skip, and it is the one an auditor will ask about. By default, the owner of a table bypasses RLS, which means the role that ran your migrations can read every row and policies do not apply. The ",{"type":46,"tag":225,"props":339,"children":342},{"href":340,"rel":341},"https://www.postgresql.org/docs/current/ddl-rowsecurity.html",[229],[343],{"type":51,"value":344},"Postgres docs on row security",{"type":51,"value":346}," call out this carve-out explicitly. ",{"type":46,"tag":54,"props":348,"children":350},{"className":349},[],[351],{"type":51,"value":352},"FORCE ROW LEVEL SECURITY",{"type":51,"value":354}," removes it. The index on ",{"type":46,"tag":54,"props":356,"children":358},{"className":357},[],[359],{"type":51,"value":127},{"type":51,"value":361}," is not optional either; the policy will reference that column on every query, so the planner needs an index to keep the cost down.",{"type":46,"tag":47,"props":363,"children":364},{},[365],{"type":51,"value":366},"Owners bypass policies until you force them not to.",{"type":46,"tag":277,"props":368,"children":370},{"id":369},"setting-the-tenant-with-set_config-inside-a-transaction",[371],{"type":51,"value":372},"Setting the tenant with set_config inside a transaction",{"type":46,"tag":47,"props":374,"children":375},{},[376,378,384,386,391,393,400,402,408,410,417,419,424],{"type":51,"value":377},"The GUC is how the request hands the tenant identity to the database. Postgres ships a function called ",{"type":46,"tag":54,"props":379,"children":381},{"className":380},[],[382],{"type":51,"value":383},"set_config",{"type":51,"value":385}," that writes to a session variable; with the third argument set to ",{"type":46,"tag":54,"props":387,"children":389},{"className":388},[],[390],{"type":51,"value":135},{"type":51,"value":392},", the write is scoped to the surrounding transaction and reverts on commit or rollback. The ",{"type":46,"tag":225,"props":394,"children":397},{"href":395,"rel":396},"https://www.postgresql.org/docs/current/sql-set.html",[229],[398],{"type":51,"value":399},"Postgres SET reference",{"type":51,"value":401}," describes the same semantics under the ",{"type":46,"tag":54,"props":403,"children":405},{"className":404},[],[406],{"type":51,"value":407},"LOCAL",{"type":51,"value":409}," keyword, and ",{"type":46,"tag":225,"props":411,"children":414},{"href":412,"rel":413},"https://www.postgresql.org/docs/current/runtime-config-custom.html",[229],[415],{"type":51,"value":416},"custom GUC variables",{"type":51,"value":418}," explain why a two-part name like ",{"type":46,"tag":54,"props":420,"children":422},{"className":421},[],[423],{"type":51,"value":103},{"type":51,"value":425}," is allowed at all (Postgres reserves single-word GUCs for itself and accepts namespaced ones for applications).",{"type":46,"tag":47,"props":427,"children":428},{},[429],{"type":46,"tag":300,"props":430,"children":431},{},[432],{"type":51,"value":433},"src/db/with-tenant.ts",{"type":46,"tag":306,"props":435,"children":440},{"className":436,"code":438,"language":439,"meta":6},[437],"language-ts","import type { PoolClient } from 'pg'\n\nexport async function withTenant\u003CT>(\n  client: PoolClient,\n  tenantId: string,\n  work: (client: PoolClient) => Promise\u003CT>,\n): Promise\u003CT> {\n  await client.query('BEGIN')\n  try {\n    await client.query('SELECT set_config($1, $2, true)', [\n      'app.tenant_id',\n      tenantId,\n    ])\n    const result = await work(client)\n    await client.query('COMMIT')\n    return result\n  } catch (error) {\n    await client.query('ROLLBACK')\n    throw error\n  }\n}\n","ts",[441],{"type":46,"tag":54,"props":442,"children":443},{"__ignoreMap":6},[444],{"type":51,"value":438},{"type":46,"tag":47,"props":446,"children":447},{},[448,450,455,457,462,464,469],{"type":51,"value":449},"The wrapper does three things you cannot afford to do by hand on every endpoint. It begins a transaction so the ",{"type":46,"tag":54,"props":451,"children":453},{"className":452},[],[454],{"type":51,"value":383},{"type":51,"value":456}," write has a scope, it sets the GUC as the first statement so the policy has something to read, and it rolls back on any thrown error so a half-committed update never lands. The ",{"type":46,"tag":54,"props":458,"children":460},{"className":459},[],[461],{"type":51,"value":135},{"type":51,"value":463}," argument is the line that matters most. Without it, the GUC stays on the session, the connection returns to the pool with a tenant id baked in, and the next request can run a query with the previous tenant's permissions. With ",{"type":46,"tag":54,"props":465,"children":467},{"className":466},[],[468],{"type":51,"value":135},{"type":51,"value":470},", the value evaporates on commit. Same connection, clean slate.",{"type":46,"tag":277,"props":472,"children":474},{"id":473},"writing-the-create-policy-against-a-guc-not-authuid",[475],{"type":51,"value":476},"Writing the CREATE POLICY against a GUC, not auth.uid()",{"type":46,"tag":47,"props":478,"children":479},{},[480,482,487,489,496,498,504,506,512],{"type":51,"value":481},"The policy is the boundary. It reads the GUC, compares it to the row's ",{"type":46,"tag":54,"props":483,"children":485},{"className":484},[],[486],{"type":51,"value":127},{"type":51,"value":488},", and Postgres skips any row that does not match without ever telling the application the row existed. The ",{"type":46,"tag":225,"props":490,"children":493},{"href":491,"rel":492},"https://www.postgresql.org/docs/current/sql-createpolicy.html",[229],[494],{"type":51,"value":495},"CREATE POLICY reference",{"type":51,"value":497}," documents the four commands you can attach a policy to and the ",{"type":46,"tag":54,"props":499,"children":501},{"className":500},[],[502],{"type":51,"value":503},"USING",{"type":51,"value":505}," and ",{"type":46,"tag":54,"props":507,"children":509},{"className":508},[],[510],{"type":51,"value":511},"WITH CHECK",{"type":51,"value":513}," clauses that gate reads and writes.",{"type":46,"tag":47,"props":515,"children":516},{},[517],{"type":46,"tag":300,"props":518,"children":519},{},[520],{"type":51,"value":521},"src/db/policies.sql",{"type":46,"tag":306,"props":523,"children":526},{"className":524,"code":525,"language":311,"meta":6},[309],"CREATE POLICY tenant_isolation_select ON invoices\n  FOR SELECT\n  USING (tenant_id = current_setting('app.tenant_id', true)::uuid);\n\nCREATE POLICY tenant_isolation_insert ON invoices\n  FOR INSERT\n  WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);\n\nCREATE POLICY tenant_isolation_update ON invoices\n  FOR UPDATE\n  USING (tenant_id = current_setting('app.tenant_id', true)::uuid)\n  WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);\n\nCREATE POLICY tenant_isolation_delete ON invoices\n  FOR DELETE\n  USING (tenant_id = current_setting('app.tenant_id', true)::uuid);\n",[527],{"type":46,"tag":54,"props":528,"children":529},{"__ignoreMap":6},[530],{"type":51,"value":525},{"type":46,"tag":47,"props":532,"children":533},{},[534,535,540,542,548,550,556,558,563,565,571,573,578],{"type":51,"value":321},{"type":46,"tag":54,"props":536,"children":538},{"className":537},[],[539],{"type":51,"value":135},{"type":51,"value":541}," second argument to ",{"type":46,"tag":54,"props":543,"children":545},{"className":544},[],[546],{"type":51,"value":547},"current_setting",{"type":51,"value":549}," is the one new readers always miss. By default, ",{"type":46,"tag":54,"props":551,"children":553},{"className":552},[],[554],{"type":51,"value":555},"current_setting('app.tenant_id')",{"type":51,"value":557}," raises an error if the GUC was never set. The ",{"type":46,"tag":54,"props":559,"children":561},{"className":560},[],[562],{"type":51,"value":135},{"type":51,"value":564}," flag tells Postgres to return an empty string instead, which then casts to a null uuid, which then fails the equality check, which denies every row. This is what makes a forgotten middleware safe: the failure mode is zero rows, not a leaked row from the previous request. Four policies cover the four DML commands; a single ",{"type":46,"tag":54,"props":566,"children":568},{"className":567},[],[569],{"type":51,"value":570},"FOR ALL",{"type":51,"value":572}," policy would work if read and write rules are identical, but splitting them keeps the audit trail readable when you eventually need a different rule for inserts. The ",{"type":46,"tag":54,"props":574,"children":576},{"className":575},[],[577],{"type":51,"value":511},{"type":51,"value":579}," clause on insert and update is what stops a tenant from writing a row tagged as another tenant.",{"type":46,"tag":47,"props":581,"children":582},{},[583],{"type":51,"value":584},"The GUC is the API between your application and the policy engine.",{"type":46,"tag":277,"props":586,"children":588},{"id":587},"connection-pooling-and-the-reset-on-checkout-problem",[589],{"type":51,"value":590},"Connection pooling and the reset-on-checkout problem",{"type":46,"tag":47,"props":592,"children":593},{},[594,596,602,604,611],{"type":51,"value":595},"The pool is where the pattern usually breaks. A naive implementation sets the GUC with ",{"type":46,"tag":54,"props":597,"children":599},{"className":598},[],[600],{"type":51,"value":601},"SET app.tenant_id = ...",{"type":51,"value":603}," outside a transaction, the connection returns to the pool, and the next checkout inherits the value. The ",{"type":46,"tag":225,"props":605,"children":608},{"href":606,"rel":607},"https://node-postgres.com/features/pooling",[229],[609],{"type":51,"value":610},"node-postgres pooling docs",{"type":51,"value":612}," explain that pooled connections are reused across requests, which is the whole point of pooling and the whole risk for RLS. Two mitigations work together.",{"type":46,"tag":47,"props":614,"children":615},{},[616,618,623,625,631],{"type":51,"value":617},"First, always set the GUC with ",{"type":46,"tag":54,"props":619,"children":621},{"className":620},[],[622],{"type":51,"value":111},{"type":51,"value":624}," inside a transaction. The transaction-scoped write reverts on commit or rollback, so the connection returns to the pool with the GUC cleared. Wrapping every code path that uses the pool in ",{"type":46,"tag":54,"props":626,"children":628},{"className":627},[],[629],{"type":51,"value":630},"withTenant",{"type":51,"value":632}," enforces this.",{"type":46,"tag":47,"props":634,"children":635},{},[636,638,645],{"type":51,"value":637},"Second, if you sit behind ",{"type":46,"tag":225,"props":639,"children":642},{"href":640,"rel":641},"https://www.pgbouncer.org/features.html",[229],[643],{"type":51,"value":644},"PgBouncer",{"type":51,"value":646},", run it in transaction mode, not session mode. Transaction mode binds a pooled connection to a single transaction at a time, which matches the lifetime of the GUC and gives you back the per-request safety the application code already assumes. Session mode binds the connection for the whole client session, which is the opposite of what you want here. That is fine for a worker that owns a connection for its entire lifetime; it is wrong for a web tier where one request hands the connection to the next.",{"type":46,"tag":47,"props":648,"children":649},{},[650],{"type":51,"value":651},"A shared pool is only safe when every tenant-scoped query runs inside its own transaction.",{"type":46,"tag":79,"props":653,"children":655},{"id":654},"wiring-rls-through-drizzle-or-prisma-without-escape-hatches",[656],{"type":51,"value":657},"Wiring RLS through Drizzle or Prisma without escape hatches",{"type":46,"tag":47,"props":659,"children":660},{},[661,663,668],{"type":51,"value":662},"The ORM should not let you forget the GUC. Every tenant-scoped query in the application has to flow through a transaction that sets ",{"type":46,"tag":54,"props":664,"children":666},{"className":665},[],[667],{"type":51,"value":103},{"type":51,"value":669}," before any other statement, and the way to enforce that is to put the transaction inside a middleware the route handler cannot bypass. Both Drizzle and Prisma expose the hooks you need; the wiring differs.",{"type":46,"tag":277,"props":671,"children":673},{"id":672},"drizzle-transactions-and-the-tenant-middleware",[674],{"type":51,"value":675},"Drizzle transactions and the tenant middleware",{"type":46,"tag":47,"props":677,"children":678},{},[679,681,688,690,695,697,704],{"type":51,"value":680},"Drizzle ships a ",{"type":46,"tag":225,"props":682,"children":685},{"href":683,"rel":684},"https://orm.drizzle.team/docs/transactions",[229],[686],{"type":51,"value":687},"transactions API",{"type":51,"value":689}," that maps directly onto the ",{"type":46,"tag":54,"props":691,"children":693},{"className":692},[],[694],{"type":51,"value":630},{"type":51,"value":696}," pattern, and an ",{"type":46,"tag":225,"props":698,"children":701},{"href":699,"rel":700},"https://orm.drizzle.team/docs/rls",[229],[702],{"type":51,"value":703},"RLS helper module",{"type":51,"value":705}," for declaring policies alongside the schema. The middleware shape below works for any framework that exposes a request lifecycle.",{"type":46,"tag":47,"props":707,"children":708},{},[709],{"type":46,"tag":300,"props":710,"children":711},{},[712],{"type":51,"value":713},"src/db/drizzle-tenant.ts",{"type":46,"tag":306,"props":715,"children":718},{"className":716,"code":717,"language":439,"meta":6},[437],"import { drizzle } from 'drizzle-orm/node-postgres'\nimport { sql } from 'drizzle-orm'\nimport { Pool } from 'pg'\n\nconst pool = new Pool({ connectionString: process.env.DATABASE_URL })\nexport const db = drizzle(pool)\n\nexport async function withTenantTx\u003CT>(\n  tenantId: string,\n  work: (tx: Parameters\u003CParameters\u003Ctypeof db.transaction>[0]>[0]) => Promise\u003CT>,\n): Promise\u003CT> {\n  return db.transaction(async (tx) => {\n    await tx.execute(\n      sql`SELECT set_config('app.tenant_id', ${tenantId}, true)`,\n    )\n    return work(tx)\n  })\n}\n",[719],{"type":46,"tag":54,"props":720,"children":721},{"__ignoreMap":6},[722],{"type":51,"value":717},{"type":46,"tag":47,"props":724,"children":725},{},[726,728,734,736,741,743,749,751,757,758,764,766,772,774,780,782,788,790,795],{"type":51,"value":727},"The exported ",{"type":46,"tag":54,"props":729,"children":731},{"className":730},[],[732],{"type":51,"value":733},"withTenantTx",{"type":51,"value":735}," is the only function any route handler is allowed to call. It opens a Drizzle transaction, runs ",{"type":46,"tag":54,"props":737,"children":739},{"className":738},[],[740],{"type":51,"value":383},{"type":51,"value":742}," as the first statement inside that transaction, and hands the transaction object to the caller. Any ",{"type":46,"tag":54,"props":744,"children":746},{"className":745},[],[747],{"type":51,"value":748},"select",{"type":51,"value":750},", ",{"type":46,"tag":54,"props":752,"children":754},{"className":753},[],[755],{"type":51,"value":756},"insert",{"type":51,"value":750},{"type":46,"tag":54,"props":759,"children":761},{"className":760},[],[762],{"type":51,"value":763},"update",{"type":51,"value":765},", or ",{"type":46,"tag":54,"props":767,"children":769},{"className":768},[],[770],{"type":51,"value":771},"delete",{"type":51,"value":773}," the handler issues runs inside the same transaction, so the policy fires with the right GUC every time. The escape hatch a careless reviewer might miss is a direct call to ",{"type":46,"tag":54,"props":775,"children":777},{"className":776},[],[778],{"type":51,"value":779},"db.select()",{"type":51,"value":781}," outside the wrapper; the way to close it is to mark ",{"type":46,"tag":54,"props":783,"children":785},{"className":784},[],[786],{"type":51,"value":787},"db",{"type":51,"value":789}," as internal and only export ",{"type":46,"tag":54,"props":791,"children":793},{"className":792},[],[794],{"type":51,"value":733},{"type":51,"value":796}," from the module, so the type system itself stops the bypass.",{"type":46,"tag":47,"props":798,"children":799},{},[800],{"type":46,"tag":300,"props":801,"children":802},{},[803],{"type":51,"value":804},"src/routes/invoices.ts",{"type":46,"tag":306,"props":806,"children":809},{"className":807,"code":808,"language":439,"meta":6},[437],"import { withTenantTx } from '@/db/drizzle-tenant'\nimport { invoices } from '@/db/schema'\n\nexport async function listInvoices(tenantId: string) {\n  return withTenantTx(tenantId, async (tx) => {\n    return tx.select().from(invoices)\n  })\n}\n",[810],{"type":46,"tag":54,"props":811,"children":812},{"__ignoreMap":6},[813],{"type":51,"value":808},{"type":46,"tag":47,"props":815,"children":816},{},[817,819,825],{"type":51,"value":818},"The handler reads as if no policy existed at all. There is no ",{"type":46,"tag":54,"props":820,"children":822},{"className":821},[],[823],{"type":51,"value":824},"WHERE tenant_id =",{"type":51,"value":826}," in sight, because the policy adds one to the plan automatically, and that is the point. Application code stays clean, the boundary stays in the database, and a forgotten predicate is a non-event because the policy filled it in. The trade-off is that you cannot use Drizzle's logger to spot a missing tenant filter; you need a negative test instead, which the next section covers.",{"type":46,"tag":277,"props":828,"children":830},{"id":829},"prisma-extends-and-the-same-idea",[831,833,839],{"type":51,"value":832},"Prisma ",{"type":46,"tag":54,"props":834,"children":836},{"className":835},[],[837],{"type":51,"value":838},"$extends",{"type":51,"value":840}," and the same idea",{"type":46,"tag":47,"props":842,"children":843},{},[844,846,853,855,862],{"type":51,"value":845},"Prisma's hook surface is different but the pattern is identical. The ",{"type":46,"tag":225,"props":847,"children":850},{"href":848,"rel":849},"https://www.prisma.io/docs/orm/prisma-client/client-extensions",[229],[851],{"type":51,"value":852},"Prisma client extensions",{"type":51,"value":854}," API lets you wrap every query with custom logic, and ",{"type":46,"tag":225,"props":856,"children":859},{"href":857,"rel":858},"https://www.prisma.io/docs/orm/prisma-client/queries/transactions",[229],[860],{"type":51,"value":861},"interactive transactions",{"type":51,"value":863}," give you the same kind of begin-set-run-commit lifecycle Drizzle does.",{"type":46,"tag":47,"props":865,"children":866},{},[867],{"type":46,"tag":300,"props":868,"children":869},{},[870],{"type":51,"value":871},"src/db/prisma-tenant.ts",{"type":46,"tag":306,"props":873,"children":876},{"className":874,"code":875,"language":439,"meta":6},[437],"import { PrismaClient } from '@prisma/client'\n\nconst prisma = new PrismaClient()\n\nexport async function withTenantTx\u003CT>(\n  tenantId: string,\n  work: (tx: Parameters\u003CParameters\u003Ctypeof prisma.$transaction>[0]>[0]) => Promise\u003CT>,\n): Promise\u003CT> {\n  return prisma.$transaction(async (tx) => {\n    await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`\n    return work(tx)\n  })\n}\n",[877],{"type":46,"tag":54,"props":878,"children":879},{"__ignoreMap":6},[880],{"type":51,"value":875},{"type":46,"tag":47,"props":882,"children":883},{},[884,886,891,893,898],{"type":51,"value":885},"The mechanics match. An interactive transaction opens, the first statement is a raw ",{"type":46,"tag":54,"props":887,"children":889},{"className":888},[],[890],{"type":51,"value":383},{"type":51,"value":892},", and the work callback runs against the transaction-bound client. Raw SQL is unavoidable for the GUC write because the value Prisma sets through its query API would be parameterized differently and would not satisfy ",{"type":46,"tag":54,"props":894,"children":896},{"className":895},[],[897],{"type":51,"value":383},{"type":51,"value":899},"'s function signature. Everything else stays Prisma-native: relations, includes, generated types, the lot.",{"type":46,"tag":47,"props":901,"children":902},{},[903],{"type":46,"tag":300,"props":904,"children":905},{},[906],{"type":51,"value":907},"src/routes/invoices-prisma.ts",{"type":46,"tag":306,"props":909,"children":912},{"className":910,"code":911,"language":439,"meta":6},[437],"import { withTenantTx } from '@/db/prisma-tenant'\n\nexport async function listInvoicesPrisma(tenantId: string) {\n  return withTenantTx(tenantId, (tx) => tx.invoice.findMany())\n}\n",[913],{"type":46,"tag":54,"props":914,"children":915},{"__ignoreMap":6},[916],{"type":51,"value":911},{"type":46,"tag":47,"props":918,"children":919},{},[920,922,928,930,936,938,944,946,951,953,959],{"type":51,"value":921},"Note ",{"type":46,"tag":54,"props":923,"children":925},{"className":924},[],[926],{"type":51,"value":927},"findMany()",{"type":51,"value":929}," with no ",{"type":46,"tag":54,"props":931,"children":933},{"className":932},[],[934],{"type":51,"value":935},"where",{"type":51,"value":937},". The policy supplies the predicate, and any code reviewer who sees a Prisma call inside this wrapper can trust the database to filter. Guard the same seam as Drizzle: do not export the raw ",{"type":46,"tag":54,"props":939,"children":941},{"className":940},[],[942],{"type":51,"value":943},"PrismaClient",{"type":51,"value":945}," from this module, only ",{"type":46,"tag":54,"props":947,"children":949},{"className":948},[],[950],{"type":51,"value":733},{"type":51,"value":952},", so a handler cannot reach ",{"type":46,"tag":54,"props":954,"children":956},{"className":955},[],[957],{"type":51,"value":958},"prisma.invoice.findMany()",{"type":51,"value":960}," directly and bypass the policy.",{"type":46,"tag":47,"props":962,"children":963},{},[964],{"type":51,"value":965},"Same pattern. Same trade-off. Different surface.",{"type":46,"tag":79,"props":967,"children":969},{"id":968},"negative-tests-the-part-everyone-skips",[970],{"type":51,"value":971},"Negative tests, the part everyone skips",{"type":46,"tag":47,"props":973,"children":974},{},[975,977,983],{"type":51,"value":976},"A policy is only as good as the test that proves it denies. The positive test (tenant A sees tenant A's invoices) is the one every team writes; the negative test (tenant A sees zero of tenant B's invoices) is the one that catches the regression where someone disabled RLS on a migration and nobody noticed. A companion piece on ",{"type":46,"tag":225,"props":978,"children":980},{"href":979},"/blog/ai-code-review-checklist-react-vue",[981],{"type":51,"value":982},"an AI code review checklist for React and Vue",{"type":51,"value":984}," covers the wider habit of writing reviewer-grade tests; here the same principle lands on the data layer.",{"type":46,"tag":47,"props":986,"children":987},{},[988],{"type":46,"tag":300,"props":989,"children":990},{},[991],{"type":51,"value":992},"src/db/policies.test.ts",{"type":46,"tag":306,"props":994,"children":997},{"className":995,"code":996,"language":439,"meta":6},[437],"import { afterAll, beforeAll, describe, expect, it } from 'vitest'\nimport { Pool } from 'pg'\nimport { withTenant } from '@/db/with-tenant'\n\nconst pool = new Pool({ connectionString: process.env.DATABASE_URL })\n\nconst tenantA = '11111111-1111-1111-1111-111111111111'\nconst tenantB = '22222222-2222-2222-2222-222222222222'\n\nbeforeAll(async () => {\n  const client = await pool.connect()\n  try {\n    await client.query('TRUNCATE invoices')\n    await client.query(\n      `INSERT INTO invoices (tenant_id, customer, total) VALUES\n       ($1, 'Acme', 100.00),\n       ($2, 'Globex', 200.00)`,\n      [tenantA, tenantB],\n    )\n  } finally {\n    client.release()\n  }\n})\n\nafterAll(async () => {\n  await pool.end()\n})\n\ndescribe('invoices RLS policy', () => {\n  it('returns only tenant A rows when tenant A is set', async () => {\n    const client = await pool.connect()\n    try {\n      const rows = await withTenant(client, tenantA, async (c) => {\n        const { rows } = await c.query('SELECT customer FROM invoices')\n        return rows\n      })\n      expect(rows).toEqual([{ customer: 'Acme' }])\n    } finally {\n      client.release()\n    }\n  })\n\n  it('returns zero rows when no tenant is set', async () => {\n    const client = await pool.connect()\n    try {\n      const { rows } = await client.query('SELECT customer FROM invoices')\n      expect(rows).toEqual([])\n    } finally {\n      client.release()\n    }\n  })\n\n  it('denies cross-tenant insert via WITH CHECK', async () => {\n    const client = await pool.connect()\n    try {\n      await expect(\n        withTenant(client, tenantA, async (c) => {\n          await c.query(\n            'INSERT INTO invoices (tenant_id, customer, total) VALUES ($1, $2, $3)',\n            [tenantB, 'Initech', 999],\n          )\n        }),\n      ).rejects.toThrow(/row-level security/i)\n    } finally {\n      client.release()\n    }\n  })\n})\n",[998],{"type":46,"tag":54,"props":999,"children":1000},{"__ignoreMap":6},[1001],{"type":51,"value":996},{"type":46,"tag":47,"props":1003,"children":1004},{},[1005,1007,1012],{"type":51,"value":1006},"Three assertions cover the failure modes that matter. Tenant A reads tenant A's row and nothing else. A request with no tenant set reads nothing, which is the safe failure mode. An attempt to insert a row tagged as another tenant gets rejected by the ",{"type":46,"tag":54,"props":1008,"children":1010},{"className":1009},[],[1011],{"type":51,"value":511},{"type":51,"value":1013}," clause and surfaces as a Postgres error. The seed data is intentionally minimal so the test is fast and so a future change to the policy is the only reason any of these assertions can flip. I have shipped a trick on more than one project: run this test against the migrated schema in CI before any application code runs, so a missing RLS migration fails the build before the deploy.",{"type":46,"tag":47,"props":1015,"children":1016},{},[1017],{"type":51,"value":1018},"Tests that prove denials are the only tests that prove a boundary.",{"type":46,"tag":79,"props":1020,"children":1022},{"id":1021},"performance-indexes-and-the-explain-plan-with-rls-on",[1023],{"type":51,"value":1024},"Performance, indexes, and the EXPLAIN plan with RLS on",{"type":46,"tag":47,"props":1026,"children":1027},{},[1028,1030,1037],{"type":51,"value":1029},"RLS is not free. The policy adds a predicate to every plan, and Postgres has to evaluate it on every row the planner considers. With the right index, the predicate folds into the index scan and the overhead is invisible. Without the index, the same query degrades to a sequential scan and the overhead is enormous. The ",{"type":46,"tag":225,"props":1031,"children":1034},{"href":1032,"rel":1033},"https://www.postgresql.org/docs/current/sql-explain.html",[229],[1035],{"type":51,"value":1036},"Postgres EXPLAIN docs",{"type":51,"value":1038}," describe how to read the plan and confirm which scan node is responsible.",{"type":46,"tag":47,"props":1040,"children":1041},{},[1042],{"type":46,"tag":300,"props":1043,"children":1044},{},[1045],{"type":51,"value":1046},"explain.sql",{"type":46,"tag":306,"props":1048,"children":1051},{"className":1049,"code":1050,"language":311,"meta":6},[309],"EXPLAIN (ANALYZE, BUFFERS)\nSELECT * FROM invoices;\n",[1052],{"type":46,"tag":54,"props":1053,"children":1054},{"__ignoreMap":6},[1055],{"type":51,"value":1050},{"type":46,"tag":47,"props":1057,"children":1058},{},[1059,1061,1067,1069,1075,1077,1083,1085,1091],{"type":51,"value":1060},"Run that query twice on the same table, once with ",{"type":46,"tag":54,"props":1062,"children":1064},{"className":1063},[],[1065],{"type":51,"value":1066},"invoices_tenant_id_idx",{"type":51,"value":1068}," present and once after dropping it. With the index, the plan reports an ",{"type":46,"tag":54,"props":1070,"children":1072},{"className":1071},[],[1073],{"type":51,"value":1074},"Index Scan using invoices_tenant_id_idx",{"type":51,"value":1076}," and execution time stays in the same order of magnitude as the same query without RLS enabled, often within a few percent on tables under a million rows. Without the index, the plan reports a ",{"type":46,"tag":54,"props":1078,"children":1080},{"className":1079},[],[1081],{"type":51,"value":1082},"Seq Scan on invoices",{"type":51,"value":1084}," with a ",{"type":46,"tag":54,"props":1086,"children":1088},{"className":1087},[],[1089],{"type":51,"value":1090},"Filter: (tenant_id = ...)",{"type":51,"value":1092}," line, and execution time on a multi-million-row table can climb by an order of magnitude or more depending on the row width and the shared buffer hit rate.",{"type":46,"tag":47,"props":1094,"children":1095},{},[1096,1098,1104],{"type":51,"value":1097},"The takeaway is a single rule: every column referenced by a policy needs an index. For the canonical tenant_id pattern, that is one index per tenant-scoped table. Composite indexes work too when the same query filters on tenant plus another column (a common shape for \"the most recent invoices for this tenant\"): ",{"type":46,"tag":54,"props":1099,"children":1101},{"className":1100},[],[1102],{"type":51,"value":1103},"CREATE INDEX invoices_tenant_id_created_at_idx ON invoices (tenant_id, created_at DESC)",{"type":51,"value":1105}," keeps both the policy and the order-by inside one index scan. The cost is the index maintenance on writes, which is usually a fair trade for the read path.",{"type":46,"tag":47,"props":1107,"children":1108},{},[1109],{"type":51,"value":1110},"Add the index, never weaken the policy.",{"type":46,"tag":79,"props":1112,"children":1114},{"id":1113},"when-to-use-security-definer-and-how-to-review-one-safely",[1115],{"type":51,"value":1116},"When to use SECURITY DEFINER and how to review one safely",{"type":46,"tag":47,"props":1118,"children":1119},{},[1120,1126,1128,1134],{"type":46,"tag":54,"props":1121,"children":1123},{"className":1122},[],[1124],{"type":51,"value":1125},"SECURITY DEFINER",{"type":51,"value":1127}," is a Postgres function attribute that runs the function as the user who defined it, not the user who called it. In the RLS context, that means a definer function can read rows the calling tenant's policies would deny, which is occasionally what you want (a billing aggregate across all tenants, a platform-admin tool) and very often what an attacker would want. Every definer function is a documented policy bypass, and every one should be a review item the same way a ",{"type":46,"tag":54,"props":1129,"children":1131},{"className":1130},[],[1132],{"type":51,"value":1133},"sudo",{"type":51,"value":1135}," line in a shell script is.",{"type":46,"tag":47,"props":1137,"children":1138},{},[1139],{"type":51,"value":1140},"The safe pattern is to make the bypass explicit and the scope tight. A definer function should accept the tenant id it intends to scope to as a parameter, re-set the GUC inside the function body so any policy that reads it still fires, and document the audit trail in the function comment.",{"type":46,"tag":47,"props":1142,"children":1143},{},[1144],{"type":46,"tag":300,"props":1145,"children":1146},{},[1147],{"type":51,"value":1148},"src/db/definer.sql",{"type":46,"tag":306,"props":1150,"children":1153},{"className":1151,"code":1152,"language":311,"meta":6},[309],"CREATE OR REPLACE FUNCTION billing_summary(tenant uuid)\nRETURNS TABLE (total numeric, count bigint)\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = public\nAS $$\nBEGIN\n  PERFORM set_config('app.tenant_id', tenant::text, true);\n\n  RETURN QUERY\n  SELECT SUM(total) AS total, COUNT(*) AS count\n  FROM invoices;\nEND;\n$$;\n\nCOMMENT ON FUNCTION billing_summary(uuid) IS\n  'SECURITY DEFINER: runs under the function owner. Caller must pass the tenant_id and the function re-applies the GUC so the same RLS policy still fires inside the body.';\n",[1154],{"type":46,"tag":54,"props":1155,"children":1156},{"__ignoreMap":6},[1157],{"type":51,"value":1152},{"type":46,"tag":47,"props":1159,"children":1160},{},[1161,1163,1168,1170,1176,1178,1184,1186,1192],{"type":51,"value":1162},"Four lines do the audit work. ",{"type":46,"tag":54,"props":1164,"children":1166},{"className":1165},[],[1167],{"type":51,"value":1125},{"type":51,"value":1169}," declares the bypass. ",{"type":46,"tag":54,"props":1171,"children":1173},{"className":1172},[],[1174],{"type":51,"value":1175},"SET search_path = public",{"type":51,"value":1177}," stops a hostile schema on the calling session from shadowing the function's table references, which is the standard injection vector for definer functions. The ",{"type":46,"tag":54,"props":1179,"children":1181},{"className":1180},[],[1182],{"type":51,"value":1183},"PERFORM set_config(...)",{"type":51,"value":1185}," inside the body re-applies the GUC so the same RLS policy you wrote for application traffic still constrains what the function can read. An auditor reads the ",{"type":46,"tag":54,"props":1187,"children":1189},{"className":1188},[],[1190],{"type":51,"value":1191},"COMMENT ON FUNCTION",{"type":51,"value":1193}," first; treat it as a required field.",{"type":46,"tag":47,"props":1195,"children":1196},{},[1197,1199,1205],{"type":51,"value":1198},"The review checklist for any new definer function is short. Does it take the tenant scope as an explicit parameter? Does it re-apply the GUC inside the body? Does it set ",{"type":46,"tag":54,"props":1200,"children":1202},{"className":1201},[],[1203],{"type":51,"value":1204},"search_path",{"type":51,"value":1206},"? Is the comment honest about what it bypasses? If any answer is no, the function is the bug.",{"type":46,"tag":47,"props":1208,"children":1209},{},[1210],{"type":51,"value":1211},"Every definer function is a tracked exception, not a shortcut to skip RLS.",{"type":46,"tag":79,"props":1213,"children":1215},{"id":1214},"a-reviewable-rls-checklist-for-your-next-pr",[1216],{"type":51,"value":1217},"A reviewable RLS checklist for your next PR",{"type":46,"tag":47,"props":1219,"children":1220},{},[1221,1223,1229],{"type":51,"value":1222},"The pattern fits on a PR template. Paste this into the description of any PR that touches a tenant-scoped table, and any reviewer can validate the diff in under five minutes. The same posture lives on the application side in the ",{"type":46,"tag":225,"props":1224,"children":1226},{"href":1225},"/blog/hardening-ai-generated-react-app-for-production",[1227],{"type":51,"value":1228},"hardening AI-generated React apps for production",{"type":51,"value":1230}," audit; this is the database-layer companion to that pass.",{"type":46,"tag":144,"props":1232,"children":1233},{},[1234,1245,1256,1310,1315,1334,1354,1359,1386,1391],{"type":46,"tag":148,"props":1235,"children":1236},{},[1237,1243],{"type":46,"tag":54,"props":1238,"children":1240},{"className":1239},[],[1241],{"type":51,"value":1242},"ALTER TABLE \u003Ctable> ENABLE ROW LEVEL SECURITY;",{"type":51,"value":1244}," is in the migration.",{"type":46,"tag":148,"props":1246,"children":1247},{},[1248,1254],{"type":46,"tag":54,"props":1249,"children":1251},{"className":1250},[],[1252],{"type":51,"value":1253},"ALTER TABLE \u003Ctable> FORCE ROW LEVEL SECURITY;",{"type":51,"value":1255}," is in the migration so owner roles do not bypass.",{"type":46,"tag":148,"props":1257,"children":1258},{},[1259,1260,1265,1267,1273,1274,1280,1281,1287,1288,1294,1296,1301,1303,1308],{"type":51,"value":256},{"type":46,"tag":54,"props":1261,"children":1263},{"className":1262},[],[1264],{"type":51,"value":75},{"type":51,"value":1266}," exists for every command the application uses (",{"type":46,"tag":54,"props":1268,"children":1270},{"className":1269},[],[1271],{"type":51,"value":1272},"SELECT",{"type":51,"value":750},{"type":46,"tag":54,"props":1275,"children":1277},{"className":1276},[],[1278],{"type":51,"value":1279},"INSERT",{"type":51,"value":750},{"type":46,"tag":54,"props":1282,"children":1284},{"className":1283},[],[1285],{"type":51,"value":1286},"UPDATE",{"type":51,"value":750},{"type":46,"tag":54,"props":1289,"children":1291},{"className":1290},[],[1292],{"type":51,"value":1293},"DELETE",{"type":51,"value":1295},"), each referencing ",{"type":46,"tag":54,"props":1297,"children":1299},{"className":1298},[],[1300],{"type":51,"value":119},{"type":51,"value":1302}," and using ",{"type":46,"tag":54,"props":1304,"children":1306},{"className":1305},[],[1307],{"type":51,"value":511},{"type":51,"value":1309}," for writes.",{"type":46,"tag":148,"props":1311,"children":1312},{},[1313],{"type":51,"value":1314},"Every column referenced by a policy has an index, and the index is part of the same migration.",{"type":46,"tag":148,"props":1316,"children":1317},{},[1318,1320,1325,1327,1332],{"type":51,"value":1319},"The application path goes through a single ",{"type":46,"tag":54,"props":1321,"children":1323},{"className":1322},[],[1324],{"type":51,"value":733},{"type":51,"value":1326}," wrapper that begins a transaction, calls ",{"type":46,"tag":54,"props":1328,"children":1330},{"className":1329},[],[1331],{"type":51,"value":111},{"type":51,"value":1333}," as the first statement, and runs the work inside the same transaction.",{"type":46,"tag":148,"props":1335,"children":1336},{},[1337,1339,1344,1346,1352],{"type":51,"value":1338},"The raw ",{"type":46,"tag":54,"props":1340,"children":1342},{"className":1341},[],[1343],{"type":51,"value":787},{"type":51,"value":1345}," or ",{"type":46,"tag":54,"props":1347,"children":1349},{"className":1348},[],[1350],{"type":51,"value":1351},"prisma",{"type":51,"value":1353}," client is not exported from the tenant module; only the wrapper is.",{"type":46,"tag":148,"props":1355,"children":1356},{},[1357],{"type":51,"value":1358},"A negative test asserts that a request with no tenant set returns zero rows, and that a write tagged as another tenant raises a policy error.",{"type":46,"tag":148,"props":1360,"children":1361},{},[1362,1364,1369,1371,1377,1379,1384],{"type":51,"value":1363},"Any new ",{"type":46,"tag":54,"props":1365,"children":1367},{"className":1366},[],[1368],{"type":51,"value":1125},{"type":51,"value":1370}," function takes the tenant id as a parameter, re-applies the GUC inside the body, sets ",{"type":46,"tag":54,"props":1372,"children":1374},{"className":1373},[],[1375],{"type":51,"value":1376},"search_path = public",{"type":51,"value":1378},", and has a ",{"type":46,"tag":54,"props":1380,"children":1382},{"className":1381},[],[1383],{"type":51,"value":1191},{"type":51,"value":1385}," that explains the bypass.",{"type":46,"tag":148,"props":1387,"children":1388},{},[1389],{"type":51,"value":1390},"If the deployment uses PgBouncer, the pool runs in transaction mode, not session mode.",{"type":46,"tag":148,"props":1392,"children":1393},{},[1394],{"type":51,"value":1395},"The CI pipeline runs the negative test against the migrated schema before the application image is built.",{"type":46,"tag":47,"props":1397,"children":1398},{},[1399],{"type":51,"value":1400},"Ten items. Two minutes per item. One audit-safe boundary at the end.",{"type":46,"tag":47,"props":1402,"children":1403},{},[1404],{"type":51,"value":1405},"That is the full pattern: enable, force, set, check, test, index, review. RLS is the cheapest hard boundary you can put on a multi-tenant Postgres, and the pattern stays the same whether you reach for Drizzle, Prisma, or raw node-postgres. The work is in the wiring, not in the feature.",{"title":6,"searchDepth":1407,"depth":1407,"links":1408},2,[1409,1410,1411,1418,1423,1424,1425,1426],{"id":81,"depth":1407,"text":84},{"id":187,"depth":1407,"text":190},{"id":267,"depth":1407,"text":270,"children":1412},[1413,1415,1416,1417],{"id":279,"depth":1414,"text":282},3,{"id":369,"depth":1414,"text":372},{"id":473,"depth":1414,"text":476},{"id":587,"depth":1414,"text":590},{"id":654,"depth":1407,"text":657,"children":1419},[1420,1421],{"id":672,"depth":1414,"text":675},{"id":829,"depth":1414,"text":1422},"Prisma $extends and the same idea",{"id":968,"depth":1407,"text":971},{"id":1021,"depth":1407,"text":1024},{"id":1113,"depth":1407,"text":1116},{"id":1214,"depth":1407,"text":1217},"markdown",1785621731452]