Access Control
The SSO platform implements a Relationship-Based Access Control (ReBAC) system inspired by Google Zanzibar. This provides fine-grained, scalable authorization that supports both direct user permissions and inherited permissions through organizational relationships.
Overview
The access control system replaces traditional role-based access control (RBAC) with a more flexible ReBAC model. Instead of assigning fixed roles, the system grants permissions based on relationships between users, organizations, and resources.
Relation Tuples
At the core of the ReBAC system are relation tuples, which represent permissions using the format:
object#relation@subject
Components
- Object: The resource being accessed, consisting of:
namespace: The resource type (organization, service, plan, etc.)object_id: The specific resource instance ID
- Relation: The permission type (owner, admin, member, viewer, etc.)
- Subject: The entity receiving the permission, which can be:
- A direct user grant:
user:user_id - A userset (inherited permission):
namespace:object_id#relation
- A direct user grant:
Examples
Direct User Permission:
organization:org-123#admin@user:user-456
User user-456 has the admin relation on organization org-123.
Userset Permission (Inherited):
service:svc-abc#viewer@organization:org-123#member
All members of organization org-123 inherit the viewer relation on service svc-abc.
Namespaces
The system defines several resource namespaces:
| Namespace | Description |
|---|---|
organization |
Multi-tenant organizations |
service |
Applications using the SSO |
plan |
Subscription plans |
webhook |
Webhook configurations |
api_key |
API authentication keys |
user |
End-users |
Permission Relations
Standard relations supported by the system:
| Relation | Description | Typical Use |
|---|---|---|
owner |
Full control including deletion | Organization creator |
admin |
Administrative access, can manage members | Organization administrators |
member |
Basic membership access | Regular organization members |
viewer |
Read-only access | Auditors, observers |
editor |
Modify resource content | Content managers |
manager |
Manage resource settings | Resource managers |
Permission Hierarchy
The system implements hierarchical permissions through userset expansion. When checking if a user has a permission, the authorization engine:
- Checks direct grants - Does the user have a direct permission tuple?
- Expands usersets - Does the user belong to any groups that grant this permission?
- Recursive expansion - Continues expanding until all paths are checked (max depth: 10)
Example: Organization Membership
When a user joins an organization as a member:
- System creates:
organization:org-123#member@user:user-456 - Service access is granted via:
service:svc-abc#viewer@organization:org-123#member - User can now view the service through inherited permission
Roles vs Permissions
The system maintains the concept of “roles” (Owner, Admin, Member) for user-facing interfaces, but internally these map to specific permission relations:
| Role | Permissions Granted |
|---|---|
| Owner | owner relation on the organization |
| Admin | admin relation on the organization |
| Member | member relation on the organization |
Important: The API enforces permissions (e.g., write:users, manage:services), not roles. Roles are semantic groupings of permissions for organizational convenience.
Permission Checks
The authorization engine implements Zanzibar’s Check algorithm with optimizations:
PermissionsStore::check(
db,
"organization", // namespace
"org-123", // object_id
"admin", // relation
"user-456" // user_id
)
Algorithm
- Direct Check: Query for
user:user-456with the exact relation - Userset Expansion: Find all userset grants for this permission
- Recursive Check: For each userset, check if the user has the required relation
- Depth Limiting: Prevents circular dependencies (max depth: 10)
- Cycle Detection: Tracks visited checks to avoid infinite loops
Permission Operations
Granting Permissions
// Direct user permission
let tuple = RelationTuple::user(
Namespace::Organization,
"org-123",
"admin",
"user-456"
);
PermissionsStore::grant(db, tuple).await?;
// Userset permission
let tuple = RelationTuple::userset(
Namespace::Service,
"svc-abc",
"viewer",
Namespace::Organization,
"org-123",
PermissionRelation::Member
);
PermissionsStore::grant(db, tuple).await?;
Revoking Permissions
PermissionsStore::revoke(
db,
"organization",
"org-123",
"admin",
"user",
"user-456",
None // subject_relation
).await?;
Expanding Permissions
The Expand algorithm returns all users who have a specific permission:
// Get all users who can view this service
let user_ids = PermissionsStore::expand(
db,
"service",
"svc-abc",
"viewer"
).await?;
Database Schema
The permissions table stores relation tuples:
CREATE TABLE permissions (
id TEXT PRIMARY KEY,
namespace TEXT NOT NULL,
object_id TEXT NOT NULL,
relation TEXT NOT NULL,
subject_type TEXT NOT NULL,
subject_id TEXT NOT NULL,
subject_relation TEXT,
created_at TEXT NOT NULL
);
Indexes
The table includes optimized indexes for:
- Permission checks:
(namespace, object_id, relation, subject_type, subject_id) - Userset expansion:
(namespace, object_id, relation) - User permission listing:
(subject_type, subject_id) - Object cleanup:
(namespace, object_id) - Uniqueness: Composite unique constraint on all tuple fields
Service vs Organization API
The SSO platform provides two distinct APIs with different authorization contexts:
Organization API
- Authentication: JWT tokens issued to human administrators
- Authorization: Based on organization membership permissions
- Use Case: Human administrators managing their organization
- Endpoints:
/api/organizations/*,/api/user/*
Service API
- Authentication: API keys with SHA256 hashing
- Authorization: Service-scoped permissions
- Use Case: Machine-to-machine service interactions
- Endpoints:
/api/service/*
Security Considerations
Circular Dependencies
The permission check algorithm includes cycle detection to prevent infinite loops when permission relationships form cycles.
Performance
- Permissions checks are optimized with database indexes
- The system uses SQLite with WAL mode for concurrent reads
- Maximum recursion depth prevents expensive queries
- Visited set prevents redundant checks
Cleanup
When deleting resources, all associated permissions must be cleaned up:
PermissionsStore::delete_object_permissions(
db,
"organization",
"org-123"
).await?;
Common Patterns
Organization Membership
// Grant membership
PermissionsStore::grant_org_membership(
db, "org-123", "user-456", "member"
).await?;
// Check membership
let is_member = PermissionsStore::is_org_member(
db, "org-123", "user-456"
).await?;
Service Access
// Grant entire organization access to service
PermissionsStore::grant_service_access_to_org(
db, "svc-abc", "org-123", "viewer"
).await?;
Admin Checks
// Check if user is owner or admin
let is_admin = PermissionsStore::is_org_owner_or_admin(
db, "org-123", "user-456"
).await?;
Migration from Roles
The platform migrated from a traditional membership-based roles system to ReBAC. The migration populated the permissions table with relation tuples derived from the legacy memberships table, preserving all existing access relationships.
Organizations using the API experienced no disruption, as the role-based concepts remain in the API surface while the underlying enforcement uses ReBAC.
Related Concepts
- Authentication - How users authenticate to obtain tokens
- Background Jobs - Async permission sync operations
- Rate Limiting - Per-permission rate limiting