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:
Who has the relationship (Alice)
What the relationship points to (document 1)
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.
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)→ FALSEDifferent relation:
(user:alice) -> editor -> (document:1)when checking#viewer→ FALSEUnknown 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
Traditional authorization fails because it models relationships as database joins: Each new requirement adds exponential complexity to queries and scattered logic across systems.
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."
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.
