Broken access control is at the top of the list for OWASP top 10. In fact, it’s still #1 in the 2025 release candidate with 100% (previously 94%) of tested applications showing some form of broken access control. That number alone should make you pay attention to the concept. Add to this the fact that we are now in an era where pretty much every developer is using Large Language Models (LLM) to some extent and we are producing more code than ever as a result. Whether AI is helping us write more vulnerable code faster or we were already this bad at authorization, I’ll leave as an exercise to the reader.

No matter if you are building a multi-tenant application, adding LLM/AI features like search, summaries, or chat over data, you suddenly have a very real question to answer: is the user allowed to see the document I’m about to serve them?

This isn’t a hypothetical. Your AI pipeline retrieves 10 documents, your LLM generates a beautiful summary, and… oops, 3 of those documents belonged to a different tenant. Or a project the user lost access to last week. Or a folder they were never invited to.

Traditional authorization models weren’t built for this. Relationship-Based Access Control (ReBAC) was. The concept originates from Google’s Zanzibar paper, the system that powers authorization for Google Drive, YouTube, and other Google services at massive scale. In this text we’ll introduce the problems with general approaches, how ReBAC addresses the issue and of course some code examples.

The problem with traditional approaches

You’ve probably worked with Role-Based Access Control (RBAC) before. It assigns permissions through roles, but falls apart when access depends on context rather than static assignments.

Attribute-Based Access Control (ABAC) is more flexible, evaluating attributes at runtime, but quickly turns into a maze of policies that are a nightmare to debug.

Both have the same fundamental issue: they weren’t designed for a world where relationships are the primary driver of access.

The multi-tenant reality

In a multi-tenant application or just complex applications, authorization isn’t just “can this user do this action”. It’s:

  • Can this user see this document/data in this organization?
  • Can they access this folder because their team has access?
  • Should this file show up in search given the user’s current project context?

And now with AI features everywhere, every single retrieval becomes an authorization question. Your vector search doesn’t care about permissions. It just returns the most semantically similar documents. It’s on you to filter.

You can do this with scattered if-statements and query filters. I’ve done it. It works until it doesn’t. And when it doesn’t, you’re either leaking data across tenants or breaking features for users who should have access.

Why ReBAC fits

ReBAC models authorization based on relationships between users and resources. Instead of asking “what role does this user have?”, you ask “what is this user’s relationship to this resource?”

A quick example:

  1. Alice is a member of Organization A
  2. Organization A contains Folder X
  3. Folder X contains Document Y
  4. Alice can view Document Y because the relationship chain grants it

The beauty here is that when you add Alice to Organization A, she automatically gets access to everything inside it. When you remove her, access disappears. No manual syncing. No stale permissions. The relationships are the permissions.

For multi-tenant systems, this is exactly what you need. Tenant isolation becomes a natural property of the model rather than something you bolt on with query filters.

And for AI features? You can ask “which of these 50 retrieved documents can this user actually see?” and get a fast, correct answer.

I promised code, code you shall get

One of my favourite tools for ReBAC is OpenFGA, a CNCF incubating project. Another great VC backed alternative is AuthZed/SpiceDB. No matter what you end up choosing, I highly suggest going through the OpenFGA modeling guide. It’s the clearest introduction to thinking in relationships I’ve seen.

Let’s model something concrete: a multi-tenant system with organizations, folders, and documents. We want:

  • Users belong to organizations through teams
  • Folders inherit access from their parent organization
  • Documents inherit access from their parent folder
  • Some users can edit, others can only view
model
  schema 1.1

# A base type with no relations, used to represent people
type user

type team
  relations
    # [user] means this is a direct relationship.
    # You explicitly assign users to teams via API calls.
    define member: [user]

type organization
  relations
    define owner: [user]
    define team: [team]
    # No brackets = computed relationship, evaluated at runtime.
    # Members are either owners OR members of any team in this org.
    define member: owner or member from team

type folder
  relations
    # Which organization does this folder belong to?
    define organization: [organization]
    # Viewers are computed from the parent organization's members
    define viewer: member from organization
    # [user, team#member] means editors can be assigned directly as users
    # OR as members of a team. The # syntax lets you reference a specific
    # relation (member) on another type (team).
    define editor: [user, team#member]

type document
  relations
    define folder: [folder]
    define owner: [user]
    # Inherited from the parent folder
    define viewer: viewer from folder
    define editor: owner or editor from folder

    # These are the permissions we actually check in our application
    define can_view: viewer or editor
    define can_edit: editor

Reading the model

A few things to note:

  • type defines an entity (user, organization, document, etc.)
  • relations defines how entities connect to each other
  • [type] in brackets means a direct relationship, something you explicitly create via API
  • Without brackets means a computed relationship, derived at runtime

The magic is in lines like define viewer: member from organization. This says “viewers are all members from whatever organization this folder belongs to.” When you add someone to a team in that organization, they automatically get view access to all folders and documents inside. No extra API calls. No sync jobs.

Read more about the syntax here.

Filtering AI results

So how does this help with your application? Let’s say your application pipeline retrieves 20 documents. Before you feed them to the LLM (or return them in search results), you need to filter:

# Pseudocode - the actual OpenFGA SDK is similar
retrieved_docs = vector_search(query, limit=20)

# Batch check which documents the user can view
allowed = openfga.batch_check(
    user=f"user:{current_user.id}",
    checks=[
        {"object": f"document:{doc.id}", "relation": "can_view"}
        for doc in retrieved_docs
    ]
)

# Only use documents the user can actually see
filtered_docs = [doc for doc, ok in zip(retrieved_docs, allowed) if ok]

This is correct (the relationships are the source of truth), and doesn’t require you to maintain separate permission caches or query filters.

A word on performance

Both OpenFGA and SpiceDB approach this problem by essentially acting as specialized graph databases. When you check a permission, they traverse the relationship graph to compute the answer. Depending on how deep your hierarchy is and how complex your model, this can mean hundreds of roundtrips to the underlying datastore. This is one of the major drawbacks to be aware of: your model design matters a lot for performance.

In OpenFGA, calling ListObjects (give me all documents this user can view) is often more efficient than doing many individual checks (but this of course depends). If you can restructure your query to ask “what can this user access?” rather than “can this user access each of these 50 things?”, you’ll generally see better performance.

Both projects are actively working on optimizations. SpiceDB has introduced AuthZed Materialize, which pre-computes permissions ahead of time rather than calculating them at query time. Think of it like a materialized view in a relational database. This is a significant step forward for use cases like search filtering where you need to check permissions against thousands of documents. I’m hopeful OpenFGA will introduce similar capabilities soon.

If you’re interested in the pre-computation approach, I highly recommend this article from Feldera on solving fine-grained authorization with incremental computation. They demonstrate how to turn authorization checks into simple key/value lookups by pre-computing all relevant decisions ahead of time using their incremental SQL engine. It’s a fascinating approach to the same problem.

What if you could keep using your existing database and get transactional guarantees instead and good performance? Personally I’m convinced that there is a middle ground implementation that might work for a lot of people even if it might not grow to Google scale. If your datastore supports recursive querying and you have more of a read-heavy application/pipeline I don’t see why something like Postgresql wouldn’t serve this very well. It would however change quite a lot of how both SpiceDB and OpenFGA seem to approach the problem currently.

Testing the model

One of the underrated benefits of ReBAC: your authorization logic becomes testable. No more “deploy and pray” or clicking through permission matrices manually.

OpenFGA tests have 3 parts:

  1. The model (inline or referenced from a file)
  2. The tuples (the relationships you’ve created, think: “Oscar is a member of team writers”)
  3. The assertions (what access should result from those relationships)

Below is a full self-contained example:

model: |
    model
        schema 1.1

    type user

    type team
        relations
            define member: [user]

    type organization
        relations
            define owner: [user]
            define team: [team]
            define member: owner or member from team

    type folder
        relations
            define organization: [organization]
            define viewer: member from organization
            define editor: [user, team#member]

    type document
        relations
            define folder: [folder]
            define owner: [user]
            define viewer: viewer from folder
            define editor: owner or editor from folder
            define can_view: viewer or editor
            define can_edit: editor    

tuples:
  # Oscar is in the engineering team
  - user: user:oscar
    relation: member
    object: team:engineering

  # Engineering team belongs to Acme organization
  - user: team:engineering
    relation: team
    object: organization:acme

  # Project folder belongs to Acme organization
  - user: organization:acme
    relation: organization
    object: folder:project

  # Secret doc is in the project folder
  - user: folder:project
    relation: folder
    object: document:secret_doc

  # Emily is in a different organization entirely
  - user: user:emily
    relation: member
    object: team:sales

  - user: team:sales
    relation: team
    object: organization:other_company

tests:
  - name: "Oscar can view documents in his organization"
    check:
      - user: user:oscar
        object: document:secret_doc
        assertions:
          can_view: true

  - name: "Emily cannot view documents from another tenant"
    check:
      - user: user:emily
        object: document:secret_doc
        assertions:
          can_view: false

You can run this in CI using the OpenFGA GitHub Action or locally with fga model test --tests <path_to_file> (install via brew install openfga/tap/fga).

Final thoughts

If you’re building a multi-tenant application or even just complex relationships in your application/pipeline, authorization complexity is coming for you whether you like it or not. If you’re adding AI/LLM features on top, it’s not a question of if you’ll need fine-grained access control, but when.

ReBAC isn’t the answer to everything. If your app has 3 roles and no hierarchy, keep it simple. But for multi-tenant systems with organizations, folders, sharing, and now AI/LLM features that need to filter results? It’s been a joy to move this complexity out of scattered if-statements and into something declarative that I can reason about, test, and actually explain to people who don’t write code. I’m a proponent of boring technology and even if I can’t say that the solutions mentioned here are up to that level yet, I am sure ReBAC as a concept is going to be way more common in the future.

There are loads of things to say about implementing ReBAC where one of the main things we discussed was performance. However, there are quite a few more caveats that come with it that I might share in a future text.