2026-05-31 22:09:12 +02:00
|
|
|
|
# ReBAC Authorization
|
|
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
OxiCloud uses **Relationship-Based Access Control** (ReBAC): access is
|
2026-05-31 22:09:12 +02:00
|
|
|
|
expressed as a typed triple
|
|
|
|
|
|
|
|
|
|
|
|
```
|
2026-07-14 13:27:47 +02:00
|
|
|
|
Subject has Role on Resource (until ExpiresAt?)
|
2026-05-31 22:09:12 +02:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
stored as rows in a single table — `storage.role_grants` — and resolved at
|
2026-05-31 22:09:12 +02:00
|
|
|
|
request time by the **`AuthorizationEngine`** (concretely, `PgAclEngine`).
|
|
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
Each `Role` expands to a fixed set of atomic `Permission`s 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.
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 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:
|
|
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
- **Grants are facts, not global attributes.** Each row is
|
|
|
|
|
|
`(subject → role → resource)`, optionally with an expiration.
|
2026-05-31 22:09:12 +02:00
|
|
|
|
- **The same model covers users, anonymous share-links, groups, and federated
|
|
|
|
|
|
identities** — they all share the `subject_type` discriminator.
|
2026-07-14 13:27:47 +02:00
|
|
|
|
- **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.
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
|
|
|
|
|
The owner short-circuit is the one bit of non-ReBAC logic: a resource's owner
|
2026-07-14 13:27:47 +02:00
|
|
|
|
always passes the check without needing a row in `role_grants`.
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 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),
|
2026-07-14 13:27:47 +02:00
|
|
|
|
Drive(Uuid), // top-level container (personal / shared)
|
|
|
|
|
|
Calendar(Uuid), // CalDAV
|
|
|
|
|
|
AddressBook(Uuid), // CardDAV
|
|
|
|
|
|
Playlist(Uuid), // music
|
2026-05-31 22:09:12 +02:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
`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).
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
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.
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
### Role — *the primary sharing verb*
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
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.
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
| Role | Permissions expanded | Typical UX label |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| `Viewer` | `Read` | Can view |
|
|
|
|
|
|
| `Commenter` | `Read`, `Comment` | Can view & comment |
|
|
|
|
|
|
| `Contributor` | `Read`, `Create` | Can upload but not modify siblings |
|
|
|
|
|
|
| `Editor` | `Read`, `Comment`, `Create`, `Update` | Can edit |
|
|
|
|
|
|
| `Owner` | `Read`, `Comment`, `Create`, `Update`, `Delete`, `Share`, `Manage` | Can manage |
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
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.
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
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*
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
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) |
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Storage shape
|
|
|
|
|
|
|
|
|
|
|
|
```
|
2026-07-14 13:27:47 +02:00
|
|
|
|
storage.role_grants
|
2026-05-31 22:09:12 +02:00
|
|
|
|
id UUID
|
2026-07-14 13:27:47 +02:00
|
|
|
|
subject_type 'user' | 'group' | 'token'
|
2026-05-31 22:09:12 +02:00
|
|
|
|
subject_id UUID
|
2026-07-14 13:27:47 +02:00
|
|
|
|
resource_type 'drive' | 'folder' | 'file' | 'calendar' | 'address_book' | 'playlist'
|
2026-05-31 22:09:12 +02:00
|
|
|
|
resource_id UUID
|
2026-07-14 13:27:47 +02:00
|
|
|
|
role storage.grant_role
|
|
|
|
|
|
-- ENUM: 'viewer' | 'commenter' | 'contributor' | 'editor' | 'owner'
|
2026-05-31 22:09:12 +02:00
|
|
|
|
granted_by UUID (the user who issued the grant)
|
|
|
|
|
|
granted_at TIMESTAMPTZ
|
|
|
|
|
|
expires_at TIMESTAMPTZ NULL
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
**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.
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 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, …)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
Groups are addressed as a `Subject::Group(uuid)` and appear in `role_grants`
|
2026-05-31 22:09:12 +02:00
|
|
|
|
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
|
2026-07-14 13:27:47 +02:00
|
|
|
|
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)`.
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
### 1. Resource cascade — *down the drive → folder → file tree*
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
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:
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
grant.lpath @> target.lpath
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
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`.
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
|
|
|
|
|
The handler-layer `_cascade_grant_exists` functions in
|
|
|
|
|
|
`src/infrastructure/services/pg_acl_engine.rs` are the canonical
|
2026-07-14 13:27:47 +02:00
|
|
|
|
implementation. Drives cascade through the same code path — the drive's
|
|
|
|
|
|
root folder is what the ltree query anchors on.
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
|
|
|
|
|
### 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
|
|
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
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(...)`):
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
|
|
|
|
|
```
|
2026-07-14 13:27:47 +02:00
|
|
|
|
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
|
2026-05-31 22:09:12 +02:00
|
|
|
|
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
2026-07-14 13:27:47 +02:00
|
|
|
|
AND gf.lpath @> (SELECT lpath FROM storage.folders -- resource cascade
|
2026-05-31 22:09:12 +02:00
|
|
|
|
WHERE id = $target_folder_id)
|
|
|
|
|
|
LIMIT 1
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
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.
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 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:
|
|
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
- **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.
|
2026-05-31 22:09:12 +02:00
|
|
|
|
- **Subject deletion** — deleting a user or group cascades to their
|
|
|
|
|
|
outgoing/incoming grants via FK + matching triggers.
|
|
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
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).
|
|
|
|
|
|
|
|
|
|
|
|
| Env | Default | Meaning |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| `OXICLOUD_GRANT_CLEANUP_ENABLED` | `true` | Master switch. Default **on** — expired-grant purge is a security-hygiene default, not opt-in. |
|
|
|
|
|
|
| `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` | `15` | Days past `expires_at` before a row is eligible for deletion. |
|
|
|
|
|
|
| `OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS` | `24` | How 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.
|
|
|
|
|
|
|
2026-07-27 23:40:40 +02:00
|
|
|
|
The daemon runs inside the same process, registered with the periodic-job
|
|
|
|
|
|
scheduler (`docs/plan/job-registry.md`) on a 24-hour tick. An admin-
|
|
|
|
|
|
triggered `POST /api/admin/jobs/grant_cleanup/trigger?force=true` lets
|
|
|
|
|
|
operators force a purge in test or incident scenarios — `force=true`
|
|
|
|
|
|
collapses the grace window to zero for that call only.
|
2026-07-14 13:27:47 +02:00
|
|
|
|
|
|
|
|
|
|
The [Share Integration](/architecture/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.
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## What ReBAC does *not* cover (yet)
|
|
|
|
|
|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
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.
|
2026-05-31 22:09:12 +02:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## File map
|
|
|
|
|
|
|
|
|
|
|
|
| Concern | Module |
|
|
|
|
|
|
|---|---|
|
2026-07-14 13:27:47 +02:00
|
|
|
|
| Domain types (`Subject`, `Resource`, `Role`, `Permission`) + `Role::expand()` | `src/domain/services/authorization.rs` |
|
2026-05-31 22:09:12 +02:00
|
|
|
|
| Subject groups (entity + repo trait) | `src/domain/entities/subject_group.rs`, `src/domain/repositories/subject_group_repository.rs` |
|
|
|
|
|
|
| Engine — `check`, listing, expansion, cache | `src/infrastructure/services/pg_acl_engine.rs` |
|
|
|
|
|
|
| Group repo — recursive CTEs, cycle/depth | `src/infrastructure/repositories/pg/subject_group_pg_repository.rs` |
|
2026-07-14 13:27:47 +02:00
|
|
|
|
| Grant DTOs | `src/application/dtos/grant_dto.rs` |
|
|
|
|
|
|
| Schema — `role_grants` + ENUM + triggers, `subject_groups`, `subject_group_members` | `migrations/20260730000000_role_grants.sql` and follow-ups |
|
2026-05-31 22:09:12 +02:00
|
|
|
|
| REST handlers | `src/interfaces/api/handlers/grant_handler.rs`, `subject_group_handler.rs` |
|
2026-07-14 13:27:47 +02:00
|
|
|
|
| Hurl coverage | `tests/api/grants.hurl`, `subject_groups.hurl`, `grants_nested_groups.hurl`, `drives_membership.hurl` |
|