ABAC Permission System
Cloudillo uses Attribute-Based Access Control (ABAC) to provide flexible, fine-grained permissions across all resources. This system moves beyond simple role-based access control to enable sophisticated permission rules based on attributes of users, resources, and context.
What is ABAC?
Attribute-Based Access Control determines access by evaluating attributes rather than fixed roles. Instead of asking “Does this user have the admin role?”, ABAC asks “Does this user’s attributes match the policy rules for this resource?”
Benefits Over RBAC
Role-Based Access Control (RBAC):
if user.role == "admin" → allow
if user.role == "editor" → allow read/write
if user.role == "viewer" → allow readFixed roles, limited flexibility
Attribute-Based Access Control (ABAC):
if user.connected_to(resource.owner) AND resource.visibility == "connected" → allow
if resource.visibility == "public" → allow
if current_time < resource.expires_at → allowFlexible rules based on any attributes
Key Advantages
✅ Fine-Grained Control: Permission rules can use any attribute ✅ Context-Aware: Decisions based on time, location, relationship status ✅ Scalable: No need to create roles for every permission combination ✅ Decentralized: Resource owners define their own permission rules ✅ Expressive: Complex boolean logic with AND/OR operators
The Four-Object Model
ABAC decisions involve four types of objects:
1. Subject (Who)
The user or entity requesting access.
pub struct AuthCtx {
pub tn_id: TnId, // Tenant ID (database key)
pub id_tag: String, // Identity (e.g., "alice.example.com")
pub roles: Vec<String>, // Roles (e.g., ["user", "moderator"])
}Attributes Available:
tn_id- Internal tenant identifierid_tag- Public identity tagroles- Assigned roles- Relationship to resource owner (computed at runtime)
Example:
Subject:
id_tag: "bob.example.com"
roles: ["user"]2. Action (What)
The operation being attempted.
Format: resource:operation
Common Actions:
file:read → Read a file
file:write → Modify a file
file:delete → Delete a file
action:create → Create an action token
action:delete → Delete an action token
profile:update → Update a profile
profile:admin → Administrative access to profileFormat: resource:operation (e.g., file:read, action:create, profile:admin)
3. Object (Resource)
The resource being accessed. Must implement AttrSet trait to provide queryable attributes.
pub trait AttrSet {
fn get_attr(&self, key: &str) -> Option<Value>;
}Example - File Resource:
struct FileMetadata {
file_id: String,
owner: String, // "alice.example.com"
visibility: Visibility, // public, private, followers, etc.
created_at: i64,
size: u64,
shared_with: Vec<String>,
}Implementing AttrSet:
FileMetadata.get_attr(key):
switch key:
case "owner":
return Value::String(self.owner)
case "visibility":
return Value::String(self.visibility)
case "created_at":
return Value::Number(self.created_at)
case "size":
return Value::Number(self.size)
default:
return NoneAttributes Available (depends on resource type):
owner- Resource owner’s identityvisibility- Visibility levelcreated_at- Creation timestampaudience- Intended audience (for actions)shared_with- List of identities with explicit access- Any custom attributes defined by the resource
4. Environment (Context)
Contextual factors like time, location, or system state.
pub struct Environment {
pub current_time: i64, // Unix timestamp
pub request_origin: Option<String>, // Future: IP, location
pub system_load: Option<f32>, // Future: rate limiting context
}Attributes Available:
current_time- Current Unix timestamp- Future: IP address, geographic location, system load
Example:
Environment:
current_time: 1738483200
request_origin: null
system_load: nullVisibility Levels
Cloudillo defines five visibility levels that determine who can access a resource:
1. public
Everyone can access the resource, even unauthenticated users.
Use Cases:
- Public blog posts
- Open documentation
- Community announcements
- Shared files with public links
Permission Logic:
if resource.visibility == "public":
return ALLOWExample:
FileMetadata:
owner: "alice.example.com"
visibility: "public"
# Anyone can read this file2. private
Only the owner can access the resource.
Use Cases:
- Personal notes
- Private drafts
- Sensitive documents
- User settings
Permission Logic:
if resource.owner == subject.id_tag:
return ALLOW
else:
return DENYExample:
FileMetadata:
owner: "alice.example.com"
visibility: "private"
# Only alice can access3. followers
Followers of the owner can access the resource (plus the owner).
Use Cases:
- Social media posts visible to followers
- Updates shared with audience
- Blog posts for subscribers
Permission Logic:
if resource.owner == subject.id_tag:
return ALLOW
if subject.follows(resource.owner):
return ALLOW
else:
return DENYChecking Following Status:
is_following = meta_adapter.has_action(subject.tn_id, "FLLW", resource.owner)
# Checks if subject has a FLLW action token for the resource ownerExample:
ActionToken:
owner: "alice.example.com"
visibility: "followers"
type: "POST"
content: "Hello followers!"
# Accessible to anyone who follows alice4. connected
Connected users can access the resource (mutually connected users + owner).
A connection exists when both parties have issued CONN action tokens to each other.
Use Cases:
- Private conversations
- Shared documents between colleagues
- Connection-only updates
- Direct messages
Permission Logic:
if resource.owner == subject.id_tag:
return ALLOW
if bidirectional_connection(subject.id_tag, resource.owner):
return ALLOW
else:
return DENYChecking Connection Status:
# Check if both users have CONN tokens for each other
alice_to_bob = meta_adapter.has_action(alice_tn_id, "CONN", "bob.example.com")
bob_to_alice = meta_adapter.has_action(bob_tn_id, "CONN", "alice.example.com")
connected = alice_to_bob AND bob_to_aliceExample:
FileMetadata:
owner: "alice.example.com"
visibility: "connected"
file_name: "project-proposal.pdf"
# Only users connected to alice can access5. direct (Audience-Based)
Specific users listed in the audience field can access the resource.
Use Cases:
- Direct messages to specific users
- Files shared with specific people
- Invitations to specific identities
- Private group resources
Permission Logic:
if resource.owner == subject.id_tag:
return ALLOW
if resource.audience.contains(subject.id_tag):
return ALLOW
else:
return DENYExample:
ActionToken:
owner: "alice.example.com"
type: "MSG"
content: "Hi Bob!"
audience: ["bob.example.com"]
visibility: "direct"
# Only alice and bob can see thisPolicy Structure
ABAC uses two-level policies to define permission boundaries:
TOP Policy (Constraints)
Defines maximum permissions - what is never allowed.
Example:
TopPolicy:
Rule 1:
Condition: visibility == "public" AND size > 100MB
Effect: DENY
# Files larger than 100MB cannot be shared publicly
Rule 2:
Condition: created_at < (current_time - 86400)
Effect: DENY_WRITE
# Action tokens cannot be modified after 24 hoursBOTTOM Policy (Guarantees)
Defines minimum permissions - what is always allowed.
Example:
BottomPolicy:
Rule 1:
Condition: subject.id_tag == resource.owner
Effect: ALLOW
# Owner can always access their own resources
Rule 2:
Condition: visibility == "public" AND action == "read"
Effect: ALLOW
# Public resources are always readableDefault Rules
Between TOP and BOTTOM policies, default rules apply based on visibility and ownership:
default_permission_check(subject, action, object):
1. Check ownership:
if object.owner == subject.id_tag
return ALLOW
2. Check visibility:
switch object.visibility:
case "public":
if action ends with ":read"
return ALLOW
case "private":
return DENY
case "followers":
return check_following(subject, object)
case "connected":
return check_connection(subject, object)
case "direct":
return check_audience(subject, object)
3. Default deny
return DENYPolicy Operators
ABAC supports various operators for building permission rules:
Comparison Operators
Equals
visibility == "public"
subject.id_tag == resource.ownerNotEquals
status != "deleted"GreaterThan / LessThan
size > 1,000,000
created_at < current_timeGreaterThanOrEqual / LessThanOrEqual
age >= 18
priority <= 5Set Operators
Contains
"public" IN tags
subject.id_tag IN shared_withNotContains
subject.id_tag NOT IN blocked_usersIn
subject.role IN ["admin", "moderator"]Role Operator
HasRole
subject.HasRole("admin")
subject.HasRole("moderator")Logical Operators
And
(published == true) AND (visibility == "public")Or
(subject.id_tag == resource.owner) OR (subject.HasRole("admin"))Permission Evaluation Flow
When a permission check is requested:
1. Load Subject (user context from JWT)
↓
2. Load Object (resource with attributes)
↓
3. Load Environment (current time, etc.)
↓
4. Check TOP Policy (maximum permissions)
├─ If denied → return Deny
└─ If allowed → continue
↓
5. Check BOTTOM Policy (minimum permissions)
├─ If allowed → return Allow
└─ If not matched → continue
↓
6. Check Default Rules
├─ Ownership check
├─ Visibility check
└─ Relationship checks
↓
7. Return Decision (Allow or Deny)Evaluation Example
Request: Bob wants to read Alice’s file
Subject:
id_tag: "bob.example.com"
roles: ["user"]
Action: "file:read"
Object:
owner: "alice.example.com"
visibility: "connected"
file_id: "f1~abc123"
Environment:
current_time: 1738483200
Evaluation:
1. TOP Policy: No blocking rules → continue
2. BOTTOM Policy: Not owner → continue
3. Default Rules:
a. Is owner? No (alice ≠ bob)
b. Visibility = "connected"
c. Check connection:
- Alice has CONN to Bob? Yes
- Bob has CONN to Alice? Yes
- Result: Connected!
d. Action is "read"? Yes
→ ALLOWIntegration with Routes
Cloudillo uses permission middleware to enforce ABAC on HTTP routes:
Permission Middleware Layers
Routes are configured with permission middleware to enforce access control:
Protected Routes:
# Actions
GET /api/action + check_perm_action("read")
POST /api/action + check_perm_action("create")
DEL /api/action/:id + check_perm_action("delete")
# Files
GET /api/file/:id + check_perm_file("read")
PATCH /api/file/:id + check_perm_file("write")
DEL /api/file/:id + check_perm_file("delete")
# Profiles
PATCH /api/profile/:id + check_perm_profile("update")
PATCH /api/admin/profile/:id + check_perm_profile("admin")Each middleware checks permissions before the handler executes.
Middleware Implementation
The permission middleware follows this flow:
check_perm_action(action):
1. Extract auth context from request
2. Extract resource ID from request path
3. Load resource from storage adapter
4. Call abac::check_permission(auth, "action:{action}", resource, environment)
5. If allowed: proceed to next middleware/handler
6. If denied: return Error::PermissionDeniedThis middleware is applied to each route requiring permission checks (see Permission Middleware Layers above).
Examples
Example 1: Public File Access
Alice creates a public file:
POST /api/file/image/logo.png
Authorization: Bearer <alice_token>
Body: <image data>
Response:
file_id: "f1~abc123"
visibility: "public"Bob reads the file (no connection needed):
GET /api/file/f1~abc123
Permission Check:
Subject: bob.example.com
Action: file:read
Object: { owner: alice, visibility: public }
Decision: ALLOW (public resources readable by anyone)Example 2: Connected-Only File
Alice creates a connected-only file:
POST /api/file/document/private-notes.pdf
Authorization: Bearer <alice_token>
Body: { visibility: "connected" }
Response:
file_id: "f1~xyz789"
visibility: "connected"Bob tries to read (not connected):
GET /api/file/f1~xyz789
Authorization: Bearer <bob_token>
Permission Check:
Subject: bob
Action: file:read
Object: { owner: alice, visibility: connected }
Connection: alice ↔ bob? NO
Decision: DENYCharlie tries to read (connected):
GET /api/file/f1~xyz789
Authorization: Bearer <charlie_token>
Permission Check:
Subject: charlie
Action: file:read
Object: { owner: alice, visibility: connected }
Connection: alice ↔ charlie? YES
Decision: ALLOWExample 3: Role-Based Access
Admin deletes any profile:
DELETE /api/admin/profile/bob.example.com
Authorization: Bearer <admin_token>
Permission Check:
Subject: admin (roles: [admin])
Action: profile:admin
Object: { owner: bob }
HasRole("admin")? YES
Decision: ALLOWRegular user tries same action:
DELETE /api/admin/profile/bob.example.com
Authorization: Bearer <alice_token>
Permission Check:
Subject: alice (roles: [user])
Action: profile:admin
HasRole("admin")? NO
Decision: DENYExample 4: Time-Based Access
Define policy: documents expire after 30 days
TopPolicy:
Rule:
Condition: (expires_at < current_time) AND (action == "read")
Effect: DENYBob tries to read expired document:
GET /api/file/f1~old123
Permission Check:
Subject: bob
Action: file:read
Object: {
owner: alice
visibility: public
expires_at: 1738400000 (in the past)
}
Environment: { current_time: 1738483200 }
TOP Policy check:
1738400000 < 1738483200? YES
Decision: DENY (expired)Implementing Custom Policies
Adding a Custom Policy
Define a custom policy for team files:
create_team_policy():
top_rules:
Rule 1:
Condition: (type == "team") AND (visibility == "public")
Effect: DENY
Description: "Team files must not be public"
bottom_rules:
Rule 1:
Condition: (type == "team") AND (subject.id_tag IN members) AND (action == "read")
Effect: ALLOW
Description: "Team members can read team files"Apply in handler:
get_team_file(auth, file_id):
file = meta_adapter.read_file(file_id)
allowed = abac::check_permission_with_policy(
auth,
"file:read",
file,
Environment::current(),
create_team_policy()
)
if NOT allowed:
return Error::PermissionDenied
return serve_file(file)Defining Custom Attributes
struct TeamFile {
file_id: String
owner: String
team_id: String
members: List[String]
file_type: String
}Implementing AttrSet trait:
TeamFile.get_attr(key):
switch key:
case "owner":
return Value::String(self.owner)
case "team_id":
return Value::String(self.team_id)
case "members":
return Value::Array(self.members)
case "type":
return Value::String(self.file_type)
default:
return NonePerformance Considerations
Caching Relationship Checks
Following/connection checks can be expensive. Cache results to avoid repeated database queries:
RelationshipCache:
ttl: Duration (cache validity)
cache: HashMap[(user_a, user_b) → (bool, timestamp)]
check_connection(user_a, user_b):
1. Check cache for (user_a, user_b)
if found AND cached_at.elapsed() < ttl
return cached_result
2. Query database
connected = check_connection_db(user_a, user_b)
3. Cache result
cache[(user_a, user_b)] = (connected, now)
4. Return resultOptimizing Policy Evaluation
For complex policies, evaluate cheapest conditions first:
// Bad: Expensive database check first
And(vec![
Attr("members").Contains(subject.id_tag), // DB query
Attr("type").Equals("team"), // Cheap
])
// Good: Cheap check first
And(vec![
Attr("type").Equals("team"), // Cheap, fails fast
Attr("members").Contains(subject.id_tag), // Only if needed
])Permission Check Batching
When checking permissions for multiple resources, batch-fetch relationships to minimize database queries:
check_permissions_batch(auth, action, resources):
1. Extract all resource owners
owners = {r.owner for r in resources}
2. Pre-fetch all relationships
relationships = fetch_relationships_batch(auth.id_tag, owners)
# Fetches all FLLW/CONN tokens in one query
3. Check each resource
for resource in resources:
permission = check_permission_with_cache(
auth, action, resource, relationships
)
results.append(permission)
4. Return resultsThis avoids N+1 queries where each permission check would require separate lookups.
Security Best Practices
1. Default Deny
Always default to denying access unless explicitly allowed:
Good Pattern:
check_permission(...):
check all explicit allow conditions
if any match: return ALLOW
otherwise: return DENY
Bad Pattern:
check_permission(...):
check some conditions
forget to handle unknown cases
accidentally allows access2. Validate on Both Sides
Check permissions in both locations:
-
Client-side: For UX (show/hide UI elements)
- Don’t rely on this for security
- Users can modify client-side checks
-
Server-side: For security (enforce access control)
- Always validate, even if client already checked
- Trust only server-side permission checks
Example flow:
Client:
if canDeleteFile(auth, file):
show delete button (UX convenience)
Server:
delete_file(auth, file_id):
file = load_file(file_id)
if NOT check_permission(auth, "file:delete", file):
return Error::PermissionDenied
// Safe to proceed with deletion3. Audit Permission Denials
Log all permission denials for security monitoring:
if NOT allowed:
log warning:
subject: auth.id_tag
action: action
resource: resource.id
timestamp: current_time
reason: "ABAC permission denial"
return Error::PermissionDeniedLog fields should include subject identity, action attempted, resource ID, and timestamp for audit trails.
4. Test Permission Policies
Write comprehensive tests for all permission scenarios:
test_connected_file_access():
# Setup
alice = create_user("alice")
bob = create_user("bob")
charlie = create_user("charlie")
create_connection(alice, bob) # Alice ↔ Bob
file = create_file(alice, visibility="connected")
# Test cases
assert check_permission(alice, "file:read", file) # Owner → ALLOW
assert check_permission(bob, "file:read", file) # Connected → ALLOW
assert NOT check_permission(charlie, "file:read", file) # Not connected → DENYTest all visibility levels (public, private, followers, connected, direct) and edge cases (expired resources, role-based access, custom attributes).
Troubleshooting
Permission Denied But Should Be Allowed
Debugging steps:
-
Check visibility level:
visibility = resource.visibility # Expected: "public" | "private" | "followers" | "connected" | "direct" -
Check ownership:
owner = resource.owner subject_id = subject.id_tag # If owner == subject_id, should have full access -
Check relationship status:
following = check_following(subject, resource) connected = check_connection(subject, resource) # Verify FLLW and/or CONN tokens exist if needed -
Check action format:
Wrong: Action("read") # Missing resource type Correct: Action("file:read") # Includes resource type format -
Enable debug logging:
RUST_LOG=cloudillo::core::abac=debug cargo run # Shows decision flow and matched rules
Relationship Checks Not Working
Common issues:
-
Missing action tokens: Ensure FLLW/CONN tokens exist
Check for FLLW token: meta_adapter.read_action(tn_id, "FLLW", target) Check for CONN token: meta_adapter.read_action(tn_id, "CONN", target) # If not found, relationship check will return false -
Unidirectional connection: Both sides need CONN tokens
Alice → Bob: CONN token exists Bob → Alice: CONN token MISSING Result: NOT connected (requires bidirectional tokens) -
Cache staleness: Clear relationship cache if stale
relationship_cache.clear() # Cache holds results for TTL duration; manually clear if needed
See Also
- [Access Control](/architecture/data-layer/access-control/access - Token-based authentication
- [Actions](/architecture/actions-federation/actions - Action tokens for relationships (CONN, FLLW)
- System Architecture - Overall system design
- Identity System - User identity and authentication