doc: update doc to reflect recent changes

- grants: permission moved to roles
    - new resources (Drive, Caldav, Carddav, Playlist) now using ReBAC
    - expired shared now cleaned up
    - drive visible in Webdav
    - new login/registration options (domain allow list, policies, etc)
    - upgrade of external user into internal user
This commit is contained in:
Edouard Vanbelle
2026-07-14 13:27:47 +02:00
parent 970f97b91a
commit 5e95d6dccf
6 changed files with 292 additions and 113 deletions
+23 -6
View File
@@ -15,7 +15,7 @@ Every user row in `auth.users` carries one identity field, three independent cre
| `password_hash` | `String NULL` | no | Argon2 hash if the user chose one. NULL = no password. No sentinel strings. |
| `oidc_subject` | `String NULL` | no | IdP subject claim if the user linked an external identity. NULL = no OIDC. |
| `is_external` | `bool` | yes (default false) | Provisioning origin marker. `true` = created via email-invitation. Affects home-folder provisioning and DAV access. |
| `email_verified_at` | `Timestamp NULL` | no | PR 23 — when the user demonstrated control of their email. NULL = unverified. Stamped on first magic-link redemption OR OIDC JIT with verified claim. Idempotent: the first proof timestamp is preserved. No policy gates today; future PRs may gate features on this signal. |
| `email_verified_at` | `Timestamp NULL` | no | When the user demonstrated control of their email. NULL = unverified. Stamped on first magic-link redemption OR OIDC JIT with verified claim OR admin-created / setup-admin accounts (admin fiat). Idempotent: the first proof timestamp is preserved. Gated by `OXICLOUD_REQUIRE_VERIFIED_EMAIL` — see below. |
The **`@` ban on usernames** is what makes the username and email namespaces provably disjoint. The login dispatcher relies on this — input containing `@` is unambiguously an email lookup, input without is a username lookup. No fallback chain, single DB hit.
@@ -42,6 +42,23 @@ The `@` ban on usernames makes this unambiguous. A single DB lookup, no fallback
The frontend's "Username or email" field submits whatever the user typed; the JSON field is still named `username` for backwards compatibility, with a docstring noting the dual semantics.
The same dispatch applies to `POST /api/auth/magic-link/send` — its `email` field also accepts either an email or a username. When a username is supplied, the server resolves it to the account's registered email BEFORE rate-limiting so `alice` and `alice@example.com` share one budget (otherwise alternating shapes would double the effective per-target budget).
## Deployment auth policy
Two env vars control the self-service auth surface, orthogonal to OIDC:
- `OXICLOUD_AUTH_METHODS` — allowlist of enabled methods (`password`, `magic_link`, or both). Default: both. Removing one produces distinct error_type codes so the SPA can render specific UX:
- Removing `password` → `POST /api/auth/login` → 403 `PasswordLoginDisabled`; password-based `register` → 403 `PasswordRegistrationDisabled`.
- Removing `magic_link` → `magic-link/send` → 403 `MagicLinkLoginDisabled`; login-purpose token redemption refuses.
- **Startup gate:** magic-link-only + no SMTP wired → server refuses to start (main.rs panics).
- `OXICLOUD_AUTH_POLICIES` — additive policy switches. Today: `permit_magic_link_for_password_users`. Future variants (`Require...`, `Deny...`) reuse the same vector-shaped env var — no per-policy env-var proliferation.
- `OXICLOUD_REQUIRE_VERIFIED_EMAIL` — when true, `POST /api/auth/login` returns 403 `EmailNotVerified` for accounts with `email_verified_at IS NULL`. Checked AFTER password validation (anti-enum — an attacker without the password can't probe verification state). **Admin accounts are exempt** from this gate to prevent a config flip from locking pre-existing admins out of their own instance.
**Verification piggyback.** When the `EmailNotVerified` branch fires (password OK + email unverified), the login handler auto-sends a verification magic-link to the account via a distinct service method that bypasses the `has_password` eligibility gate — the password itself just proved identity, so mailbox-only trust isn't being extended beyond what the password already established. Response is 403 `EmailNotVerified` with "check your inbox"; re-submitting the same login re-triggers the send. This is why there is no unauthenticated "resend verification" endpoint — one would leak `has_password` state to unauthenticated callers.
**OIDC-master rule.** When `OXICLOUD_OIDC_ENABLED=true`, magic-link login is hard-off regardless of `OXICLOUD_AUTH_METHODS`. Magic-link would bypass any 2FA / step-up the IdP enforces.
## Login paths
| Path | How it works | When available |
@@ -197,13 +214,13 @@ The auth model lands across PR 16-24, all forward-only and non-destructive.
## Future direction — per-user `login_strategy`
The current model is implicit: a user's available login paths derive from which credential slots they have set. A future direction is to make this **explicit** with a per-user policy enum:
The current model has moved from fully-implicit toward **instance-scoped explicit** via `OXICLOUD_AUTH_METHODS` and `OXICLOUD_AUTH_POLICIES` (see above). The next step is **per-user explicit** — a policy enum on the user row that overrides the deployment default:
| Strategy | Login requires |
|---|---|
| `passwordless` | magic-link only (current external default) |
| `password` | password only |
| `password_or_magic_link` | either (today's lenient mode, account-scoped instead of instance-scoped) |
| `password_or_magic_link` | either (today's `permit_magic_link_for_password_users` per-account) |
| `password_and_magic_link` | both — true 2FA, mailbox-as-second-factor |
| `oidc` | IdP redirect (existing) |
| `password_and_totp` | once native TOTP enrolment ships |
@@ -211,16 +228,16 @@ The current model is implicit: a user's available login paths derive from which
`password_and_magic_link` is particularly interesting: it turns the parallel single-factor paths we have today into a real MFA primitive (something you know + access to a mailbox). No new auth code required — just a policy gate.
This stays out of the current PR sequence; the data model already accommodates it (the eligibility predicate is the single migration point).
The instance-scoped equivalents are already deployed via `OXICLOUD_AUTH_METHODS` / `OXICLOUD_AUTH_POLICIES`; per-user overrides would need a new column and an eligibility branch that reads it. Stays out of the current PR sequence.
## What is deliberately out of scope
- **Native TOTP / WebAuthn enrolment.** The eligibility predicate has room for a `Reject("mfa_enrolled")` branch once native MFA lands. OIDC delegation is the only MFA path today.
- **External-user → internal-user promotion.** When an external user later sets a credential, today `is_external` stays true (they remain second-class for home folders, DAV, etc.). A future PR promotes them properly.
- **External-user → internal-user promotion — SHIPPED.** `POST /api/auth/upgrade-to-internal` flips `is_external` to false, optionally sets a password (optional iff the deployment offers magic-link login), and provisions a personal drive via `PersonalDriveLifecycleHook::on_upgraded_to_internal`. Refused with distinguished `error_type` codes: `AlreadyInternal`, `ManagedByIdP` (OIDC users), `PasswordRequired`, `RegistrationDomainNotAllowed` (domain outside the register allowlist — invitations must not become a bypass of the operator's self-registration policy). Self-service only; admin-side upgrade endpoint is a follow-up.
- **Session-kind discriminator.** A magic-link session is indistinguishable from a password session today. Scoped sessions (Option-B style: "magic-link sessions only access granted resources") are deferred.
- **Differentiated session TTL for externals.** Refresh-token expiry is uniform today. Future env: `OXICLOUD_EXTERNAL_REFRESH_TOKEN_EXPIRY_DAYS`.
- **Open Cloud Mesh (OCM) federation.** A third source for external provisioning. The `ExternalIdentityLifecycleHook::on_user_created` design accommodates the `source` discriminator (`magic_link` / `oidc` / `ocm`).
- **Email-verified policy gates.** PR 23 introduced the `email_verified_at` signal; gating features (uploads, shares, etc.) on it is future work — likely a single `OXICLOUD_REQUIRE_EMAIL_VERIFICATION=true` env var that adds middleware to the relevant routes.
- **Email-verified login gate — SHIPPED.** `OXICLOUD_REQUIRE_VERIFIED_EMAIL=true` gates login on `email_verified_at IS NOT NULL` (admins exempt). Gating other features (uploads, shares, etc.) on the same signal is future work; the plumbing is in place.
- **Username rename via the API.** PR 24 makes `username` claim-once-immutable on `/api/auth/me/profile`. A future admin endpoint at `PATCH /api/admin/users/{id}` can override for typo correction; that surface is admin-policy territory, not user-self-service.
- **Anti-enumeration latency parity.** The success and collision branches of `register` already use similar code paths, but a sophisticated attacker could still time-distinguish. Deferred; rate-limiting bounds the damage.
- **Per-user opt-out of magic-link.** The `OPEN_TO_PASSWORD_USERS` flag is instance-wide today. A future per-account toggle for high-privilege users (admins, etc.) would need a column + extra eligibility branch.
+1 -1
View File
@@ -251,5 +251,5 @@ These are intentionally deferred. Each has a clear future trigger; none block th
- [User lifecycle](/architecture/user-lifecycle) — the hook framework that fires on user creation and the deletion modes.
- [ReBAC Authorization](/architecture/rebac-authorization) — how grants are evaluated against `auth.users` rows (including external ones).
- [Share Integration](/architecture/share-integration) — how the public-share-link flow relates to the email-invite flow (both create `access_grants` rows; only the former lives in `storage.shares`).
- [Share Integration](/architecture/share-integration) — how the public-share-link flow relates to the email-invite flow (both create `role_grants` rows; only the former lives in `storage.shares`).
- [Environment Variables](/config/env) — the full set of `OXICLOUD_*` knobs.
+186 -88
View File
@@ -1,18 +1,22 @@
# ReBAC Authorization
OxiCloud uses **Relationship-Based Access Control** (ReBAC): permissions are
OxiCloud uses **Relationship-Based Access Control** (ReBAC): access is
expressed as a typed triple
```
Subject has Permission on Resource (until ExpiresAt?)
Subject has Role on Resource (until ExpiresAt?)
```
stored as rows in a single table — `storage.access_grants` — and resolved at
stored as rows in a single table — `storage.role_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.
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.
---
@@ -22,15 +26,19 @@ 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)`.
- **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.
- **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 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 a row in `access_grants`.
always passes the check without needing a row in `role_grants`.
---
@@ -57,71 +65,105 @@ UUID of the relevant row. The SQL discriminator (`subject_type` column) is
enum Resource {
Folder(Uuid),
File(Uuid),
// Calendar / AddressBook / Playlist reserved for future use.
Drive(Uuid), // top-level container (personal / shared)
Calendar(Uuid), // CalDAV
AddressBook(Uuid), // CardDAV
Playlist(Uuid), // music
}
```
Both variants are content resources; the future variants will reuse the same
machinery.
`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).
### Permission — *the verb*
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.
Six atomic permissions:
### 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.
| 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 |
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 only — meaningful as inherited grant) |
| `Create` | create a child resource (folders / drives only — meaningful as an 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.
| `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.access_grants
storage.role_grants
id UUID
subject_type 'user' | 'group' | 'token' | 'external'
subject_type 'user' | 'group' | 'token'
subject_id UUID
resource_type 'folder' | 'file'
resource_type 'drive' | 'folder' | 'file' | 'calendar' | 'address_book' | 'playlist'
resource_id UUID
permission 'read' | 'create' | 'update' | 'delete' | 'share' | 'comment'
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 `(subject, permission, resource)` triple. An "owner role on folder
X for user Y" is 6 rows; a "viewer role" is 1 row.
**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.
> **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.
### History
Cleanup is trigger-driven (`trg_cleanup_grants_folder`, …): when a resource or
subject is deleted, all referencing grants disappear in the same transaction.
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.
---
@@ -145,7 +187,7 @@ 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`
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`.
@@ -154,26 +196,33 @@ just like users. The Rust types live in
## Two kinds of cascading
OxiCloud has **two independent cascades** that compose on every permission
check.
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 folder tree*
### 1. Resource cascade — *down the drive → folder → file 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:
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 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`.
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.
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*
@@ -200,22 +249,29 @@ first lookup per user per ~30 s window.
### Composition
The engine combines both cascades in a single SQL round-trip:
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 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'
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 folders -- resource cascade
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.
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.
---
@@ -271,28 +327,70 @@ on `granted_by = caller`. Group membership has no role there.
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.
- **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: `expires_at IS NULL OR expires_at > NOW()` is part
of every cascade query, so a soft expiry doesn't need a sweeper.
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.
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](/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.
---
## What ReBAC does *not* cover (yet)
The two extensions sketched in the design notes but not yet implemented:
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.
- **`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.
---
@@ -300,11 +398,11 @@ The two extensions sketched in the design notes but not yet implemented:
| Concern | Module |
|---|---|
| Domain types (`Subject`, `Resource`, `Permission`) | `src/domain/services/authorization.rs` |
| 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, 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/` |
| 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 |
| 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` |
| Hurl coverage | `tests/api/grants.hurl`, `subject_groups.hurl`, `grants_nested_groups.hurl`, `drives_membership.hurl` |
+28 -15
View File
@@ -2,9 +2,9 @@
OxiCloud supports public file and folder sharing through signed share links. A share can be public, password-protected, or time-limited.
> **Where permission and expiration live now.** Both the granted permissions and the expiration timestamp are stored on the `storage.access_grants` row that represents the share, not on the share row itself. They are evaluated by the same `AuthorizationEngine` that handles user and group grants — see [ReBAC Authorization](/architecture/rebac-authorization). The `storage.shares` row keeps only the token-side metadata (public token, password hash, item name, access count).
> **Where the role and expiration live now.** Both the granted role and the expiration timestamp are stored on the `storage.role_grants` row that represents the share, not on the share row itself. They are evaluated by the same `AuthorizationEngine` that handles user and group grants — see [ReBAC Authorization](/architecture/rebac-authorization). The `storage.shares` row keeps only the token-side metadata (public token, password hash, item name, access count).
> **Sharing with people who do not yet have an account.** Token-based shares are anonymous; anyone with the URL can use them. To share with a specific person who isn't on the instance yet, the share modal accepts a raw email address and provisions the recipient as an *external user* on the fly. That flow is described in [Magic-link external authentication](/architecture/magic-link-auth), and the resulting grant is a regular per-user `access_grants` row — identical in evaluation to a grant on an internal recipient.
> **Sharing with people who do not yet have an account.** Token-based shares are anonymous; anyone with the URL can use them. To share with a specific person who isn't on the instance yet, the share modal accepts a raw email address and provisions the recipient as an *external user* on the fly. That flow is described in [Magic-link external authentication](/architecture/magic-link-auth), and the resulting grant is a regular per-user `role_grants` row — identical in evaluation to a grant on an internal recipient.
## What a Share Contains
@@ -17,8 +17,8 @@ A share record (`storage.shares`) tracks:
What used to live on the share row but is now resolved through ReBAC:
- **Expiration** → `access_grants.expires_at`. The cascade query filters expired grants inline (`expires_at IS NULL OR expires_at > NOW()`), so an expired share fails the same path a revoked user grant fails. No separate "is this share expired" check.
- **Permission scope** → `access_grants.permission` rows. **For security, public share-link grants are restricted to `read` only** (the equivalent of the `viewer` role). Anyone holding the token can view but not modify, comment, share, or delete. To grant write or share access to a specific recipient, create a per-user or per-group grant instead of a share link.
- **Expiration** → `role_grants.expires_at`. The cascade query filters expired grants inline (`expires_at IS NULL OR expires_at > NOW()`), so an expired share fails the same path a revoked user grant fails. No separate "is this share expired" check.
- **Role scope** → `role_grants.role` (Postgres ENUM `storage.grant_role`). **For security, public share-link grants are always `viewer`** and cannot be raised. Anyone holding the token can view but not modify, comment, share, or delete. To grant write or share access to a specific recipient, create a per-user or per-group grant with a higher role (`editor`, `contributor`, `owner`) instead of a share link.
## Public and Private Routes
@@ -70,44 +70,57 @@ Share metadata is persisted separately from the file content itself. The shared
## Lifecycle & cleanup
Because permissions and expiry now live on `access_grants`, every share is represented by two correlated rows: one in `storage.shares` (token metadata) and one or more in `storage.access_grants` (`subject_type='token'`, `subject_id=share.id`). Two triggers keep them in sync — one per direction — so neither side can outlive the other.
Because the role and expiry live on `role_grants`, every share is represented by two correlated rows: one in `storage.shares` (token metadata) and one in `storage.role_grants` with `subject_type='token'` and `subject_id=share.id` carrying the `viewer` role. Two triggers keep them in sync — one per direction — so neither side can outlive the other.
### Share deletion → grant cleanup
Deleting a share row (`DELETE FROM storage.shares` via `DELETE /api/shares/{id}`) fires the `trg_cleanup_grants_token` trigger declared in `migrations/20260520000000_rebac_access_grants.sql`. That trigger removes every `access_grants` row whose `subject_type='token'` and `subject_id=share.id`, in the same transaction. The token becomes unreachable immediately — no stale grants left behind.
Deleting a share row (`DELETE FROM storage.shares` via `DELETE /api/shares/{id}`) fires the token-side cleanup trigger. It removes the matching `role_grants` row whose `subject_type='token'` and `subject_id=share.id`, in the same transaction. The token becomes unreachable immediately — no stale grant left behind.
The same pattern runs when the underlying resource is deleted: `trg_cleanup_grants_folder` / `trg_cleanup_grants_file` clean up the grants, and any share row referencing a deleted resource is then garbage-collected by the reverse trigger described below.
The same pattern runs when the underlying resource is deleted: the per-resource-type triggers on `role_grants` (`trg_cleanup_role_grants_folder`, `trg_cleanup_role_grants_file`, `trg_cleanup_role_grants_drive`, `_calendar`, `_address_book`, `_playlist`) clean up the grants, and any share row referencing a deleted resource is then garbage-collected by the reverse trigger described below.
### Grant revocation → share row cleanup
`DELETE /api/grants/{grant_id}` on the **last** grant of a token row removes the matching `storage.shares` row, atomically and in the same transaction. The `trg_cleanup_share_on_grant_delete` trigger declared in `migrations/20260612000001_share_grant_reverse_cascade.sql` watches `access_grants` for `DELETE` events with `subject_type='token'` and deletes the paired share row **iff no other grants for the same `subject_id` still exist**:
`DELETE /api/grants/{grant_id}` on a token row removes the matching `storage.shares` row, atomically and in the same transaction. The `trg_cleanup_share_on_grant_delete` trigger (originally introduced in `migrations/20260612000001_share_grant_reverse_cascade.sql`, carried forward through the `role_grants` migration by `migrations/20260801000001_role_grants_cascade_triggers.sql`) watches `role_grants` for `DELETE` events with `subject_type='token'` and deletes the paired share row **iff no other grants for the same `subject_id` still exist**:
```sql
AFTER DELETE ON storage.access_grants:
AFTER DELETE ON storage.role_grants:
IF OLD.subject_type = 'token' THEN
DELETE FROM storage.shares
WHERE id = OLD.subject_id
AND NOT EXISTS (SELECT 1 FROM storage.access_grants
AND NOT EXISTS (SELECT 1 FROM storage.role_grants
WHERE subject_type = 'token'
AND subject_id = OLD.subject_id);
```
The `NOT EXISTS` guard makes it safe in two important cases:
- **Multi-grant tokens** — if a token had several permission rows (e.g. read+share, were that ever to be allowed), revoking one leaves the share row intact. Only the final revocation triggers cleanup.
- **Forward-cascade re-entry** — when the original DELETE comes from `storage.shares`, the forward trigger is already deleting these grant rows. The reverse trigger then tries to delete a share row that's already gone, finds no row, and the statement is a no-op. No recursion.
- **Multi-role tokens** — the schema doesn't currently allow more than one role on a token (public share-links are always `viewer`), but the guard is still correct for the general case. Reserved for a future extension where a token might carry multiple assignments.
- **Forward-cascade re-entry** — when the original DELETE comes from `storage.shares`, the forward trigger is already deleting the corresponding `role_grants` row. The reverse trigger then tries to delete a share row that's already gone, finds no row, and the statement is a no-op. No recursion.
Net effect: revoking the last grant on a token via the grants API and deleting the share via `DELETE /api/shares/{id}` are now equivalent — both end in a clean state with zero rows on either side.
Net effect: revoking the grant on a token via the grants API and deleting the share via `DELETE /api/shares/{id}` are equivalent — both end in a clean state with zero rows on either side.
### Resource deletion
Both triggers compose cleanly with resource lifecycle:
- A folder/file delete → `trg_cleanup_grants_*` removes the grants → `trg_cleanup_share_on_grant_delete` removes the share rows that just lost their last grant. One delete on the resource cleans up everything downstream in a single transaction.
- A folder/file/drive delete → per-resource-type `trg_cleanup_role_grants_*` removes the grants → `trg_cleanup_share_on_grant_delete` removes the share rows that just lost their last grant. One delete on the resource cleans up everything downstream in a single transaction.
### Expired shares — background purge
Public shares with an expiration date follow the general expired-grant
lifecycle: the AuthZ engine treats them as unusable the moment `expires_at`
passes (inline filter, no separate expiry check), and the `GrantCleanupService`
daemon physically deletes the underlying `role_grants` row after a grace
window (default 15 days, `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS`). When it does,
the reverse trigger described above fires and reaps the paired `storage.shares`
row in the same transaction. Expired public shares vanish end-to-end without
operator intervention. See
[ReBAC Authorization → Post-expiry cleanup](/architecture/rebac-authorization#post-expiry-cleanup)
for the daemon and its env vars.
### Pre-existing orphans
The `20260612000001` migration also runs a one-shot `DELETE FROM storage.shares WHERE NOT EXISTS (… token grants)` to garbage-collect any orphans that accumulated before the reverse trigger existed.
The `20260612000001` migration ran a one-shot `DELETE FROM storage.shares WHERE NOT EXISTS (… token grants)` to garbage-collect any orphans that accumulated before the reverse trigger existed. The `role_grants` migration path preserved that cleanup — no fresh orphan class was introduced.
## Security Notes
+11 -3
View File
@@ -1,7 +1,8 @@
# Sharing
OxiCloud lets you share any file or folder with other people. Open the
item, click **Share**, and pick who you'd like to share it with.
OxiCloud lets you share any file, folder, drive, calendar, address
book, or playlist with other people. Open the item, click **Share**,
and pick who you'd like to share it with.
> Sharing works inside any [Drive](/guide/drives) you have access to.
> Some sharing options may be limited by a drive's policies (no public
@@ -26,8 +27,15 @@ above it allows.
| Level | What it allows |
|---|---|
| **Can view** | Open and download. |
| **Can view & comment** | Plus leave comments (comments are a planned feature; the level is reserved for it). |
| **Can upload** | Plus add new files or folders. Cannot modify or delete siblings — useful for "drop-box" style folders where you want contributors to submit but not touch each other's work. |
| **Can edit** | Plus create, rename, and modify files. |
| **Can manage** | Plus delete and reshare. |
| **Can manage** | Plus delete, reshare, and change settings. On a drive, also controls membership. |
Sharing a **drive** grants the same level on everything inside it, and
future items added to it. If you share a drive as *Can edit* and later
someone drops a folder in there, the person you shared with can edit
that folder too.
## Public links are view-only
+43
View File
@@ -8,6 +8,49 @@ OxiCloud exposes a fully RFC 4918 compliant WebDAV interface at `/webdav/`. It w
https://your-server:8086/webdav/
```
## Drives in the URL
A user can own multiple [drives](/guide/drives) (one personal + any
number of shared drives they've been added to). The WebDAV URL scheme
lets you address them all, and the operator can choose between two
layouts via `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX` (default `"@drive"`):
**Default — `"@drive"` sigil.** Bare `/webdav/…` addresses your default
personal drive, keeping single-drive clients working with zero config.
Explicit drive listing lives under the sigil.
| URL | Target |
|---|---|
| `/webdav/` | Your default personal drive (back-compat) |
| `/webdav/Documents/report.pdf` | A file inside your default drive |
| `/webdav/@drive/` | Directory listing of every drive you can read |
| `/webdav/@drive/<uuid-or-name>/…` | A specific drive by UUID or display name |
**Empty prefix (`""`) — flat layout.** `/webdav/` IS the drive listing.
Every drive appears as a top-level entry. No hidden default.
| URL | Target |
|---|---|
| `/webdav/` | Directory listing of every drive you can read |
| `/webdav/<uuid-or-name>/…` | A specific drive by UUID or display name |
Set `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` for the flat layout.
Any non-empty value replaces the sigil (e.g. `"drives"` gives you
`/webdav/drives/<selector>/…`).
**Trade-off with the empty prefix**: recursive DAV clients (Cyberduck,
Finder, rclone default, NC desktop) will mirror ALL drives you can
read, which can be a lot of storage. The `@drive` sigil keeps the
default drive as the client's sync root and puts the picker behind an
opt-in URL. Pick the empty prefix only when you want explicit
multi-drive visibility.
**Folder name collision note.** A user could name a folder `@drive`
inside their default drive; that folder would then mask the drive
picker for that user under the default sigil. Rare enough to be
accepted; the sigil is renameable via the env var above if it becomes
an issue.
## Authentication
HTTP Basic Authentication: