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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user