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 read

Fixed 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 → allow

Flexible 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 identifier
  • id_tag - Public identity tag
  • roles - 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 profile

Format: 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 None

Attributes Available (depends on resource type):

  • owner - Resource owner’s identity
  • visibility - Visibility level
  • created_at - Creation timestamp
  • audience - 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: null

Visibility 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 ALLOW

Example:

FileMetadata:
    owner: "alice.example.com"
    visibility: "public"
    # Anyone can read this file

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

Example:

FileMetadata:
    owner: "alice.example.com"
    visibility: "private"
    # Only alice can access

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

Checking 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 owner

Example:

ActionToken:
    owner: "alice.example.com"
    visibility: "followers"
    type: "POST"
    content: "Hello followers!"
    # Accessible to anyone who follows alice

4. 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 DENY

Checking 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_alice

Example:

FileMetadata:
    owner: "alice.example.com"
    visibility: "connected"
    file_name: "project-proposal.pdf"
    # Only users connected to alice can access

5. 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 DENY

Example:

ActionToken:
    owner: "alice.example.com"
    type: "MSG"
    content: "Hi Bob!"
    audience: ["bob.example.com"]
    visibility: "direct"
    # Only alice and bob can see this

Policy 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 hours

BOTTOM 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 readable

Default 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 DENY

Policy Operators

ABAC supports various operators for building permission rules:

Comparison Operators

Equals

visibility == "public"
subject.id_tag == resource.owner

NotEquals

status != "deleted"

GreaterThan / LessThan

size > 1,000,000
created_at < current_time

GreaterThanOrEqual / LessThanOrEqual

age >= 18
priority <= 5

Set Operators

Contains

"public" IN tags
subject.id_tag IN shared_with

NotContains

subject.id_tag NOT IN blocked_users

In

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
   → ALLOW

Integration 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::PermissionDenied

This 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: DENY

Charlie 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: ALLOW

Example 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: ALLOW

Regular 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: DENY

Example 4: Time-Based Access

Define policy: documents expire after 30 days

TopPolicy:
    Rule:
        Condition: (expires_at < current_time) AND (action == "read")
        Effect: DENY

Bob 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 None

Performance 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 result

Optimizing 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 results

This 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 access

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

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

Log 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 → DENY

Test 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:

  1. Check visibility level:

    visibility = resource.visibility
    # Expected: "public" | "private" | "followers" | "connected" | "direct"
  2. Check ownership:

    owner = resource.owner
    subject_id = subject.id_tag
    # If owner == subject_id, should have full access
  3. Check relationship status:

    following = check_following(subject, resource)
    connected = check_connection(subject, resource)
    # Verify FLLW and/or CONN tokens exist if needed
  4. Check action format:

    Wrong:  Action("read")           # Missing resource type
    Correct: Action("file:read")      # Includes resource type format
  5. Enable debug logging:

    RUST_LOG=cloudillo::core::abac=debug cargo run
    # Shows decision flow and matched rules

Relationship Checks Not Working

Common issues:

  1. 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
  2. Unidirectional connection: Both sides need CONN tokens

    Alice → Bob: CONN token exists
    Bob → Alice: CONN token MISSING
    Result: NOT connected (requires bidirectional tokens)
  3. 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