From 5e95d6dccff0e589f14b9304351fa94eca8aba49 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 14 Jul 2026 13:27:47 +0200 Subject: [PATCH] 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 --- docs/architecture/auth-model.md | 29 ++- docs/architecture/magic-link-auth.md | 2 +- docs/architecture/rebac-authorization.md | 274 +++++++++++++++-------- docs/architecture/share-integration.md | 43 ++-- docs/guide/sharing.md | 14 +- docs/guide/webdav.md | 43 ++++ 6 files changed, 292 insertions(+), 113 deletions(-) diff --git a/docs/architecture/auth-model.md b/docs/architecture/auth-model.md index 28aa6f39..fab5b421 100644 --- a/docs/architecture/auth-model.md +++ b/docs/architecture/auth-model.md @@ -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. diff --git a/docs/architecture/magic-link-auth.md b/docs/architecture/magic-link-auth.md index bfcb5d09..2d905fd6 100644 --- a/docs/architecture/magic-link-auth.md +++ b/docs/architecture/magic-link-auth.md @@ -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. diff --git a/docs/architecture/rebac-authorization.md b/docs/architecture/rebac-authorization.md index 2a1d70d9..ee0c0738 100644 --- a/docs/architecture/rebac-authorization.md +++ b/docs/architecture/rebac-authorization.md @@ -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 = 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` | diff --git a/docs/architecture/share-integration.md b/docs/architecture/share-integration.md index e998dba5..0fa6c2bb 100644 --- a/docs/architecture/share-integration.md +++ b/docs/architecture/share-integration.md @@ -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 diff --git a/docs/guide/sharing.md b/docs/guide/sharing.md index b120b5ea..55d0490a 100644 --- a/docs/guide/sharing.md +++ b/docs/guide/sharing.md @@ -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 diff --git a/docs/guide/webdav.md b/docs/guide/webdav.md index e59bf4cd..7b1824d9 100644 --- a/docs/guide/webdav.md +++ b/docs/guide/webdav.md @@ -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//…` | 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//…` | 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//…`). + +**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: