<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: João Pedro Guimarães Borba</title>
    <description>The latest articles on DEV Community by João Pedro Guimarães Borba (@mezhack).</description>
    <link>https://dev.to/mezhack</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4120849%2F6d7ee81a-8889-47fb-8d1a-0bedf89905f3.png</url>
      <title>DEV Community: João Pedro Guimarães Borba</title>
      <link>https://dev.to/mezhack</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mezhack"/>
    <language>en</language>
    <item>
      <title>RLS isolated my tenants. A missing sequence GRANT still broke production.</title>
      <dc:creator>João Pedro Guimarães Borba</dc:creator>
      <pubDate>Fri, 11 Sep 2026 11:38:23 +0000</pubDate>
      <link>https://dev.to/mezhack/rls-isolated-my-tenants-a-missing-sequence-grant-still-broke-production-46b7</link>
      <guid>https://dev.to/mezhack/rls-isolated-my-tenants-a-missing-sequence-grant-still-broke-production-46b7</guid>
      <description>&lt;p&gt;I built FunilChat AI, a multi-tenant WhatsApp CRM with an AI agent that answers leads&lt;br&gt;
before a human ever opens the app, for WA Financial Services, a firm that helps&lt;br&gt;
Brazilian immigrants in the US with tax and immigration paperwork. Solo developer, one&lt;br&gt;
VPS, no ops team, no staging environment. Every migration runs straight against&lt;br&gt;
production.&lt;/p&gt;

&lt;p&gt;Two constraints shaped almost every decision:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No security budget, real security stakes.&lt;/strong&gt; End customers submit SSN/ITIN/EIN-
equivalent documents. This isn't a fintech with a compliance team; every control
(isolation, MFA, encryption, malware scanning) had to be built into the product itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One system, many paying clients, no ops team to run a fleet of databases.&lt;/strong&gt; WA
Financial Services was tenant one, but the plan from day one was to sell the same
product to other businesses. Onboarding a new tenant had to take minutes, not a new
deployment.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The isolation decision
&lt;/h2&gt;

&lt;p&gt;The obvious multi-tenant pattern is one database (or schema) per client. I didn't do&lt;br&gt;
that. Instead: one shared Postgres database, one schema, and the tenant boundary&lt;br&gt;
enforced by row-level security (RLS) policies on every tenant-scoped table (21 of them),&lt;br&gt;
keyed off a session variable the backend sets on every request.&lt;/p&gt;

&lt;p&gt;The FastAPI backend sets that session variable on every request it handles. From there,&lt;br&gt;
RLS does the actual enforcement: a query running under the API's database role simply&lt;br&gt;
cannot see another tenant's rows, even if application code somewhere forgets a&lt;br&gt;
&lt;code&gt;WHERE tenant_id = ...&lt;/code&gt; clause. The database refuses the row before the app ever sees&lt;br&gt;
it, not "if the code remembers to filter."&lt;/p&gt;

&lt;p&gt;The cost of that choice showed up somewhere I didn't expect: the automation layer. The&lt;br&gt;
product also runs an n8n workflow (it holds the AI agent and the handoff logic), and&lt;br&gt;
n8n can't easily set a per-request session variable the way the API does. So its&lt;br&gt;
database role runs with &lt;code&gt;BYPASSRLS&lt;/code&gt; — a real, permanent blind spot in an otherwise&lt;br&gt;
fail-closed model, one that has to be reviewed by hand every time a new workflow&lt;br&gt;
touches the database. I'd rather have zero exceptions, but the alternative (rewriting&lt;br&gt;
the tenant boundary check into every n8n node by hand) was worse: more surface area for&lt;br&gt;
exactly the kind of mistake RLS exists to prevent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke: a table grant isn't a sequence grant
&lt;/h2&gt;

&lt;p&gt;A few months in, a production n8n workflow failed to create a handoff notification. The&lt;br&gt;
Postgres error named a sequence, not a table — and the row-level insert itself looked&lt;br&gt;
like something that should have been allowed.&lt;/p&gt;

&lt;p&gt;Root cause: the migration that created the notifications table granted &lt;code&gt;INSERT&lt;/code&gt; on the&lt;br&gt;
table to both database roles, but only granted &lt;code&gt;USAGE&lt;/code&gt; and &lt;code&gt;SELECT&lt;/code&gt; on the table's&lt;br&gt;
auto-increment sequence to the API's role. Postgres treats table privileges and&lt;br&gt;
sequence privileges as two separate grants, and the automation role's default&lt;br&gt;
privileges had never been set up to inherit sequence access the way table access had&lt;br&gt;
been. Every &lt;code&gt;INSERT&lt;/code&gt; that needed a new auto-generated ID from that role failed silently&lt;br&gt;
at the sequence, while everything else on the table worked fine — which is a confusing&lt;br&gt;
failure to read from the outside, because the obvious suspect (the RLS policy) was&lt;br&gt;
innocent.&lt;/p&gt;

&lt;p&gt;Fix: a follow-up migration added the missing&lt;br&gt;
&lt;code&gt;GRANT USAGE, SELECT ON SEQUENCE ... TO &amp;lt;automation_role&amp;gt;&lt;/code&gt;. The more useful fix was&lt;br&gt;
turning it into a standing rule instead of a one-off patch: every migration that&lt;br&gt;
creates a table the automation workflows write to now includes the sequence grant in&lt;br&gt;
the same file as the table grant, checked while writing the migration instead of&lt;br&gt;
discovered later from a production error.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other invariant I actually care about
&lt;/h2&gt;

&lt;p&gt;The AI and a human agent can both be looking at the same WhatsApp conversation, and the&lt;br&gt;
one thing that can't happen is both replying, or the AI replying after a human already&lt;br&gt;
stepped in. A customer deciding whether to trust a stranger with their tax documents&lt;br&gt;
seeing two different voices talk over each other in one thread does more damage than a&lt;br&gt;
slow reply ever would.&lt;/p&gt;

&lt;p&gt;The mechanism is deliberately boring: a per-conversation flag. A human agent hits&lt;br&gt;
"Assume," the flag flips, and the AI workflow checks that flag immediately before every&lt;br&gt;
reply and stays silent if it's set. Thirty minutes after the last human reply, it&lt;br&gt;
releases automatically, so a conversation nobody explicitly handed back doesn't stay&lt;br&gt;
stuck in silence. Nothing here is negotiated between AI and human at message time — it's&lt;br&gt;
a boolean checked before the AI is allowed to speak, which is also why it was cheap to&lt;br&gt;
make airtight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;In continuous production since April 2026, on release 2.21.0, 21 tables under row-level&lt;br&gt;
security, 307 commits on the main branch. Numbers pulled straight from the repo, not&lt;br&gt;
rounded up for effect.&lt;/p&gt;

&lt;p&gt;I wrote this case up in full on my portfolio, with all four architecture decisions&lt;br&gt;
(this post covers two), every invariant, and the diagram:&lt;br&gt;
&lt;a href="https://joaoborba.dev/cases/funilchat-ai/" rel="noopener noreferrer"&gt;joaoborba.dev/cases/funilchat-ai&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I'm a solo full-stack developer based in Brazil, looking for remote work, ideally with&lt;br&gt;
teams in North America or Western Europe. Happy to go deeper on any of this in the&lt;br&gt;
comments.&lt;/p&gt;

&lt;h1&gt;
  
  
  postgres #backend #architecture #showdev
&lt;/h1&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>production</category>
      <category>security</category>
    </item>
  </channel>
</rss>
