Skip to content

ReBAC Authorization

OxiCloud uses Relationship-Based Access Control (ReBAC): access is expressed as a typed triple

Subject  has  Role  on  Resource    (until ExpiresAt?)

stored as rows in a single table — storage.role_grants — and resolved at request time by the AuthorizationEngine (concretely, PgAclEngine).

Each Role expands to a fixed set of atomic Permissions at engine read time (Viewer → {Read}, Editor → {Read, Comment, Create, Update}, …). The database stores the role name; permission expansion happens in Rust.

This document explains how subjects, roles, permissions, resources, groups and two kinds of cascading fit together. For implementation details, follow the links to the relevant Rust modules.


Why ReBAC

A simpler RBAC ("Alice is an editor") is global. We need per-resource sharing: "Alice can edit this folder but not that one"; "Bob can view that file until March". ReBAC is the natural fit:

  • Grants are facts, not global attributes. Each row is (subject → role → resource), optionally with an expiration.
  • The same model covers users, anonymous share-links, groups, and federated identities — they all share the subject_type discriminator.
  • The same model covers files, folders, drives, calendars, address books, and playlists — every resource type routes through the same engine and the same role_grants table.
  • No global "admin of folder X" magic — the engine answers a yes/no question by scanning role_grants plus the relationships (folder ancestry, drive membership, group membership) that connect a subject to a resource.

The owner short-circuit is the one bit of non-ReBAC logic: a resource's owner always passes the check without needing a row in role_grants.


The four entities

Subject — who is asking

rust
enum Subject {
    User(Uuid),      // auth.users
    Group(Uuid),     // auth.subject_groups
    Token(Uuid),     // storage.shares — anonymous share links
    External(Uuid),  // federated identity (Open Cloud Mesh, future)
}

Defined in src/domain/services/authorization.rs. Each variant carries the UUID of the relevant row. The SQL discriminator (subject_type column) is 'user' | 'group' | 'token' | 'external'.

Resource — what is being acted on

rust
enum Resource {
    Folder(Uuid),
    File(Uuid),
    Drive(Uuid),        // top-level container (personal / shared)
    Calendar(Uuid),     // CalDAV
    AddressBook(Uuid),  // CardDAV
    Playlist(Uuid),     // music
}

Folder, File, and Drive participate in the folder-ancestry cascade (a grant on a drive descends to every folder + file inside it — see below). Calendar, AddressBook, and Playlist are top-level per user and don't cascade — the engine resolves them directly against a single role_grants row per (subject, resource).

The Playlist, Calendar, and AddressBook cases replaced the pre-2026 per-feature *_shares tables (caldav.calendar_shares, carddav.address_book_shares, music.playlist_shares) with a single uniform role_grants model + bespoke-helper-free code path.

Role — the primary sharing verb

Since the D-Prep migration (2026-07), roles are the primary sharing unit. Each role_grants row carries a role name; permissions are computed by expanding it in Rust at read time.

RolePermissions expandedTypical UX label
ViewerReadCan view
CommenterRead, CommentCan view & comment
ContributorRead, CreateCan upload but not modify siblings
EditorRead, Comment, Create, UpdateCan edit
OwnerRead, Comment, Create, Update, Delete, Share, ManageCan manage

Defined in src/domain/services/authorization.rs::Role::expand() — the single source of truth. The DB column is a Postgres ENUM (storage.grant_role, migration 20260801000000_role_grants_enum.sql), so unknown values are refused at the storage layer.

The REST API accepts the role name directly on grant endpoints (POST /api/grants { "role": "editor", … }, PUT /api/grants/role). Callers no longer manipulate permission sets by hand.

Permission — the atomic verb the engine checks

Seven atomic permissions. Handlers ask "does this subject have Permission::X on Resource::Y?"; the engine translates that to "…does any role granted to this subject include X?".

| Read | view the resource / list folder contents | | Create | create a child resource (folders / drives only — meaningful as an inherited grant) | | Update | rename, move, edit content | | Delete | delete the resource | | Share | grant roles to other subjects | | Comment | add comments (reserved — comments feature not implemented yet) | | Manage | change resource settings, membership, policies (Drive owners; future Group-as-Resource) |


Storage shape

storage.role_grants
  id           UUID
  subject_type 'user' | 'group' | 'token'
  subject_id   UUID
  resource_type 'drive' | 'folder' | 'file' | 'calendar' | 'address_book' | 'playlist'
  resource_id  UUID
  role         storage.grant_role
                 -- ENUM: 'viewer' | 'commenter' | 'contributor' | 'editor' | 'owner'
  granted_by   UUID  (the user who issued the grant)
  granted_at   TIMESTAMPTZ
  expires_at   TIMESTAMPTZ NULL

One row per role assignment. A "viewer of folder X for user Y" is one row; an "owner of drive Z" is one row. Permission expansion happens in Rust at engine read time via Role::expand() — the DB never stores a permission column.

History

The pre-2026-07 model kept one row per (subject, permission, resource) triple in storage.access_grants — an editor was 4 rows, an owner was 6. The D-Prep migration (20260730000000_role_grants.sql + follow-ups through 20260801000002_drop_access_grants.sql) collapsed that into one row per assignment, added the DB-side grant_role ENUM, renamed the former admin role bundle to owner (to disambiguate from UserRole::Admin, the JWT-level user-account privilege), and dropped access_grants entirely. Coverage extension migrations (20260906…_role_grants_calendar_address_book, 20260910…_role_grants_playlist) folded the last three per-feature share tables (CalDAV / CardDAV / Music) into the same role_grants model.

Cleanup is trigger-driven (trg_cleanup_role_grants_folder, one per resource type): when a resource or subject is deleted, all referencing grants disappear in the same transaction.


Subject groups — bundling subjects

Groups let you grant against many users at once, with two extra features:

  1. Nesting. A group can contain users and other groups (up to depth 8). Cycles are rejected at write time by a recursive CTE in subject_group_pg_repository::add_member.

  2. Virtual groups. Server-managed groups with a well-known UUID and immutable membership. Today: one entry, Internal (00000000-…-000000000001), implicitly containing every authenticated user. Future: Everyone (incl. externals).

The schema:

auth.subject_groups          (id, name, description, is_virtual, …)
auth.subject_group_members   (group_id, user_id XOR member_group_id, added_by, …)

Groups are addressed as a Subject::Group(uuid) and appear in role_grants just like users. The Rust types live in src/domain/entities/subject_group.rs.


Two kinds of cascading

OxiCloud has two independent cascades that compose on every permission check for the storage-tree resources (Drive, Folder, File). Standalone resource types (Calendar, AddressBook, Playlist) skip cascade entirely — the engine resolves them via a direct role_grants lookup keyed by (subject, resource).

1. Resource cascade — down the drive → folder → file tree

Every folder belongs to exactly one drive (the D0 refactor made storage.folders.drive_id mandatory); the drive root is itself a folder with parent_id IS NULL. Folder hierarchy uses PostgreSQL ltree. A grant on a drive OR a folder implicitly applies to every descendant folder and to every file inside any descendant folder. The check uses the GiST index on storage.folders.lpath for an O(log N) ancestor lookup:

grant.lpath  @>  target.lpath

So one Owner grant on a drive permits reading any file within it; one Editor grant on /projects permits editing /projects/q4/report.pdf. Files are not part of the ltree — instead, a file inherits its containing folder's position and the cascade query joins on target.folder_id.

The handler-layer _cascade_grant_exists functions in src/infrastructure/services/pg_acl_engine.rs are the canonical implementation. Drives cascade through the same code path — the drive's root folder is what the ltree query anchors on.

2. Subject cascade — up the group tree

A User caller is automatically expanded to:

{ user_id }  ∪  groups_for_user(user_id)  ∪  { INTERNAL_GROUP_ID }

where groups_for_user is the recursive CTE that walks subject_group_members to find every group the user belongs to transitively. A grant on the top of a nesting chain henry ∈ B ⊂ A permits henry to act.

The expansion is computed by PgAclEngine::expand_user(...) and cached in a Moka cache keyed by user_id:

  • TTL: 30 s
  • Capacity: 50 000 entries
  • Invalidation: TTL-only today; explicit busts on group mutation are a follow-up.

The cache makes the listing + cascade hot path effectively free after the first lookup per user per ~30 s window.

Composition

The engine combines both cascades in a single SQL round-trip. The role column carries the assignment; permission expansion happens by filtering on the set of role names that include the requested permission (computed once at process start via Permission::roles_implying(...)):

SELECT 1 FROM storage.role_grants g
  JOIN storage.folders gf ON gf.id = g.resource_id
 WHERE g.subject_type = ANY('{user,group}')          -- subject cascade
   AND g.subject_id   = ANY($expanded_set)           --   (user + groups + Internal)
   AND g.role         = ANY($roles_implying_perm)    -- role → permission
   AND g.resource_type IN ('drive','folder')         -- drive OR folder ancestry
   AND (g.expires_at IS NULL OR g.expires_at > NOW())
   AND gf.lpath @> (SELECT lpath FROM storage.folders  -- resource cascade
                     WHERE id = $target_folder_id)
 LIMIT 1

The file variant adds a UNION ALL branch for the direct-file-grant case (where the grant is on the file itself, not a folder or drive above it). The Calendar / AddressBook / Playlist variants skip the cascade join entirely and check (g.resource_type = <kind> AND g.resource_id = $target) directly.


How a check is decided

PgAclEngine::check(subject, permission, resource) returns a bool:

                ┌─── owner short-circuit ───┐
                │                           │
   subject = user, owner ⇒ Ok(true)         │

       otherwise:  expand_user(uid)  ⇒  (subject_types, subject_ids)


       resource = folder:  folder_cascade_grant_exists(...)
       resource = file:    file_cascade_grant_exists(...)   (direct OR ancestor)


                                       Ok(true / false)

Non-user subjects (Token / External / Group-as-caller) skip the expansion — their cascade input is a single-element set.

The decision is made entirely in the application service layer (*_with_perms methods). HTTP handlers authenticate the caller and pass caller_id through; they never inspect ownership or grants directly. This is enforced by convention — see CLAUDE.md → "Authorization (AuthZ)".


Listing endpoints — symmetric expansion

The "Shared with me" feed (GET /api/grants/incoming, paginated /api/grants/incoming/resources) reuses the same subject expansion. A user listing their incoming grants sees both:

  • Direct grants where subject_id = caller_id.
  • Group-mediated grants where subject_id ∈ groups_for_user(caller) ∪ {Internal}.

This guarantees that anything the engine would allow also surfaces in the listing — no silent gap between "you have access" and "you see it". The single chokepoint is PgAclEngine::subject_match_set(...), shared by check and the listing queries.

The reverse direction (/api/grants/outgoing — "what I've shared") filters on granted_by = caller. Group membership has no role there.


Lifecycle

Two state machines run alongside grants:

  • Resource deletion — folder / file / drive / calendar / address book / playlist delete each fire a per-type trigger (trg_cleanup_role_grants_folder, trg_cleanup_role_grants_file, trg_cleanup_role_grants_drive, and the three for the standalone resource types) that nukes every grant whose resource_id matches. Same transaction; clients see grants vanish from incoming lists immediately.
  • Subject deletion — deleting a user or group cascades to their outgoing/incoming grants via FK + matching triggers.

Expiry is enforced inline at read time: expires_at IS NULL OR expires_at > NOW() is part of every cascade query, so an expired grant is invisible to the engine the moment its timestamp passes. The AuthZ hot path never needs to consult a sweeper.

Post-expiry cleanup

Dead rows are physically deleted by a background daemon, GrantCleanupService, so role_grants doesn't accumulate lapsed rows indefinitely (each share with a TTL would otherwise leave a permanent row unless someone manually revoked it).

EnvDefaultMeaning
OXICLOUD_GRANT_CLEANUP_ENABLEDtrueMaster switch. Default on — expired-grant purge is a security-hygiene default, not opt-in.
OXICLOUD_GRANT_CLEANUP_GRACE_DAYS15Days past expires_at before a row is eligible for deletion.
OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS24How often the daemon fires.

The grace window (default 15 days) preserves the audit / support answer to "what happened to my access?" for two weeks past expiration, then the row goes. Because the AuthZ engine's expires_at filter is at read time, the grace window has zero effect on live access decisions — an expired grant is invisible to check(...) even during the grace period. Cleanup only affects storage bloat and the list_grants_* history surface.

The daemon runs inside the same process (tokio::spawn at startup, same lifecycle as trash-cleanup / storage-usage sweep), so no external scheduler is needed. An admin-triggered POST /api/admin/internal/trigger-grant-cleanup lets operators force a purge in test or incident scenarios; the internal- endpoints gate (OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS) applies.

The Share Integration doc's reverse trigger takes it from there: when the daemon deletes the last role_grants row for a share-token subject, trg_cleanup_share_on_grant_delete fires and deletes the paired storage.shares row in the same transaction. Expired public shares vanish end-to-end after the grace window without any operator intervention.


What ReBAC does not cover (yet)

Extensions sketched in the design notes but not yet implemented:

  • Resource::SubjectGroup(id) — per-group Manage / use-as-subject grants. Would let non-admins curate their own groups via the same engine path as files/folders/drives. Permission::Manage already exists in the enum for this reason; only the resource variant and the handler wiring are pending.
  • Global roles in the JWT (role = "admin") — today these gate a few admin-only management endpoints (user CRUD, group CRUD, admin settings). They live outside ReBAC because they're cross-cutting concerns, not per-resource permissions.
  • Materialised rights (v2) — a future flattening of the cascade into an indexed materialised view for O(1) reads. Deferred; see docs/plan/ for design.

File map

ConcernModule
Domain types (Subject, Resource, Role, Permission) + Role::expand()src/domain/services/authorization.rs
Subject groups (entity + repo trait)src/domain/entities/subject_group.rs, src/domain/repositories/subject_group_repository.rs
Engine — check, listing, expansion, cachesrc/infrastructure/services/pg_acl_engine.rs
Group repo — recursive CTEs, cycle/depthsrc/infrastructure/repositories/pg/subject_group_pg_repository.rs
Grant DTOssrc/application/dtos/grant_dto.rs
Schema — role_grants + ENUM + triggers, subject_groups, subject_group_membersmigrations/20260730000000_role_grants.sql and follow-ups
REST handlerssrc/interfaces/api/handlers/grant_handler.rs, subject_group_handler.rs
Hurl coveragetests/api/grants.hurl, subject_groups.hurl, grants_nested_groups.hurl, drives_membership.hurl

Released under the MIT License.