Permission System

Cloudillo’s permission system has two pillars that work together to control access to all resources:

  1. ABAC Policies (profile/community-level) — Configurable TOP and BOTTOM policy rules that define hard constraints and guarantees
  2. Discretionary Access Control — The content creator’s own choices: visibility levels, explicit audience, file shares, and access grants

These two pillars combine in a layered evaluation:

1. TOP POLICY (ABAC)   → Hard constraints — what is NEVER allowed
       ↓
2. BOTTOM POLICY (ABAC) → Hard guarantees — what is ALWAYS allowed
       ↓
3. DISCRETIONARY ACCESS  → Creator's choices: visibility, shares, ownership
       ↓
4. DEFAULT DENY          → If nothing matched, deny access

Pillar 1: ABAC Policies

Attribute-Based Access Control evaluates rules based on attributes of users, resources, and context. In Cloudillo, ABAC is used for profile-level (community/company) policies that set boundaries around the discretionary access decisions.

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: Box<str>,              // Identity (e.g., "alice.example.com")
    pub roles: Box<[Box<str>]>,        // Roles (e.g., ["moderator"], or ["SADM"] for site admin)
    pub scope: Option<Box<str>>,       // Optional scope (e.g., "apkg:publish")
}

Roles come from a fixed hierarchy — public, follower, supporter, contributor, moderator, leader (each inherits the lower ones) — plus the site-admin role SADM, which is checked separately by require_admin rather than through ABAC.

2. Action (What)

The operation being attempted, in resource:operation format:

file:read         → Read a file
file:write        → Modify, delete, tag, or restore a file
file:create       → Upload/create a file
action:read       → View an action token
action:write      → Change an action's status (accept/reject/dismiss), delete, or publish a draft
action:create     → Create an action token
profile:read      → Read a profile
profile:write     → Update own profile
profile:admin     → Administer another community member's profile

The operation string maps directly to the middleware on the route — for example PATCH /api/files/{id} and DELETE /api/files/{id} both run check_perm_file("write"), so deletion is gated by the same file:write check rather than a separate file:delete.

Action bodies are immutable; status is not

An action’s signed payload (type, audience, visibility) is content-addressed and fixed at creation. What action:write controls is the mutable status — accepting, rejecting, dismissing, deleting, or publishing a scheduled draft — never the payload itself.

3. Object (Resource)

The resource being accessed. Must implement the AttrSet trait:

pub trait AttrSet: Send + Sync {
    fn get(&self, key: &str) -> Option<&str>;
    fn get_list(&self, key: &str) -> Option<Vec<&str>>;
    fn has(&self, key: &str, value: &str) -> bool;      // get(key) == value
    fn contains(&self, key: &str, value: &str) -> bool; // list contains value
}

4. Environment (Context)

Contextual factors for the request:

pub struct Environment {
    pub time: Timestamp,    // Current Unix timestamp
}

TOP Policy (Constraints)

Defines maximum permissions — what is never allowed, regardless of discretionary settings. Evaluated first; if a rule matches with Deny effect, access is immediately denied.

Use case: Community or company-wide restrictions.

Examples:

TopPolicy:
    Rule 1:
        Condition: visibility == "public" AND size > 100MB
        Effect: DENY
        # Community rule: files larger than 100MB cannot be shared publicly

    Rule 2:
        Condition: subject.banned == true
        Effect: DENY
        # Banned users cannot access any resource

BOTTOM Policy (Guarantees)

Defines minimum permissions — what is always allowed, regardless of other rules. Evaluated second; if a rule matches with Allow effect, access is immediately granted.

Use case: Platform guarantees and special role privileges.

Examples:

BottomPolicy:
    Rule 1:
        Condition: subject.id_tag == resource.owner
        Effect: ALLOW
        # Owner can always access their own resources

    Rule 2:
        Condition: subject.HasRole("leader")
        Effect: ALLOW
        # Community leaders have full access

Policy Operators

A rule holds a list of conditions; all conditions must match for the rule to fire (implicit AND — there is no separate And/Or operator). Each condition uses one of:

Comparison: Equals, NotEquals, GreaterThan, LessThan

Set/string: Contains, NotContains, In

Role: HasRole — checks if the subject has a specific role


Pillar 2: Discretionary Access Control

Between the TOP and BOTTOM policies, discretionary access control determines access based on the content creator’s own choices. This is the primary day-to-day access mechanism.

Ownership

The simplest check: owners always have full access to their own resources. The tenant is treated as owner-equivalent, so a community profile owns the resources stored on it.

if resource.owner == subject.id_tag → Owner access
if subject.id_tag == tenant_id_tag  → Owner access (tenant = owner equivalent)

Separately, the leader role short-circuits to ALLOW for any operation (see the evaluation order below), so community leaders effectively have owner-level reach.

Visibility Levels

Content creators set visibility when creating resources. This is a discretionary choice stored as a single character in the database (or NULL for direct).

Hierarchy (most to least permissive):

Code Level Who can access
P Public Anyone, including unauthenticated users
V Verified Any authenticated user from any federated instance
2 SecondDegree Friend of friend (reserved for voucher token system)
F Follower Authenticated users who follow the owner
C Connected Authenticated users with mutual connection
NULL Direct Only owner + explicit audience

The system computes the subject’s access level based on their relationship with the resource owner, then checks if it meets the visibility requirement:

Subject Access Level Description
Owner Is the resource owner (highest)
Connected Has mutual CONN with owner
Follower Has FLLW to owner
SecondDegree Friend of friend (future)
Verified Authenticated user
Public Unauthenticated (lowest)

These levels form an ordered enum (using Rust’s PartialOrd derive), where higher levels grant access to all visibility settings that lower levels can access.

Access check: subject_access_level.can_access(resource_visibility)

For Direct visibility, the system also checks explicit audience membership — if the subject’s identity is listed in the resource’s audience field, access is granted.

Explicit Access Grants

Beyond visibility, access can be granted explicitly through several mechanisms:

File Shares (FSHR Actions)

When a user shares a file, the system creates a share entry in the database (file → recipient with a permission char) and an FSHR action token for federation. The FSHR subType maps to an access level: WRITE → Write, COMMENT → Comment, otherwise → Read. Shares attached to a folder are inherited by descendant files (the parent chain is walked, bounded to 64 levels).

The recipient gets Confirmation status and must accept the share, after which the file appears in their listing with the granted level.

Scoped Access Tokens

Access tokens can carry a scope that limits them to a single resource (used by share links):

  • File scope format: file:{file_id}:{R|C|W} → Read / Comment / Write on that file
  • A scope for a folder extends to files nested under it; a scope for a document root extends to its child files
  • apkg:publish is the only non-file scope (package upload), and grants no file access

Role-Based File Access (Communities)

For files owned by a community (a file whose owner is the tenant), member roles determine access:

  • leader, moderator, contributor → Write access
  • Any other community role → Read access

This applies only to files owned by the community profile, not files owned by individual users.

Discretionary Evaluation Order

When neither TOP nor BOTTOM policy matches, the discretionary layer evaluates by operation:

1. Leader role override → ALLOW (the "leader" role can do everything)

2. write / update / delete:
   a. Ownership → ALLOW
   b. Pre-computed access_level == "write" → ALLOW
   c. Otherwise → DENY

3. read:
   a. Pre-computed access_level in {read, comment, write} → ALLOW
   b. Visibility vs. subject's access level → ALLOW/DENY
      (for Direct visibility, audience membership also grants access)

4. create:
   a. ALLOW at this layer (quota/role checks live in the
      collection-policy middleware, not here)

5. admin (e.g. profile:admin):
   a. Subject is moderator or higher → ALLOW, else DENY

6. Default → DENY

The single pre-computed access_level attribute is what carries share, scoped-token, ownership, and community-role grants into the check — the read and write branches above just compare against it.


Complete Evaluation Flow

When a permission check is requested, the full flow is:

1. Load Subject, Object, Environment
   ↓
2. TOP POLICY check
   ├─ If Deny → return DENY (hard constraint)
   └─ No match → continue
   ↓
3. BOTTOM POLICY check
   ├─ If Allow → return ALLOW (hard guarantee)
   └─ No match → continue
   ↓
4. DISCRETIONARY ACCESS check
   ├─ Ownership → ALLOW
   ├─ Explicit grants (shares, scoped tokens) → ALLOW
   ├─ Visibility check → ALLOW/DENY
   └─ Audience membership (for Direct) → ALLOW
   ↓
5. DEFAULT DENY

Evaluation Example

Request: Bob wants to read Alice’s connected-only file

Subject:
    id_tag: "bob.example.com"
    roles: []

Action: "file:read"

Object:
    owner: "alice.example.com"
    visibility: 'C' (Connected)
    file_id: "f1~abc123"

Evaluation:
1. TOP Policy: No blocking rules → continue
2. BOTTOM Policy: Not owner → continue
3. Discretionary:
   a. Is owner? No (alice ≠ bob)
   b. Explicit grants? No shares found
   c. Visibility = Connected
   d. Check connection:
      - Alice has CONN to Bob? Yes
      - Bob has CONN to Alice? Yes
      - Subject access level = Connected
      - Connected.can_access(Connected) = true
   → ALLOW

Integration with Routes

Cloudillo uses permission middleware to enforce access control on HTTP routes:

Protected Routes:
    # Actions
    POST /api/actions                  + check_perm_create("action", "create")
    POST /api/actions/:id/accept|reject|dismiss + check_perm_action("write")
    DEL  /api/actions/:id              + check_perm_action("write")

    # Files
    POST /api/files                    + check_perm_create("file", "create")
    PATCH /api/files/:id               + check_perm_file("write")
    DEL  /api/files/:id                + check_perm_file("write")

    # Profiles
    PATCH /api/me                      + check_perm_profile("write")
    PATCH /api/admin/profiles/:id      + check_perm_profile("admin")

    # Site administration (separate from ABAC — requires SADM role)
    *    /api/admin/tenants/*          + require_admin

Object-bound middleware (check_perm_file, check_perm_action, check_perm_profile) loads the resource, computes the subject’s relationship to the owner, and runs the full evaluation flow. check_perm_create evaluates the collection policy (role/quota) before the object exists. require_admin is a plain SADM-role gate, not an ABAC check.


Examples

Example 1: Public File Access

Alice uploads a public file:

POST /api/files/default/logo.png
Authorization: Bearer <alice_token>
Body: <image data>

Response:
    fileId: "f1~abc123"
    visibility: "P"

Bob reads the file (no authentication needed):

GET /api/files/f1~abc123

Permission Check:
    Subject: unauthenticated (Public access level)
    Action: file:read
    Object: { owner: alice, visibility: P }
    Visibility check: Public.can_access(Public) = true
    Decision: ALLOW

Example 2: Connected-Only File

Alice uploads a connected-only file:

POST /api/files/default/private-notes.pdf
Authorization: Bearer <alice_token>

Response:
    fileId: "f1~xyz789"
    visibility: "C"

Bob tries to read (not connected):

Permission Check:
    Subject: bob (Verified access level — no connection)
    Object: { owner: alice, visibility: C }
    Visibility check: Verified.can_access(Connected) = false
    Decision: DENY

Charlie reads (connected to Alice):

Permission Check:
    Subject: charlie (Connected access level)
    Object: { owner: alice, visibility: C }
    Visibility check: Connected.can_access(Connected) = true
    Decision: ALLOW

Example 3: File Share Override

Alice has a Connected-only file, but wants to share it with Dave (who is not connected):

POST /api/files/f1~xyz789/shares
Authorization: Bearer <alice_token>
Body: { "idTag": "dave.example.com", "permission": "R" }

Dave can now read the file despite not being connected:

Permission Check:
    Subject: dave
    Action: file:read
    Object: { owner: alice, visibility: C }
    1. TOP Policy: No blocking rules → continue
    2. BOTTOM Policy: No match → continue
    3. Discretionary:
       a. Ownership? No
       b. Explicit grants? YES — share entry found (permission=R)
       → ALLOW (share overrides visibility restriction)

Attribute Set Implementations

Cloudillo implements the AttrSet trait for different resource types, providing consistent attribute access for permission evaluation.

The attribute keys are what policy conditions reference. Visibility is exposed as its lowercase string form (public, verified, second_degree, follower, connected, direct), not the single-char DB code.

File Attributes (FileAttrs)

file_id          → Content-addressed ID
owner_id_tag     → File owner identity
mime_type        → Content type
visibility       → public/verified/second_degree/follower/connected/direct
access_level     → Pre-computed grant: none/read/comment/write/admin
following        → Subject follows owner (bool)
connected        → Subject connected to owner (bool)
tags             → File tags (list)

Action Attributes (ActionAttrs)

type             → Action type (e.g. POST, CONN, FSHR)
sub_type         → Action sub-type
owner_id_tag     → Storage tenant (alias of tenant_id_tag)
issuer_id_tag    → Action creator identity (may differ from tenant)
parent_id        → Parent action ID
root_id          → Root action ID
audience_tag     → Target recipient(s) (list)
visibility       → public/verified/.../direct
following        → Subject follows issuer (bool)
connected        → Subject connected to issuer (bool)

Profile Attributes (ProfileAttrs)

id_tag           → Profile identity
profile_type     → "community" or empty
owner_id_tag     → Owning tenant (alias of tenant_tag)
roles            → Subject's roles on this profile (list)
status           → Profile status
visibility       → public/.../direct
following        → Subject follows profile (bool)
connected        → Subject connected to profile (bool)

Subject Attributes for CREATE (SubjectAttrs)

id_tag                 → Requesting user identity
roles                  → User roles (list)
tier                   → "free", "standard", "premium"
quota_remaining_bytes  → Remaining storage quota (bytes)
rate_limit_remaining   → Remaining requests this hour
banned                 → Whether user is banned
email_verified         → Whether email is verified

Security Best Practices

Default Deny

The system defaults to denying access unless explicitly allowed. Unknown visibility values are parsed as Direct (most restrictive). Unknown access levels default to None.

Validate Server-Side

Client-side visibility checks are for UX only (show/hide UI elements). The server always validates permissions before serving resources, regardless of client-side checks.

Audit Permission Denials

All permission denials are logged with debug-level tracing, including subject identity, action attempted, visibility level, access level, and relationship status.

Immutable Actions

Action tokens are cryptographically signed and content-addressed. They cannot be modified after creation. Visibility and audience are set at creation time and cannot be changed.


See Also