<?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: Adriano Foschi</title>
    <description>The latest articles on DEV Community by Adriano Foschi (@adrianofoschi).</description>
    <link>https://dev.to/adrianofoschi</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%2F4072856%2Fcab0f37e-7e24-4d9d-9562-c60319be1d13.png</url>
      <title>DEV Community: Adriano Foschi</title>
      <link>https://dev.to/adrianofoschi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/adrianofoschi"/>
    <language>en</language>
    <item>
      <title>ReBAC isn't the problem. The ReBAC tools I tried are.</title>
      <dc:creator>Adriano Foschi</dc:creator>
      <pubDate>Tue, 11 Aug 2026 12:04:43 +0000</pubDate>
      <link>https://dev.to/adrianofoschi/rebac-isnt-the-problem-the-rebac-tools-i-tried-are-hon</link>
      <guid>https://dev.to/adrianofoschi/rebac-isnt-the-problem-the-rebac-tools-i-tried-are-hon</guid>
      <description>&lt;p&gt;ReBAC (relationship-based access control) decides access based on how entities are connected to each other, rather than on a role attached to the user. Nowhere is it written that you can see that repository. You see it because a chain of relationships leads you there.&lt;/p&gt;

&lt;p&gt;It's a model I like, and I want to say that up front, because what follows isn't a criticism of ReBAC. I spent a few weeks integrating &lt;a href="https://openfga.dev/" rel="noopener noreferrer"&gt;OpenFGA&lt;/a&gt; into a prototype to use it properly: declarative model, sixteen test scenarios, a hundred and twenty assertions running offline in two seconds with no database and no application. It worked well. I removed it anyway.&lt;/p&gt;

&lt;p&gt;Not because of a bug, and not because of check latency. I removed it because none of the tools I tried gives me a usable answer to the second question every application asks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check is fast. The list isn't.
&lt;/h2&gt;

&lt;p&gt;"Can this user see this object?" resolves in milliseconds.&lt;/p&gt;

&lt;p&gt;The problem is that the first screen after login is almost always a list. And "which objects can this user see?" looks like the same question reversed, but it isn't: nowhere is it recorded which objects are reachable, which is the point of ReBAC seen from the other side.&lt;/p&gt;

&lt;p&gt;In the first case you hold two things and walk the graph from one to the other. In the second you hold only the user, and the set of possible answers is everything that exists in the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three routes, and where each one stops
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Filter afterwards.&lt;/strong&gt; Normal query with its normal pagination, then you send the twenty-five ids to the service and drop the ones that don't pass. The result is correct, but the total at the bottom of the page is the one from before the filter, so it's a lie. And the user with access to a small slice gets three rows out of twenty-five.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Filter first.&lt;/strong&gt; You ask the service which objects the user holds that permission on and hand them to the database. Except the list arrives whole. There's no real pagination to draw twenty-five from. It ends up as an &lt;code&gt;IN&lt;/code&gt; with thousands of identifiers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A local index.&lt;/strong&gt; You build yourself a "user → what they can see" table and the filter goes back to being a JOIN. But to fill it you need the &lt;em&gt;computed&lt;/em&gt; permissions, and the engine doesn't expose those: the API for following changes returns relationships, the facts you wrote in yourself and already have. The derived part gets computed to answer one question, then thrown away.&lt;/p&gt;

&lt;p&gt;I tried all three and they stop in different places, but they stop. &lt;a href="https://www.permit.io/" rel="noopener noreferrer"&gt;Permit.io&lt;/a&gt; is the only one attacking the problem at the root, with partial evaluation of the policy: instead of a list of ids it hands you a clause to drop into a &lt;code&gt;WHERE&lt;/code&gt;. That's exactly the right thing. Only, on Postgres it's in early access, it works for attribute-based policies rather than relationship-based ones, and it wants the rules written in a separate language. The right answer exists. It just isn't available yet for the model I have.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did instead
&lt;/h2&gt;

&lt;p&gt;I moved authorization into Postgres, as native functions. Two families: one for point checks, one that returns a set of ids.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;repositories&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;authorized_repositories&lt;/span&gt;&lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;archived&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pushed_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A subquery like any other. It composes with the filters that were already there, with the sort and with the pagination. &lt;code&gt;COUNT(*)&lt;/code&gt; tells the truth. Check and filter read the same data in the same transaction, so there's no window where permissions lag behind facts: there's nothing to synchronize, because there's no second place.&lt;/p&gt;

&lt;p&gt;The switch cost little, for a reason that matters more than the solution itself. The application had never known about the external service. Use cases called a port with a deliberately generic signature (user, permission, context), and behind it sat an adapter. I wrote another one and touched no application logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I lost
&lt;/h2&gt;

&lt;p&gt;It isn't a clean solution and I don't want to sell it as one.&lt;/p&gt;

&lt;p&gt;I lost the declarative model, which was readable: one file with the types, the relationships, and the permissions as expressions over them. Now the policy is code inside the database, versioned through migrations and verifiable only against a live database. And I lost those tests that ran in two seconds with nothing running.&lt;/p&gt;

&lt;p&gt;I also don't know how far it holds. On the prototype it's instant, but a prototype doesn't carry a real customer's volumes, and I haven't measured anything under load yet.&lt;/p&gt;

&lt;p&gt;Then there's the paradox that annoys me most. The prototype can afford native functions because its model is simple. The complex system, the one that would genuinely benefit from an external engine, is exactly the one with the deepest hierarchies, the longest lists and the largest volumes, which are the three things these tools don't help with. Whoever can afford them doesn't need them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The doubt I'm left with
&lt;/h2&gt;

&lt;p&gt;Maybe the question is wrong to begin with. The screen that lists &lt;em&gt;everything&lt;/em&gt; a user can see doesn't exist in any product: GitHub shows you the repositories of an account or an organization, search works within a scope. If every query carries a mandatory scope, the candidate set stops being "everything that exists" and becomes a number I can bound. At that point filtering afterwards works again, because you filter the set and paginate at the end.&lt;/p&gt;

&lt;p&gt;How have you solved this? I'm especially interested in anyone who went to production with a materialized index: how do you keep it up to date when something changes halfway down the chain?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The full reasoning, with the comparison between &lt;a href="https://permify.co/" rel="noopener noreferrer"&gt;Permify&lt;/a&gt;, Permit.io and OpenFGA, is on my blog: &lt;a href="https://adrianofoschi.com/blog/permissions-are-two-problems/" rel="noopener noreferrer"&gt;Permissions are two problems, not one&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rebac</category>
      <category>authorization</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
