The Problem Statement

You're building a document management system.
It starts simple: users can view or edit documents.
You create a straightforward database schema:

CREATE TABLE users (id, name);
CREATE TABLE documents (id, title, content);
CREATE TABLE permissions (user_id, document_id, permission_type);

Perfect!
"Can Alice view document 1?" is just:

SELECT 1 FROM permissions
WHERE user_id = 'alice' AND document_id = '1' AND permission_type = 'view';

But then your product manager arrives with new requirements…

Requirement 1: User Groups

“We need teams. Marketing team members should access marketing documents.”

CREATE TABLE groups (id, name);
CREATE TABLE group_memberships (user_id, group_id);
CREATE TABLE group_permissions (group_id, document_id, permission_type);

Now your query becomes:

SELECT 1 FROM permissions
WHERE user_id = 'alice' AND document_id = '1' AND permission_type = 'view'
UNION
SELECT 1 FROM group_permissions gp
JOIN group_memberships gm ON gp.group_id = gm.group_id
WHERE gm.user_id = 'alice' AND gp.document_id = '1' AND gp.permission_type = 'view';

Requirements 2–4: The Complexity Explosion

Each new requirement adds more tables and logic:

  • Hierarchical permissions → recursive queries

  • Folder inheritance → folder & mapping tables

  • Conditional access → application-side business logic

This can't be expressed in SQL alone. You need application logic:

func CanAccess(user *User, doc *Document, perm Permission) bool {
	// Check direct permissions
	if HasDirectPermission(user, doc, perm) {
		if user.Name == "alice" {
			return IsBusinessHours()
		}
		return true
	}

	// Check group permissions
	for i := range user.Groups {
		if HasGroupPermission(&user.Groups[i], doc, perm) {
			return true
		}
	}

	// Check folder inheritance
	for i := range doc.Folders {
		if CanAccessFolder(user, &doc.Folders[i], perm) {
			return true
		}
	}

	return false
}

The result: "Can Alice view document 1?" now requires joining 5+ tables, recursive queries, and scattered business logic. Each new requirement adds complexity exponentially, not linearly.

Why Traditional Approaches Fail

Problem 1: Exponential Query Complexity

Each new requirement doesn't just add one more table—it adds combinatorial complexity. With 4 permission sources (direct, group, hierarchy, folder), checking one permission requires:

  • 4 different query patterns

  • Recursive traversal for hierarchies and folders

  • Union operations across all sources

  • Application logic for conditions

Why this matters: Query performance degrades unpredictably. Adding one new permission source can make all authorization checks 10x slower.

Problem 2: Scattered Logic

Authorization logic now lives in three places:

  • Database schema: Tables and foreign keys

  • SQL queries: Joins and recursive CTEs

  • Application code: Business rules and conditions

Why this matters: When you need to answer "Why can Alice access this?", you have to trace through database schema, query logic, AND application code. Debugging becomes impossible.

Problem 3: Inconsistent Semantics

Different parts of your system implement authorization differently:

  • API endpoints check permissions in controllers

  • Background jobs check permissions in workers

  • Database triggers check permissions in SQL

  • Frontend checks permissions in JavaScript

Why this matters: You get different answers to the same question depending on where you ask it. This creates security holes and user confusion.

Problem 4: No Single Source of Truth

"Can Alice view document 1?" has no definitive answer because:

  • The database says "maybe" (if the right joins exist)

  • The application code says "depends" (on runtime conditions)

  • The frontend says "probably" (based on cached data)

Why this matters: Auditing and compliance become impossible. You can't prove what permissions existed at any point in time.

The Core Insight

The fundamental issue isn't technical—it's conceptual. We're trying to model relationships using tables.

Authorization is inherently about relationships:

  • "Alice is a member of the Marketing team"

  • "Marketing team has edit access to marketing documents"

  • "Edit permission includes view permission"

  • "Documents inherit permissions from their folders"

But relational databases are optimized for entities, not relationships. Every relationship becomes a foreign key, every rule becomes a join, every condition becomes application logic.

What if we modeled relationships as first-class entities instead?

A Minimal Relationship Model

Let's step back and ask: what is an authorization rule, really?

"Alice can view document 1."

This is a relationship between Alice and document 1. Specifically, it's a relationship of type "can view."

Instead of thinking in terms of Subject-Action-Resource-Condition, let's think in terms of relationships:

  • Subject: Alice (who has the relationship)

  • Object: document 1 (what the relationship points to)

  • Relation: view (what type of relationship it is)

Why "relation" instead of "action"? Because authorization isn't just about actions—it's about ongoing relationships between entities. Alice doesn't just "perform the action of viewing"—she has a viewing relationship with the document.

This subtle shift changes everything.

SARC vs RTM: What's Solved Now, What's Deferred

Let's test both models with examples to see where Subject-Action-Resource-Condition (SARC) fails while the Relation-Tuple Model (RTM) succeeds:

Example 1: Simple Direct Permission

Human rule: "Alice can view document 1."

SARC Model:

  • Subject: Alice

  • Action: view

  • Resource: document 1

  • Condition: (none)

RTM:

  • Subject: Alice

  • Object: document 1

  • Relation: viewer

Result: Both models work fine for direct permissions.

Example 2: Role-Based Permission (Limitation)

Human rule: "Admins can view all documents."

SARC Model:

  • Subject: Alice

  • Action: view

  • Resource: document 1

  • Condition: Alice is admin

This collapses two separate facts ("Alice is admin" and "admins can view documents") into a single conditional statement. It misses the compositional structure.

RTM (today): We can grant explicit tuples per document, but not "all documents" in one shot without naming specific documents.

Why it feels awkward in SARC: It forces us to repeat the condition for every resource check instead of modeling the underlying relationships.

What we'll add later: A way to compose facts (roles, sets, and inheritance) so the model expresses this naturally through SubjectSet references.

Example 3: Folder Inheritance (Conceptual)

Human rule: "Documents inherit viewer permissions from their parent folder."

SARC Model:

  • Subject: Alice

  • Action: view

  • Resource: document 1

  • Condition: Alice can view parent folder AND document 1 is in that folder

This creates complex transitive conditions that are hard to reason about and query efficiently.

RTM (today): Direct tuples can store the facts ("Alice is viewer of folder X", "doc1 is in folder X"), but they don't combine automatically.

What we'll add later: An operation to follow the parent edge and reuse folder viewers at the document level.

The Fundamental Difference

SARC Model treats authorization as conditional actions: "Subject can perform Action on Resource if Condition is true."

RTM treats authorization as composable relationships: "Subject has Relation to Object, and relationships can be composed through rules."

The key insight: conditions grow exponentially, but relationships compose linearly.

With SARC, each new requirement adds to the condition complexity. With RTM, each new requirement adds new facts that compose with existing facts through well-defined rules.

Building the Relationship Model

If authorization is about relationships, let's model it that way. We need to represent:

"Alice has a viewing relationship with document 1."

Step 1: What Makes a Relationship?

Every relationship has three parts:

  1. Who has the relationship (Alice)

  2. What the relationship points to (document 1)

  3. What type of relationship it is (viewing)

But we need to be more precise. "Alice" could refer to a user, a service account, or a bot. "Document 1" could be a file, a database record, or an API resource. We need namespaces to avoid ambiguity:

  • Subject: user:alice (not just "alice")

  • Object: document:1 (not just "1")

  • Relation: viewer (not just "view")

Step 2: Why "RelationTuple"?

We could call this a "Permission" or "Grant" or "AccessRule". But those terms imply action-based thinking. We're modeling relationships, so let's call it what it is.

A tuple is just a mathematical term for "a collection of ordered elements." Our relationship has three ordered elements: (subject, object, relation).

But there's a subtlety: the relationship isn't just between Alice and document 1. It's between Alice and the viewer role on document 1. Alice doesn't have a relationship with the document itself—she has a relationship with a specific aspect of the document.

So our tuple is really: (user:alice, document:1#viewer)

Step 3: The Minimal Structure

type RelationTuple struct {
    Resource *ObjectAndRelation  // What we're granting access to
    Subject  Subject             // Who we're granting access to
}

Why "Resource" instead of "Object"? Because we're not just pointing to an object—we're pointing to a specific relation on that object. document:1#viewer is a userset that can be granted.

We grant access to usersets (e.g., document:1#viewer), not to bare objects—because relations like viewer, editor, owner each have distinct semantics and composition rules.

type ObjectAndRelation struct {
    Namespace string  // Type of object ("document")
    ObjectID  string  // Specific instance ("1")
    Relation  string  // Which aspect ("viewer")
}

This gives us document:1#viewer—a precise identifier for "the viewer role on document 1."

We grant usersets (namespace:id#relation), not bare objects; each relation (viewer/editor/owner) is a distinct userset.

type DirectSubject struct {
    Object *ObjectRef  // Who has the relationship
}

type ObjectRef struct {
    Namespace string  // Type of subject ("user")
    ObjectID  string  // Specific instance ("alice")
}

This gives us user:alice—a precise identifier for Alice.

Step 4: Why This Works

Our authorization rule becomes:

RelationTuple{
    Resource: &ObjectAndRelation{
        Namespace: "document",
        ObjectID:  "1",
        Relation:  "viewer",
    },
    Subject: &DirectSubject{
        Object: &ObjectRef{
            Namespace: "user",
            ObjectID:  "alice",
        },
    },
}

This represents exactly one fact: "user:alice has the viewer relation on document:1."

No joins. No recursive queries. No scattered logic. Just one atomic fact.

Testing Our Model

Let's see if this relationship model solves our original problems:

Problem: Complex Queries

Before: 5-table joins with recursive CTEs After: Simple lookup: "Does tuple (user:alice, document:1#viewer) exist?"

Problem: Scattered Logic

Before: Logic in database schema, SQL queries, and application code After: All authorization facts are RelationTuples. Logic is just "check if tuple exists."

Problem: Inconsistent Semantics

Before: Different systems implement authorization differently After: Every system just checks tuples. Same interface everywhere.

Problem: No Single Source of Truth

Before: Authorization spread across multiple systems After: All authorization facts are tuples in one store.

The Authorization Check

func Check(user, resource string) bool {
    tuple := &RelationTuple{
        Resource: ParseObjectAndRelation(resource), // "document:1#viewer"
        Subject:  ParseDirectSubject(user),         // "user:alice"
    }
    return tupleStore.Contains(tuple) // direct-tuple only; no roles/edges/conditions yet
}

That's it. Authorization becomes a simple direct-tuple lookup. No joins, no recursion, no business logic scattered across your codebase.

Direct-tuple cases (closed in this post)

Happy path

  • Tuple exists: (user:alice) -> viewer -> (document:1)TRUE

Edge cases

  • Different subject: (user:bob) -> viewer -> (document:1)FALSE

  • Different relation: (user:alice) -> editor -> (document:1) when checking #viewerFALSE

  • Unknown relation name in request (e.g., document:1#v1ewer): FALSE (invalid userset is not granted)

  • Resource wildcards (e.g., document:*#viewer) are not supported in RTM (by design)

What the foundation does NOT do (yet)

  • No roles/sets (e.g., "all members of role:admin")

  • No inheritance across resources (e.g., folder → document)

  • No contextual conditions (e.g., business hours)

We'll add these incrementally in next posts—without changing the core tuple.

Why This Foundation Matters

  • Single source of truth: all auth facts are tuples

  • Simple & fast checks: direct lookups

  • Uniform semantics: same interface across services

  • Deterministic & bounded: we'll formalize limits later

But it only handles direct relationships. The requirements that broke our traditional approach still need solutions:

  • User Groups: "Marketing team members should access marketing documents"

  • Hierarchical Permissions: "Editors should automatically be viewers too"

  • Folder Inheritance: "Documents inherit permissions from their parent folder"

  • Conditional Access: "Alice can only access during business hours"

We've built a solid foundation. Now we need to extend it to handle composition.

Real-World Context (brief)

Modern systems converge on relationship tuples:

  • Google Zanzibar: permission checks are relationship lookups; composition (groups, hierarchy) is computed on top.

  • GitHub: tuples like (user:alice, repo:myproject#admin); org/team membership composes permissions.

RTM mirrors this: one store of tuples, composition added incrementally (roles inheritance conditions).

Takeaways

  1. Traditional authorization fails because it models relationships as database joins: Each new requirement adds exponential complexity to queries and scattered logic across systems.

  2. Authorization is fundamentally about relationships, not actions: Instead of "Alice performs action view on document 1," think "Alice has a viewing relationship with document 1."

  3. Relationship tuples provide a unified abstraction: All authorization facts become tuples. All authorization checks become tuple lookups. This eliminates scattered logic and provides a single source of truth.

What We've Built

We now have a direct relationship model that can answer "Can X do Y on Z?" with explicit tuples. This handles the foundational case: direct permissions granted to specific users.

The foundation is solid, but we need to extend it to handle composition of relationships through roles, inheritance, and conditions.

Up next (Post 2): Roles & Memberships We'll keep the tuple store exactly as-is and add a new subject form—SubjectSet—so the model can say "all members of role:admin are viewers." That closes the "groups/roles" gap without introducing inheritance or conditions yet.

Keep Reading