Files
Oxicloud/docs/architecture/rebac-authorization.md
T
2026-06-18 02:47:06 +02:00

311 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` |