<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Pgx on Haseeb Majid</title>
    <link>https://haseebmajid.dev/tags/pgx/</link>
    <description>Recent content in Pgx on Haseeb Majid</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en</language>
    <lastBuildDate>Sat, 15 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://haseebmajid.dev/tags/pgx/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>How I Implemented Authorization in My Go App With RLS (Postgres)</title>
      <link>https://haseebmajid.dev/posts/2026-08-15-how-i-implemented-authorization-in-my-go-app-with-rls-postgres/</link>
      <pubDate>Sat, 15 Aug 2026 00:00:00 +0000</pubDate>
      <author>Me</author>
      <guid>https://haseebmajid.dev/posts/2026-08-15-how-i-implemented-authorization-in-my-go-app-with-rls-postgres/</guid>
      <description>&lt;h2 id=&#34;introduction&#34;&gt;Introduction&lt;/h2&gt;
&lt;p&gt;In this article, I will show you how I set up Authorization with Row Level Security (RLS) specifically with Postgres in my
web app in Go.&lt;/p&gt;
&lt;p&gt;A quick distinction between:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Authentication (AuthN): who are you?&lt;/li&gt;
&lt;li&gt;Authorization (AuthZ): what can you do?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We will mostly be focusing on AuthZ in this article, what is the user allowed to do but mostly see and we will
use my gardening app called LettuceGo &lt;sup id=&#34;fnref:1&#34;&gt;&lt;a href=&#34;#fn:1&#34; class=&#34;footnote-ref&#34; role=&#34;doc-noteref&#34;&gt;1&lt;/a&gt;&lt;/sup&gt; (yes terrible pun, but I love it) as an example.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>In this article, I will show you how I set up Authorization with Row Level Security (RLS) specifically with Postgres in my
web app in Go.</p>
<p>A quick distinction between:</p>
<ul>
<li>Authentication (AuthN): who are you?</li>
<li>Authorization (AuthZ): what can you do?</li>
</ul>
<p>We will mostly be focusing on AuthZ in this article, what is the user allowed to do but mostly see and we will
use my gardening app called LettuceGo <sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup> (yes terrible pun, but I love it) as an example.</p>
<h2 id="the-problem">The problem</h2>
<p>I noticed in a lot of my SQL queries I was adding <code>where user_id = $1</code>, to make sure that the user had the correct
permissions. Either to read data only that user had access to i.e. all of their plants. Or to edit plants that they
&ldquo;own&rdquo; (update, write, delete etc). This involved a lot of repetition in our queries. Which in itself is not a massive
issue.</p>
<p>But I thought there has to be a better way to do this, then I remember when building other multi tenant apps
in the past I had heard of RLS. So I started looking into if that was a way to reduce our boilerplate.</p>
<p>Our old query to list plants may have looked like this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="c1">-- name: ListPlants :many
</span></span></span><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="n">plants</span><span class="w"> </span><span class="n">p</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">WHERE</span><span class="w"> </span><span class="n">p</span><span class="p">.</span><span class="n">user_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">$</span><span class="mi">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">p</span><span class="p">.</span><span class="n">name</span><span class="p">;</span><span class="w">
</span></span></span></code></pre></div><p>Then after we implement RLS we can simplify just do this. This is for all the 20/30 queries we have in our app for
example. So starts to save us a bit of boilerplate. It also simplifies our code as we don&rsquo;t need to pass a user_id
to every function i.e. <code>transport -&gt; service -&gt; data store</code>.</p>
<p>It also makes our queries a bit safer, if we miss a where filter above potentially a user could read data they
shouldn&rsquo;t be able to. But with RLS, which will see in a bit, it reduces the risk of this happening.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="c1">-- name: ListPlants :many
</span></span></span><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="n">plants</span><span class="w"> </span><span class="n">p</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">p</span><span class="p">.</span><span class="n">name</span><span class="p">;</span><span class="w">
</span></span></span></code></pre></div><h2 id="what-is-rls">What is RLS?</h2>
<p>RLS = Row Level Security, allows us to attach policies to tables. Which are evaluated per row on every
SELECT/INSERT/UPDATE/DELETE query that touches the table.</p>
<p>Turn it on per table:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">plants</span><span class="w"> </span><span class="n">ENABLE</span><span class="w"> </span><span class="k">ROW</span><span class="w"> </span><span class="k">LEVEL</span><span class="w"> </span><span class="k">SECURITY</span><span class="p">;</span><span class="w">
</span></span></span></code></pre></div><p>With RLS enabled but no policy, no rows are visible by default. That&rsquo;s only for roles RLS applies to though,
owners bypass it until you <code>FORCE</code> (see below). Add a policy:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="n">POLICY</span><span class="w"> </span><span class="n">plants_owner</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">plants</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">FOR</span><span class="w"> </span><span class="k">SELECT</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">USING</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">app_current_user_id</span><span class="p">());</span><span class="w">
</span></span></span></code></pre></div><ul>
<li><code>USING</code>: which rows are visible (SELECT, UPDATE-existing, DELETE).</li>
<li><code>WITH CHECK</code>: which rows are writable (INSERT, UPDATE-new). Blocks cross-user inserts.</li>
</ul>
<p>Essentially now we are moving some of the AuthZ logic into the DB itself vs in the business logic of our application.</p>
<h3 id="example-policy">Example Policy</h3>
<p>Not every table has a <code>user_id</code> column. <code>events</code> belongs to a <code>plant</code>, which belongs to a user. So the policy checks
ownership through the parent:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="c1">-- users (1) -&gt; (N) plants (1) -&gt; (N) events / photos / plant_tags
</span></span></span><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="n">POLICY</span><span class="w"> </span><span class="n">events_owner</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">USING</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">plant_id</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">plants</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">WHERE</span><span class="w"> </span><span class="n">user_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">app_current_user_id</span><span class="p">()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">);</span><span class="w">
</span></span></span></code></pre></div><h3 id="do-we-need-to-force-row-level-security">Do we need to <code>FORCE ROW LEVEL SECURITY</code>?</h3>
<p>In production LettuceGo connects as the <code>lettucego</code> role, a normal (non-superuser) role with <code>CREATEROLE</code> that
owns the tables in the <code>lettucego</code> database.</p>
<p>Two things to know about who RLS applies to:</p>
<ul>
<li>Owners bypass RLS: the table owner sees every row unless you <code>FORCE</code> it.</li>
<li>Superusers bypass RLS: and there is no <code>FORCE</code> for them. This is exactly why the connection
role is no longer a superuser.</li>
</ul>
<p>For the actual app queries we go one step further and <code>SET ROLE app_user</code> (below), so the session runs as a role
that owns nothing and the policies apply to it normally. <code>FORCE</code> is just there as a fallback, it covers the owner
connection itself (migrations, admin queries, or any code path that forgets to <code>SET ROLE</code>) so the owner can never
silently see every row.</p>
<p>We create a second role, <code>app_user</code>, with just the grants it needs, and make
<code>lettucego</code> a member of it so it can switch to it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">DO</span><span class="w"> </span><span class="err">$$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">BEGIN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">IF</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_roles</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">rolname</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;app_user&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">THEN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">CREATE</span><span class="w"> </span><span class="k">ROLE</span><span class="w"> </span><span class="n">app_user</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">END</span><span class="w"> </span><span class="k">IF</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">END</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="err">$$</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">GRANT</span><span class="w"> </span><span class="n">app_user</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">lettucego</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">GRANT</span><span class="w"> </span><span class="k">SELECT</span><span class="p">,</span><span class="w"> </span><span class="k">INSERT</span><span class="p">,</span><span class="w"> </span><span class="k">UPDATE</span><span class="p">,</span><span class="w"> </span><span class="k">DELETE</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="k">ALL</span><span class="w"> </span><span class="n">TABLES</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="k">SCHEMA</span><span class="w"> </span><span class="k">public</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">app_user</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">GRANT</span><span class="w"> </span><span class="k">USAGE</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="k">ALL</span><span class="w"> </span><span class="n">SEQUENCES</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="k">SCHEMA</span><span class="w"> </span><span class="k">public</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">app_user</span><span class="p">;</span><span class="w">
</span></span></span></code></pre></div><p>Then force RLS on so even the table owner is subject to policies:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">plants</span><span class="w"> </span><span class="n">ENABLE</span><span class="w"> </span><span class="k">ROW</span><span class="w"> </span><span class="k">LEVEL</span><span class="w"> </span><span class="k">SECURITY</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">plants</span><span class="w"> </span><span class="k">FORCE</span><span class="w"> </span><span class="k">ROW</span><span class="w"> </span><span class="k">LEVEL</span><span class="w"> </span><span class="k">SECURITY</span><span class="p">;</span><span class="w">
</span></span></span></code></pre></div><blockquote>
<p>In production, scope the role per app <code>lettucego_app_user</code>
Postgres roles are cluster-wide, not per-database, so a shared <code>app_user</code> would
collide across apps and end up holding grants for every app&rsquo;s tables.</p>
</blockquote>
<h3 id="the-app_current_user_id-helper">The <code>app_current_user_id()</code> helper</h3>
<p>Policies need the current user&rsquo;s id. We pass it through a session via a Postgres
custom setting. We then read it back with a small helper.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">OR</span><span class="w"> </span><span class="k">REPLACE</span><span class="w"> </span><span class="k">FUNCTION</span><span class="w"> </span><span class="n">app_current_user_id</span><span class="p">()</span><span class="w"> </span><span class="k">RETURNS</span><span class="w"> </span><span class="n">uuid</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">$$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">BEGIN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">RETURN</span><span class="w"> </span><span class="n">current_setting</span><span class="p">(</span><span class="s1">&#39;app.current_user_id&#39;</span><span class="p">,</span><span class="w"> </span><span class="k">true</span><span class="p">)::</span><span class="n">uuid</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">EXCEPTION</span><span class="w"> </span><span class="k">WHEN</span><span class="w"> </span><span class="n">others</span><span class="w"> </span><span class="k">THEN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">RETURN</span><span class="w"> </span><span class="k">NULL</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">END</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="err">$$</span><span class="w"> </span><span class="k">LANGUAGE</span><span class="w"> </span><span class="n">plpgsql</span><span class="w"> </span><span class="k">STABLE</span><span class="p">;</span><span class="w">
</span></span></span></code></pre></div><ul>
<li><code>current_setting('app.current_user_id', true)</code> read the settings. The <code>true</code> means return <code>NULL</code> if not found.</li>
<li><code>EXCEPTION</code> if the GUC holds a non-uuid string (we set <code>&quot;&quot;</code> for unauth requests), the <code>::uuid</code> cast throws, and we return NULL instead.</li>
</ul>
<h2 id="how-to-implement-this-with-go">How to implement this with Go</h2>
<p>Now that we have covered roughly how the policies look they of course do get more complicated, you can see
some examples in the codebase I shared in the appendix but roughly speaking, high level, they do the same thing.</p>
<p>We need to orchestrate our Go code to set up the app user to the current user id, so the policies actually work.
In the examples below I&rsquo;m using <code>pgx</code>, a very popular Postgres driver, but it should translate to other <code>drivers</code> and
DBs at least at a high level.</p>
<h3 id="prepareconn"><code>PrepareConn</code></h3>
<p><code>PrepareConn</code> runs every time the pool hands out a connection. We pull the user from the request context
(set by auth middleware), and then set the postgres setting.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="nx">pgxConfig</span><span class="p">.</span><span class="nx">PrepareConn</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="kd">func</span><span class="p">(</span><span class="nx">ctx</span><span class="w"> </span><span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span><span class="w"> </span><span class="nx">conn</span><span class="w"> </span><span class="o">*</span><span class="nx">pgx</span><span class="p">.</span><span class="nx">Conn</span><span class="p">)</span><span class="w"> </span><span class="p">(</span><span class="kt">bool</span><span class="p">,</span><span class="w"> </span><span class="kt">error</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">// INFO: always set app_user so RLS applies even for unauth requests.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">// Unauth requests get an empty user id, which makes app_current_user_id()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">// return NULL, so ownership policies deny every row.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="nx">_</span><span class="p">,</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">conn</span><span class="p">.</span><span class="nf">Exec</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;SET ROLE app_user&#34;</span><span class="p">);</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="kc">nil</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w"> </span><span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;failed to set role: %w&#34;</span><span class="p">,</span><span class="w"> </span><span class="nx">err</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">userID</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="s">&#34;&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="nx">user</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">identity</span><span class="p">.</span><span class="nf">GetFromContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">);</span><span class="w"> </span><span class="nx">user</span><span class="p">.</span><span class="nx">IsAuthenticated</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nx">userID</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="nx">user</span><span class="p">.</span><span class="nx">UserID</span><span class="p">.</span><span class="nf">String</span><span class="p">()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="nx">_</span><span class="p">,</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">conn</span><span class="p">.</span><span class="nf">Exec</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;SELECT set_config(&#39;app.current_user_id&#39;, $1, false)&#34;</span><span class="p">,</span><span class="w"> </span><span class="nx">userID</span><span class="p">);</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="kc">nil</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w"> </span><span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;failed to set RLS user context: %w&#34;</span><span class="p">,</span><span class="w"> </span><span class="nx">err</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w"> </span><span class="kc">nil</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>You&rsquo;ll notice that we <code>SET ROLE app_user</code> even if the request is unauthenticated, which in theory shouldn&rsquo;t be possible.
However, I think it&rsquo;s a good check to have just in case the auth middleware we have to set the user breaks.</p>
<p>An empty <code>userID</code> makes <code>app_current_user_id()</code> return NULL, so every ownership policy denies all rows.</p>
<h3 id="afterrelease"><code>AfterRelease</code></h3>
<p><code>AfterRelease</code> runs when the connection goes back to the pool. We reset the role and clear the postgres setting.
Returning <code>false</code> tells pgx to destroy the connection if anything fails.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="nx">pgxConfig</span><span class="p">.</span><span class="nx">AfterRelease</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="kd">func</span><span class="p">(</span><span class="nx">conn</span><span class="w"> </span><span class="o">*</span><span class="nx">pgx</span><span class="p">.</span><span class="nx">Conn</span><span class="p">)</span><span class="w"> </span><span class="kt">bool</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">ctx</span><span class="p">,</span><span class="w"> </span><span class="nx">cancel</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">context</span><span class="p">.</span><span class="nf">WithTimeout</span><span class="p">(</span><span class="nx">releaseCtx</span><span class="p">,</span><span class="w"> </span><span class="mi">5</span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">defer</span><span class="w"> </span><span class="nf">cancel</span><span class="p">()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="nx">_</span><span class="p">,</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">conn</span><span class="p">.</span><span class="nf">Exec</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;RESET ROLE&#34;</span><span class="p">);</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="kc">nil</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="nx">_</span><span class="p">,</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">conn</span><span class="p">.</span><span class="nf">Exec</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;SELECT set_config(&#39;app.current_user_id&#39;, $1, false)&#34;</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;&#34;</span><span class="p">);</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="kc">nil</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><blockquote>
<p>This also works for DB transactions</p>
</blockquote>
<h2 id="an-example">An example</h2>
<p>The <code>photos</code> table is an interesting one. A photo belongs to a plant, and a plant belongs to a user. We want two
things, only the owner can read a photo, and nobody can insert a photo that points at someone else&rsquo;s plant.</p>
<p>So we end up with a policy like this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="n">POLICY</span><span class="w"> </span><span class="n">photos_owner</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">photos</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">USING</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">app_current_user_id</span><span class="p">())</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">WITH</span><span class="w"> </span><span class="k">CHECK</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">user_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">app_current_user_id</span><span class="p">()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">AND</span><span class="w"> </span><span class="n">plant_id</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">plants</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">WHERE</span><span class="w"> </span><span class="n">user_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">app_current_user_id</span><span class="p">()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">);</span><span class="w">
</span></span></span></code></pre></div><p><code>USING</code> filters reads. <code>WITH CHECK</code> filters on writes (INSERT) and the new row of an UPDATE. It runs on the
proposed new row before it&rsquo;s written. If it returns false the row isn&rsquo;t stored and the statement is rejected.</p>
<p>The <code>WITH CHECK</code> here has two conditions:</p>
<ul>
<li><code>user_id = app_current_user_id()</code>: The photo&rsquo;s <code>user_id</code> must match the caller.</li>
<li><code>plant_id IN (SELECT id FROM plants WHERE user_id = app_current_user_id())</code>: The plant it&rsquo;s attached to must also belong to the caller.</li>
</ul>
<p>The second condition here is actually very important, without it, user B could insert a photo with
their own <code>user_id</code> but pointing at user A&rsquo;s <code>plant_id</code>.</p>
<p>The subquery reads <code>plants</code>, and <code>plants</code> has its own RLS policy (<code>user_id = app_current_user_id()</code>). So when
<code>app_user</code> runs the subquery, it only sees its own plants anyway. The subquery can never return someone else&rsquo;s <code>plant.id</code>,
even if we got the &ldquo;outer&rdquo; policy wrong (photos). The RLS stacks, the inner table is filtered before the &ldquo;outer&rdquo; policy sees it.</p>
<h2 id="performance">Performance</h2>
<p>RLS rewrites every query behind your back. A query you write as <code>SELECT * FROM plants</code> actually runs as
<code>SELECT * FROM plants WHERE user_id = app_current_user_id()</code>.</p>
<p>Let&rsquo;s say we add an index on plants for user id like so:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_plants_user_id</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">plants</span><span class="w"> </span><span class="p">(</span><span class="n">user_id</span><span class="p">);</span><span class="w">
</span></span></span></code></pre></div><p>This can be really important for the performance of our queries, note this can make writes a bit slower.
This matters twice for child policies. The <code>plant_tags</code> policy has
<code>plant_id IN (SELECT id FROM plants WHERE user_id = app_current_user_id())</code>.</p>
<p>That subquery can run once per outer row the planner evaluates (in a nested-loop plan). Without the index on
<code>plants.user_id</code>, each of those runs is a seq scan of <code>plants</code>. With the index, each is a cheap index lookup.
One index fixes the direct policies and the subqueries under the child policies.</p>
<h2 id="conclusion">Conclusion</h2>
<p>RLS here really only handles one part of AuthZ, ownership. As in, can you see or change <em>your</em> rows. But
authorization is broader than that. Things like whether you&rsquo;re allowed to do an action at all, role-based
access (admin vs normal user), or feature/plan limits (say, free users can only have 50 plants) don&rsquo;t really
map onto a row filter.</p>
<p>For example, &ldquo;this user has hit their free-tier plant limit, they can&rsquo;t create another&rdquo; isn&rsquo;t something RLS can
enforce. RLS only sees rows, it has no idea about plans or quotas. That check has to live in the service layer,
before the insert. RLS just backs it up, if we ever miss an ownership check the worst case is an empty result
set, not someone else&rsquo;s rows.</p>
<p>But now you should be able to setup RLS with pgx (Postgres) in Go. Let me know how you get on with it. Or if you
find a better way to implement yourself.</p>
<h2 id="appendix">Appendix</h2>
<p>The full source for the snippets in this post lives in the LettuceGo repo on GitLab <sup id="fnref1:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>:</p>
<ul>
<li><a href="https://gitlab.com/hmajid2301/lettucego/-/blob/main/internal/store/db/pgx.go"><code>internal/store/db/pgx.go</code></a></li>
<li><a href="https://gitlab.com/hmajid2301/lettucego/-/blob/main/internal/store/db/sqlc/migrations/20260428000001_add_row_level_security.sql"><code>migrations/20260428000001_add_row_level_security.sql</code></a></li>
</ul>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p><a href="https://gitlab.com/hmajid2301/lettucego">https://gitlab.com/hmajid2301/lettucego</a>&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a>&#160;<a href="#fnref1:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded>
    </item>
    
  </channel>
</rss>
