Files
Oxicloud/docs/architecture/share-integration.md
T
Edouard Vanbelle 5e95d6dccf 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
2026-07-14 13:31:48 +02:00

137 lines
8.7 KiB
Markdown

# Share Integration
OxiCloud supports public file and folder sharing through signed share links. A share can be public, password-protected, or time-limited.
> **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 `role_grants` row — identical in evaluation to a grant on an internal recipient.
## What a Share Contains
A share record (`storage.shares`) tracks:
- The shared item ID and whether it is a file or folder
- A public token used in the share URL
- Optional password protection (hash only — never plaintext)
- The creator and access count
What used to live on the share row but is now resolved through ReBAC:
- **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
### Authenticated management routes
| Method | Path | Description |
| --- | --- | --- |
| `POST` | `/api/shares/` | Create a new share |
| `GET` | `/api/shares/` | List current user's shares |
| `GET` | `/api/shares/{id}` | Fetch one share |
| `PUT` | `/api/shares/{id}` | Update permissions, password, or expiration |
| `DELETE` | `/api/shares/{id}` | Delete a share |
### Public access routes
| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/api/s/{token}` | Access a shared item |
| `POST` | `/api/s/{token}/verify` | Verify a password-protected share |
## Service Responsibilities
The share service is responsible for:
- Validating that the underlying file or folder exists
- Generating unique share IDs and public tokens
- Enforcing password checks and expiration rules
- Mapping domain permissions into API DTOs
- Recording access counts
Share metadata is persisted separately from the file content itself. The shared resource still uses the normal storage model for files and folders.
## Example Workflow
### Creating a share link
1. A user selects a file or folder in the UI
2. The frontend submits a request to `/api/shares/`
3. OxiCloud validates the target and requested permissions
4. The backend generates a token and public URL
5. The share metadata is saved and returned to the caller
### Opening a share link
1. A guest opens `/api/s/{token}`
2. OxiCloud verifies the token and checks expiration
3. If the share is password protected, the client verifies the password first
4. Access is counted and the shared resource is returned according to the granted permissions
## Lifecycle & cleanup
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 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: 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 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.role_grants:
IF OLD.subject_type = 'token' THEN
DELETE FROM storage.shares
WHERE id = OLD.subject_id
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-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 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/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 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
- Passwords are stored as hashes, never as plaintext
- Expired shares are rejected before content access by the engine's inline `expires_at` check — no separate code path
- Permissions are checked per action by the `AuthorizationEngine`, not just when the share is created. Revoking a grant takes effect immediately (subject to the 30 s group-expansion cache for user-grant checks; token-grant checks have no cache layer)
- Public share grants are server-side restricted to `read` regardless of what the request asked for — see the rebac-authorization doc for the role-to-permissions expansion
## Related Pages
- [ReBAC Authorization](/architecture/rebac-authorization) — how grants, permissions, expiry, and cascades work
- [OIDC / SSO](/config/oidc)
- [Admin Settings](/config/admin-settings)
- [Internal Architecture](/architecture/)