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

  1. "Alice is an admin" (membership relationship)

  2. "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:

  1. Direct relationships: "Alice can view document 1"

  2. 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:

    • DirectSubject returns "direct:user:alice"

    • SubjectSet returns "set:role:admin#member"

  • String(): Provides human-readable output for logging and debugging:

    • DirectSubject returns "user:alice"

    • SubjectSet returns "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)

Step 3: Authorization Check with Composition

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?"

  1. Direct check: (user:alice) -> viewer -> (document:1) → Not found

  2. Role check:

    • Find grants to document:1#viewer → Found role:admin#member

    • Check if (user:alice) -> member -> (role:admin) → Found

  3. 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

  1. 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.

  2. 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.

  3. 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.

Keep Reading