The Problem Statement
Our relationship tuple model from Post 1 works perfectly for direct permissions:
// "Alice can view document 1"
RelationTuple{
Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "1", Relation: "viewer"},
Subject: &DirectSubject{Object: &ObjectRef{Namespace: "user", ObjectID: "alice"}},
}But your product manager returns with a new requirement:
"We need admin roles. Instead of granting 100 individual users access to document 1, we should be able to create an 'admin' role and grant the role access to the document."
With our current model, giving 100 users access to document 1 requires creating 100 separate tuples:
// This doesn't scale...
RelationTuple{Resource: document:1#viewer, Subject: user:alice}
RelationTuple{Resource: document:1#viewer, Subject: user:bob}
RelationTuple{Resource: document:1#viewer, Subject: user:charlie}
// ... 97 more tuples for each user
The core issue: We can model direct relationships, but we can't model role membership where multiple users belong to a group that gets permissions as a unit.
Why Direct Tuples Aren't Enough
Let's analyze what we're really trying to express:
The Human Rules
"Alice is an admin" (membership relationship)
"Admins can view document 1" (role-based permission)
These are two separate facts that should compose together, not 100 individual user permissions.
What Goes Wrong with Direct Tuples
Approach 1: Explicit enumeration
// Create a tuple for every user who can access the document
for each user {
store.Add(RelationTuple{Resource: document:1#viewer, Subject: user:N})
}Problems:
Scale explosion: 100 users = 100 tuples per document
Maintenance nightmare: Adding a new user to the admin role requires updating all document tuples
No role semantics: We lose the concept of admin roles—just individual unrelated permissions
Approach 2: Application-layer roles
func Check(user, resource string) bool {
// Check direct tuple first
if tupleStore.Contains(user, resource) {
return true
}
// Check if user is admin (application logic)
if userService.IsAdmin(user) {
return true
}
return false
}Problems:
Scattered logic: Role semantics live outside the tuple store
Inconsistent interface: Different systems implement role checks differently
No audit trail: We can't trace why Alice has access through the tuple store
The Core Insight
We need to model two types of relationships:
Direct relationships: "Alice can view document 1"
Membership relationships: "Alice is a member of the admin role"
But both should use the same tuple abstraction and compose naturally.
Important: SubjectSet solves the "many subjects, one resource" problem (100 users accessing 1 document). It does NOT solve the "one subject, many resources" problem (1 user accessing 1,000 documents)—that requires inheritance and computed relationships, which we'll cover in upcoming posts.
Extending the Model: SubjectSet
The solution is to extend our Subject type to reference not just individual users, but sets of users.
Current Subject (Direct)
type DirectSubject struct {
Object *ObjectRef // Points to "user:alice"
}This represents a single user: user:alice.
New Subject (Set Reference)
type SubjectSet struct {
Namespace string // Type of object ("role")
ObjectID string // Specific instance ("admin")
Relation string // Which relation on that object ("member")
}This represents all users who have the "member" relation on role:admin—in other words, role:admin#member.
The Complete Subject Interface
type Subject interface {
isSubject()
Type() SubjectType
Signature() string // Unique identifier for equality/hashing
String() string // Human-readable representation
}
type SubjectType int
const (
SubjectTypeDirect SubjectType = iota
SubjectTypeSet
)
Why these methods matter:
Signature(): Creates a unique string identifier used for equality checks and hashing. For example:DirectSubjectreturns"direct:user:alice"SubjectSetreturns"set:role:admin#member"
String(): Provides human-readable output for logging and debugging:DirectSubjectreturns"user:alice"SubjectSetreturns"role:admin#member"
This allows the tuple store to efficiently compare subjects and provides clear debugging output.
Now we can express both types of relationships using the same tuple structure, with Subject being an interface that both DirectSubject and SubjectSet implement.
How Role-Based Access Control Works
Let's model our admin scenario using the extended tuple format:
Step 1: Define Role Membership
// "Alice is a member of the admin role"
RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "role",
ObjectID: "admin",
Relation: "member",
},
Subject: &DirectSubject{
Object: &ObjectRef{Namespace: "user", ObjectID: "alice"},
},
}This creates the tuple: (user:alice) -> member -> (role:admin)
Step 2: Define Role Permissions
// "All admin role members can view document 1"
RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "document",
ObjectID: "1",
Relation: "viewer",
},
Subject: &SubjectSet{
Namespace: "role",
ObjectID: "admin",
Relation: "member",
},
}
This creates the tuple: (role:admin#member) -> viewer -> (document:1)
Now "Can Alice view document 1?" becomes a two-step lookup:
func Check(user, resource string) bool {
userRef := ParseDirectSubject(user) // "user:alice"
resourceRef := ParseObjectAndRelation(resource) // "document:1#viewer"
// Step 1: Check direct tuple
directTuple := &RelationTuple{Resource: resourceRef, Subject: userRef}
if tupleStore.Contains(directTuple) {
return true
}
// Step 2: Check role-based access
// Find all SubjectSet tuples that grant access to this resource
for _, tuple := range tupleStore.FindByResource(resourceRef) {
if tuple.Subject.Type() == SubjectTypeSet {
subjectSet := tuple.Subject.(*SubjectSet)
// This resource is granted to a role (e.g., role:admin#member)
roleUserset := &ObjectAndRelation{
Namespace: subjectSet.Namespace,
ObjectID: subjectSet.ObjectID,
Relation: subjectSet.Relation,
}
// Check if user is a member of this role
membershipTuple := &RelationTuple{
Resource: roleUserset,
Subject: userRef,
}
if tupleStore.Contains(membershipTuple) {
return true
}
}
}
return false
}The magic: We've implemented full RBAC using only tuple lookups. No application logic, no scattered role checks—just composition of relationships.
Testing the Extended Model
Happy Path: Role-Based Access
Setup:
// Alice is an admin
store.Add(RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "role",
ObjectID: "admin",
Relation: "member",
},
Subject: &DirectSubject{
Object: &ObjectRef{
Namespace: "user",
ObjectID: "alice",
},
},
})
// Admins can view document 1
store.Add(RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "document",
ObjectID: "1",
Relation: "viewer",
},
Subject: &SubjectSet{
Namespace: "role",
ObjectID: "admin",
Relation: "member",
},
})Check: "Can Alice view document 1?"
Direct check:
(user:alice) -> viewer -> (document:1)→ Not foundRole check:
Find grants to
document:1#viewer→ Foundrole:admin#memberCheck if
(user:alice) -> member -> (role:admin)→ Found
Result: TRUE
Edge Case 1: Multiple Roles
Setup:
// Alice is both admin and editor
store.Add(RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "role",
ObjectID: "admin",
Relation: "member",
},
Subject: &DirectSubject{
Object: &ObjectRef{
Namespace: "user",
ObjectID: "alice",
},
},
})
store.Add(RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "role",
ObjectID: "editor",
Relation: "member",
},
Subject: &DirectSubject{
Object: &ObjectRef{
Namespace: "user",
ObjectID: "alice",
},
},
})
// Both roles can view document 1
store.Add(RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "document",
ObjectID: "1",
Relation: "viewer",
},
Subject: &SubjectSet{
Namespace: "role",
ObjectID: "admin",
Relation: "member",
},
})
store.Add(RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "document",
ObjectID: "1",
Relation: "viewer",
},
Subject: &SubjectSet{
Namespace: "role",
ObjectID: "editor",
Relation: "member",
},
})Check: "Can Alice view document 1?"
Result: TRUE (through either role)
The model naturally handles multiple role memberships without special logic.
Edge Case 2: Invalid Role Relations
Setup:
// Typo: "members" instead of "member"
store.Add(RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "role",
ObjectID: "admin",
Relation: "members", // Wrong!
},
Subject: &DirectSubject{
Object: &ObjectRef{
Namespace: "user",
ObjectID: "alice",
},
},
})
store.Add(RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "document",
ObjectID: "1",
Relation: "viewer",
},
Subject: &SubjectSet{
Namespace: "role",
ObjectID: "admin",
Relation: "member", // Correct
},
})Check: "Can Alice view document 1?"
Alice is a member of
role:admin#members(wrong relation)Document grants access to
role:admin#member(correct relation)Result: FALSE (relation mismatch)
This prevents accidental access through typos or schema mismatches.
Edge Case 3: Cyclic Role Membership
Setup:
// Role A includes members of Role B
store.Add(RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "role",
ObjectID: "a",
Relation: "member",
},
Subject: &SubjectSet{
Namespace: "role",
ObjectID: "b",
Relation: "member",
},
})
// Role B includes members of Role A (cycle!)
store.Add(RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "role",
ObjectID: "b",
Relation: "member",
},
Subject: &SubjectSet{
Namespace: "role",
ObjectID: "a",
Relation: "member",
},
})
// Alice is in Role A
store.Add(RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "role",
ObjectID: "a",
Relation: "member",
},
Subject: &DirectSubject{
Object: &ObjectRef{
Namespace: "user",
ObjectID: "alice",
},
},
})
Current behavior: Our simple two-step lookup doesn't detect cycles—it only goes one level deep.
Why this is okay for now: We're building incrementally. Upcoming posts will introduce proper graph traversal with cycle detection. For now, we document this limitation and move forward.
Real-World Context
This pattern appears in every major authorization system:
Slack Workspaces
// User membership
RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "workspace",
ObjectID: "acme",
Relation: "member",
},
Subject: &DirectSubject{
Object: &ObjectRef{
Namespace: "user",
ObjectID: "alice",
},
},
}
// Role assignment within workspace
RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "role",
ObjectID: "admin",
Relation: "member",
},
Subject: &DirectSubject{
Object: &ObjectRef{
Namespace: "user",
ObjectID: "alice",
},
},
}
// Role permissions
RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "channel",
ObjectID: "general",
Relation: "viewer",
},
Subject: &SubjectSet{
Namespace: "workspace",
ObjectID: "acme",
Relation: "member",
},
}AWS IAM Groups
// Group membership
RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "group",
ObjectID: "developers",
Relation: "member",
},
Subject: &DirectSubject{
Object: &ObjectRef{
Namespace: "user",
ObjectID: "alice",
},
},
}
// Group permissions
RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "bucket",
ObjectID: "logs",
Relation: "reader",
},
Subject: &SubjectSet{
Namespace: "group",
ObjectID: "developers",
Relation: "member",
},
}
GitHub Organizations
// Org membership
RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "org",
ObjectID: "mycompany",
Relation: "member",
},
Subject: &DirectSubject{
Object: &ObjectRef{
Namespace: "user",
ObjectID: "alice",
},
},
}
// Team membership
RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "team",
ObjectID: "backend",
Relation: "member",
},
Subject: &DirectSubject{
Object: &ObjectRef{
Namespace: "user",
ObjectID: "alice",
},
},
}
// Team permissions
RelationTuple{
Resource: &ObjectAndRelation{
Namespace: "repo",
ObjectID: "api",
Relation: "admin",
},
Subject: &SubjectSet{
Namespace: "team",
ObjectID: "backend",
Relation: "member",
},
}
The pattern is universal: membership relationships compose with permission relationships to create scalable authorization.
Takeaways
Roles are just another type of relationship: Instead of special role logic, we model "Alice is a member of admin role" as a tuple, just like any other relationship.
SubjectSet enables composition: By allowing subjects to reference sets of users (like
role:admin#member), we can grant permissions to groups without enumerating individual users.RBAC emerges from tuple composition: Full role-based access control works through simple tuple lookups—no special role tables or application logic required.
What We've Built
We now have complete RBAC (Role-Based Access Control):
Users can have direct permissions on resources
Users can be members of roles
Roles can have permissions on resources
Authorization checks compose these relationships automatically
The tuple store remains the single source of truth, but now it can express both individual and group-based permissions.
Limitations (What's Still Missing)
Our model now handles roles, but we still can't express:
Permission inheritance: "Editors should automatically be viewers too"
Resource hierarchies: "Documents inherit permissions from their parent folder"
Conditional access: "Alice can only access during business hours"
These require different types of composition that we'll tackle in the next posts.
Up next (Post 3): From Roles to Relationships (ReBAC v1) We'll add computed relationships so the model can say "owners are automatically editors" and "editors are automatically viewers." This introduces our first graph traversal and the foundation for relationship-based access control.
