311 lines
11 KiB
Markdown
311 lines
11 KiB
Markdown
# ReBAC Authorization
|
||
|
||
OxiCloud uses **Relationship-Based Access Control** (ReBAC): permissions are
|
||
expressed as a typed triple
|
||
|
||
```
|
||
Subject has Permission on Resource (until ExpiresAt?)
|
||
```
|
||
|
||
stored as rows in a single table — `storage.access_grants` — and resolved at
|
||
request time by the **`AuthorizationEngine`** (concretely, `PgAclEngine`).
|
||
|
||
This document explains how subjects, permissions, resources, roles, 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 roles.** Each row is `(subject → permission → resource)`.
|
||
- **The same model covers users, anonymous share-links, groups, and federated
|
||
identities** — they all share the `subject_type` discriminator.
|
||
- **No global "admin of folder X" magic** — the engine answers a yes/no question
|
||
by scanning `access_grants` plus the relationships (folder ancestry, 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 a row in `access_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),
|
||
// Calendar / AddressBook / Playlist reserved for future use.
|
||
}
|
||
```
|
||
|
||
Both variants are content resources; the future variants will reuse the same
|
||
machinery.
|
||
|
||
### Permission — *the verb*
|
||
|
||
Six atomic permissions:
|
||
|
||
| `Read` | view the resource / list folder contents |
|
||
| `Create` | create a child resource (folders only — meaningful as inherited grant) |
|
||
| `Update` | rename, move, edit content |
|
||
| `Delete` | delete the resource |
|
||
| `Share` | grant permissions to other subjects |
|
||
| `Comment` | add comments (reserved — feature not implemented yet) |
|
||
|
||
### Role — *a named bundle of permissions*
|
||
|
||
Roles are a UX convenience that expand to permission rows server-side. There
|
||
are no role rows in the database — only permissions.
|
||
|
||
| Role | Permissions |
|
||
|---|---|
|
||
| `viewer` | `read` |
|
||
| `editor` | `read`, `comment`, `create`, `update` |
|
||
| `admin` | `read`, `comment`, `create`, `update`, `share`, `delete` |
|
||
|
||
Defined in `src/application/dtos/grant_dto.rs::Role::expand()`. The REST API
|
||
exposes both shapes: clients can `POST /api/grants` with either `"role"` or
|
||
`"permissions"`, and `PUT /api/grants/role` reconciles the row set in one call.
|
||
|
||
---
|
||
|
||
## Storage shape
|
||
|
||
```
|
||
storage.access_grants
|
||
id UUID
|
||
subject_type 'user' | 'group' | 'token' | 'external'
|
||
subject_id UUID
|
||
resource_type 'folder' | 'file'
|
||
resource_id UUID
|
||
permission 'read' | 'create' | 'update' | 'delete' | 'share' | 'comment'
|
||
granted_by UUID (the user who issued the grant)
|
||
granted_at TIMESTAMPTZ
|
||
expires_at TIMESTAMPTZ NULL
|
||
```
|
||
|
||
One row per `(subject, permission, resource)` triple. An "owner role on folder
|
||
X for user Y" is 6 rows; a "viewer role" is 1 row.
|
||
|
||
> **Note (D-Prep, 2026-06-17):** the role assignment has since pivoted into
|
||
> a separate `storage.role_grants` table that stores **one row per role
|
||
> assignment** rather than one per permission. `access_grants` stays
|
||
> populated via dual-write during the transition; the engine reads the
|
||
> role-keyed table for authz decisions. The cleanup PR drops
|
||
> `access_grants` after the dual-write window. The historical role name
|
||
> `Admin` was renamed to `Owner` at the same time, to disambiguate from
|
||
> `UserRole::Admin` (user-account privilege) and match Drive plan
|
||
> terminology.
|
||
|
||
Cleanup is trigger-driven (`trg_cleanup_grants_folder`, …): 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 `access_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.
|
||
|
||
### 1. Resource cascade — *down the folder tree*
|
||
|
||
Folder hierarchy uses PostgreSQL `ltree`. A grant on 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 grant on `/projects` permits reading `/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.
|
||
|
||
### 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:
|
||
|
||
```
|
||
SELECT 1 FROM access_grants g
|
||
JOIN 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.permission = $permission
|
||
AND g.resource_type = 'folder'
|
||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||
AND gf.lpath @> (SELECT lpath FROM folders -- resource cascade
|
||
WHERE id = $target_folder_id)
|
||
LIMIT 1
|
||
```
|
||
|
||
The file variant adds a `UNION ALL` branch for the direct-file-grant case.
|
||
|
||
---
|
||
|
||
## 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 delete fires a trigger
|
||
(`trg_cleanup_grants_folder`, `trg_cleanup_grants_file`) 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: `expires_at IS NULL OR expires_at > NOW()` is part
|
||
of every cascade query, so a soft expiry doesn't need a sweeper.
|
||
|
||
---
|
||
|
||
## What ReBAC does *not* cover (yet)
|
||
|
||
The two 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, with the same engine path as
|
||
files/folders.
|
||
- **Global roles in the JWT** (`role = "admin"`) — today these gate a few
|
||
admin-only management endpoints (user CRUD, group CRUD). They live outside
|
||
ReBAC because they're cross-cutting concerns, not per-resource permissions.
|
||
|
||
---
|
||
|
||
## File map
|
||
|
||
| Concern | Module |
|
||
|---|---|
|
||
| Domain types (`Subject`, `Resource`, `Permission`) | `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, cache | `src/infrastructure/services/pg_acl_engine.rs` |
|
||
| Group repo — recursive CTEs, cycle/depth | `src/infrastructure/repositories/pg/subject_group_pg_repository.rs` |
|
||
| Grant DTOs + `Role::expand` | `src/application/dtos/grant_dto.rs` |
|
||
| Schema — `access_grants`, `subject_groups`, `subject_group_members` | `migrations/` |
|
||
| REST handlers | `src/interfaces/api/handlers/grant_handler.rs`, `subject_group_handler.rs` |
|
||
| Hurl coverage | `tests/api/grants.hurl`, `subject_groups.hurl`, `grants_nested_groups.hurl` |
|