Files
Oxicloud/docs/plan/plan-ReBAC-Permissions-Grants-Cascading.md
T
2026-05-20 21:49:25 +02:00

983 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OxiCloud ReBAC — Permissions, Grants, and Cascading
## Context
OxiCloud currently has a binary authorization model: the owner of a folder/file has every permission, every non-owner has none. The only user-to-user sharing is via anonymous token links (`storage.shares`) with three coarse flags (read/write/reshare). There is no way for a user to grant a named user fine-grained access to a folder, and no way to list resources others have shared with them.
This plan introduces a Relationship-Based Access Control (ReBAC) model:
- 6 named permissions: `read`, `create`, `share`, `comment`, `delete`, `update`
- Cascading: a grant on a folder applies to all descendants (sub-folders + files) via the existing `storage.folders.lpath` ltree
- Subjects: `user` (v1), `group` (future placeholder in schema), `token` (anonymous links — unified with existing `storage.shares`), `external` (future in schema for federated identities: Open Cloud Mesh / external OIDC)
- Pluggable engine: a single `AuthorizationEngine` trait, default implementation in PostgreSQL, ready for an `OpenFgaEngine` later
- **Roles** (Viewer, Commenter, Editor, Manager, Admin) as a UX/DTO sugar layer that the server expands into the underlying permission rows — storage and engine know nothing about roles
User decisions confirmed in conversation:
1. **Implicit owner** — owners have no rows in `access_grants`; the engine short-circuits when the caller is the resource's owner.
2. **`share` permission lets the holder grant to other named users** via `POST /api/grants` (not just create anonymous links).
3. **`GET /api/grants/incoming` returns direct grants only** — one row per resource explicitly granted to the caller. UI drills in via existing listing endpoints.
4. **Unify anonymous link shares under `access_grants`** with `subject_type='token'`. `storage.shares` retains token-lifecycle metadata (password, expiry, access count) only; the permission flags move to `access_grants`. One-time data migration.
5. **Roles in v1, implication chains deferred** — roles bundle the 6 raw permissions at the DTO layer (no schema impact). The storage keeps one row per granted permission. Permission implication (e.g., `update` ⊃ `comment` ⊃ `read`) is a Future optimization that compresses storage but doesn't change observable behavior.
6. **6 permissions are final for v1** — `read`, `create`, `share`, `comment`, `delete`, `update`. `download` (preview-only vs full-bytes) is a candidate for v2 if a "view-only" feature is added; trivial ALTER on the CHECK constraint then.
7. **Schema reserves `subject_type='external'` for federated identities** (Open Cloud Mesh / external OIDC). v1 adds the enum value and the `Subject::External(Uuid)` variant; the lookup table `auth.external_subjects` and the federation middleware are deferred.
8. **Architectural rule: AuthZ lives in the service layer, never in handlers.** All permission checks go through `AuthorizationEngine` via service methods. HTTP handlers (REST, WebDAV, NextCloud, CalDAV, CardDAV) authenticate the caller and pass `caller_id` into the service — they do NOT perform their own ownership/permission checks. This rule must be documented in `CLAUDE.md`.
9. **Per-row storage, not bitmap.** One row per `(subject, resource, permission)` rather than a single row with a packed bitmap. Preserves per-permission `granted_at` and `granted_by` (audit value), keeps future per-grant `expires_at` an easy addition, and maps 1:1 to OpenFGA tuples. Storage cost at OxiCloud's scale is acceptable and not on a hot path — micro-optimization deferred indefinitely. Matches the per-tuple shape used by Zanzibar, SpiceDB, OpenFGA, Permify.
Out-of-scope (deferred):
- Group creation & membership UI (the schema reserves `subject_type='group'`, but no group CRUD endpoints in this plan)
- External-user federation (`subject_type='external'` reserved in schema; `auth.external_subjects` table + OCM/OIDC federation middleware come later)
- Permission implication graph (`update` ⊃ `comment` ⊃ `read`, etc.) — storage compression with no observable behavior change
- Negative grants / deny rules (model stays additive — union of all applicable grants)
- Grant expiry per-row (token expiry stays on `storage.shares`)
- Comment feature itself (the `comment` permission is reserved; the comments table is a future feature)
- `download` permission (separation of preview-only from full-bytes export)
- Decision caching (in-process + Redis L2) — see "Future: caching layer" below
---
## Architecture overview
```
┌────────────────────┐
│ HTTP handlers │ POST/GET/DELETE /api/grants
└──────────┬─────────┘
▼
┌────────────────────┐
│ FolderService │ ────► authz.require(caller, Update, Folder(id))
│ FileManagementSvc │ ────► authz.require(caller, Create, Folder(parent))
│ FileRetrievalSvc │ ────► authz.require(caller, Read, Folder(id))
│ ShareService │ ────► token grants written via authz.grant(Token(t), ...)
└──────────┬─────────┘
▼ Arc<dyn AuthorizationEngine>
┌─────────────────────────────────────────────┐
│ AuthorizationEngine trait │
│ • check(subject, perm, resource) → bool │
│ • require(...) │
│ • grant / revoke │
│ • list_incoming / list_on_resource │
└──────────┬────────────────────────┬─────────┘
▼ ▼
PgAclEngine (v1, default) OpenFgaEngine (future)
▼
storage.access_grants + storage.folders.lpath (cascading)
```
---
## Schema
### New table: `storage.access_grants`
```sql
CREATE TABLE storage.access_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Subject (who has the permission)
-- 'user' — auth.users.id
-- 'group' — future: group membership
-- 'token' — refers to storage.shares.id (anonymous link)
-- 'external' — future: refers to auth.external_subjects.id (OCM / federated OIDC)
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group', 'token', 'external')),
subject_id UUID NOT NULL,
-- Resource (what the permission is on)
resource_type TEXT NOT NULL CHECK (resource_type IN ('folder', 'file')),
resource_id UUID NOT NULL,
-- Permission (what action is allowed)
permission TEXT NOT NULL CHECK (permission IN
('read', 'create', 'share', 'comment', 'delete', 'update')),
-- Audit
granted_by UUID NOT NULL, -- user_id who created the grant
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (subject_type, subject_id, resource_type, resource_id, permission)
);
CREATE INDEX idx_grants_subject ON storage.access_grants (subject_type, subject_id);
CREATE INDEX idx_grants_resource ON storage.access_grants (resource_type, resource_id);
```
`granted_by` is always a user (group cannot grant). No FK to `auth.users` on `subject_id` or `granted_by` — those tables are in a different schema and the values are polymorphic.
### Cleanup of `storage.shares`
The permission columns move to `access_grants`. `storage.shares` keeps token-lifecycle metadata.
```sql
-- After data migration (below):
ALTER TABLE storage.shares
DROP COLUMN permissions_read,
DROP COLUMN permissions_write,
DROP COLUMN permissions_reshare;
```
### Data migration (one-off, in the same migration file)
Each existing share becomes one or more rows in `access_grants` with `subject_type='token'`, `subject_id=shares.id`:
```sql
INSERT INTO storage.access_grants
(subject_type, subject_id, resource_type, resource_id, permission, granted_by)
SELECT 'token', s.id, s.item_type, s.item_id::uuid, 'read', s.created_by
FROM storage.shares s
WHERE s.permissions_read;
-- 'write' on the old model implies full mutation rights for the link holder.
-- Mapped to read + create + update + delete in the new model.
INSERT INTO storage.access_grants (subject_type, subject_id, resource_type, resource_id, permission, granted_by)
SELECT 'token', s.id, s.item_type, s.item_id::uuid, p.perm, s.created_by
FROM storage.shares s
CROSS JOIN (VALUES ('create'), ('update'), ('delete')) AS p(perm)
WHERE s.permissions_write;
INSERT INTO storage.access_grants (subject_type, subject_id, resource_type, resource_id, permission, granted_by)
SELECT 'token', s.id, s.item_type, s.item_id::uuid, 'share', s.created_by
FROM storage.shares s
WHERE s.permissions_reshare;
```
---
## Lifecycle and grant cleanup (v1 — correctness requirement)
When a resource or subject is **permanently** deleted, all `access_grants` rows referring to it must be removed. Otherwise:
- Orphan grants linger forever
- A future UUID reuse (unlikely but possible) could match a stale row
- "Shared with me" returns grants on resources that no longer exist
- Audit queries (`COUNT(*) FROM access_grants`) drift away from reality
### What triggers cleanup, and what doesn't
| Event | Affected grants | Action |
|---|---|---|
| Folder **permanently** deleted | `resource_type='folder', resource_id=F` (plus all descendant files via FK cascade chain) | DELETE |
| File **permanently** deleted | `resource_type='file', resource_id=X` | DELETE |
| Folder/file moved to **trash** (soft) | None | **No-op** — restore must resume access |
| Folder/file **restored** from trash | None | No-op |
| Trash **emptied** (permanent destruction) | Same as permanent delete | DELETE |
| User deleted | `subject_type='user', subject_id=U`. `granted_by=U` is left as-is (audit trail). | DELETE the subject rows; keep granter UUIDs |
| Anonymous share token deleted | `subject_type='token', subject_id=T` | DELETE |
| Group deleted (future) | `subject_type='group', subject_id=G` | DELETE |
### Defense-in-depth: DB triggers in the same migration
Even if a future code path bypasses the service layer (admin scripts, bulk maintenance, manual SQL), the database enforces cleanup:
```sql
CREATE OR REPLACE FUNCTION storage.cleanup_grants_on_resource_delete()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM storage.access_grants
WHERE resource_type = TG_ARGV[0]
AND resource_id = OLD.id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_cleanup_grants_folder
AFTER DELETE ON storage.folders
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_grants_on_resource_delete('folder');
CREATE TRIGGER trg_cleanup_grants_file
AFTER DELETE ON storage.files
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_grants_on_resource_delete('file');
CREATE OR REPLACE FUNCTION storage.cleanup_grants_on_subject_delete()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM storage.access_grants
WHERE subject_type = TG_ARGV[0]
AND subject_id = OLD.id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_cleanup_grants_user
AFTER DELETE ON auth.users
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_grants_on_subject_delete('user');
CREATE TRIGGER trg_cleanup_grants_token
AFTER DELETE ON storage.shares
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_grants_on_subject_delete('token');
```
`storage.files.folder_id REFERENCES storage.folders(id) ON DELETE CASCADE` already exists — when a folder is permanently deleted, the file trigger fires for each cascaded child. No need to walk the ltree subtree manually.
### Application-layer cleanup (explicit hooks)
The trait gains two cleanup methods so the application layer can invoke cleanup explicitly. This matters because a future cache layer (see Future section) needs to see the invalidation event at the engine boundary — DB triggers happen below the cache:
```rust
/// Removes all grants targeting this resource. Returns count removed.
async fn revoke_all_for_resource(&self, resource: Resource)
-> Result<usize, DomainError>;
/// Removes all grants where this subject is the holder.
async fn revoke_all_for_subject(&self, subject: Subject)
-> Result<usize, DomainError>;
```
Service call sites:
| Service method | Cleanup call |
|---|---|
| `FolderService::delete_folder_with_perms` (permanent delete) | `authz.revoke_all_for_resource(Folder(id))` |
| `FileManagementService::delete_file_with_perms` | `authz.revoke_all_for_resource(File(id))` |
| `FileManagementService::delete_and_cleanup_with_perms` | Same |
| `TrashService::delete_permanently` | `authz.revoke_all_for_resource(...)` per item |
| `TrashService::empty_trash` | Loop over items, same call |
| `TrashService::move_to_trash` | **No cleanup** (soft delete; grants preserved for eventual restore) |
| `TrashService::restore_item` | No action (grants are still there) |
| `ShareService::delete_shared_link` | `authz.revoke_all_for_subject(Token(share_id))` |
| `AuthApplicationService::delete_user` (admin) | `authz.revoke_all_for_subject(User(user_id))` |
DB triggers stay as defense-in-depth — they catch anything the application forgets, and they catch bulk maintenance operations. The application-layer hook is the canonical path; the trigger is the safety net.
### File lifecycle hook integration
There's an existing `FileDeletedHook` trait in `src/application/ports/file_lifecycle.rs` that already fires after permanent file deletion (used today for blob-ref-count decrement). Implement an additional hook:
```rust
struct GrantCleanupHook { authz: Arc<dyn AuthorizationEngine> }
#[async_trait::async_trait]
impl FileDeletedHook for GrantCleanupHook {
async fn on_file_deleted(&self, file_id: Uuid) -> Result<(), DomainError> {
self.authz.revoke_all_for_resource(Resource::File(file_id)).await?;
Ok(())
}
}
```
Register it in `common/di.rs` alongside the existing hooks. The folder/user/token cases get inline calls in their respective services (no hook trait for those yet — adding one if it's needed for a third caller is a future refactor).
### Verification — lifecycle scenarios in `grants.hurl`
1. **Resource delete clears grants**
- Alice creates folder F, grants Bob read, then permanently deletes F (via empty trash)
- Bob's `GET /api/grants/incoming` returns 0 entries containing F
- Direct SQL check (in a debug endpoint or via a test fixture): `SELECT COUNT(*) FROM access_grants WHERE resource_id = F` is 0
2. **Trash retains grants**
- Alice grants Bob read on F, moves F to trash, then restores F
- Bob still has `read` access after restore (regression: before any lifecycle change, this must continue to work)
3. **User delete clears subject grants but preserves granter**
- Alice grants Bob and Carol read on F. Admin deletes Bob.
- Carol's grant on F survives; her `granted_by=alice` still references Alice (intact)
- Bob's row is gone
4. **Token delete clears token grants**
- Alice creates a public share link on F → `access_grants` has rows with `subject_type='token'`
- Alice deletes the share link → token rows in `access_grants` are gone
5. **Orphan invariant (post-test SQL)**
```sql
SELECT COUNT(*) FROM storage.access_grants g
WHERE (g.resource_type = 'folder'
AND NOT EXISTS (SELECT 1 FROM storage.folders WHERE id = g.resource_id))
OR (g.resource_type = 'file'
AND NOT EXISTS (SELECT 1 FROM storage.files WHERE id = g.resource_id));
```
Must always be 0 after every Hurl run.
---
## Domain types
New module `src/domain/services/authorization.rs`:
```rust
use uuid::Uuid;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Subject {
User(Uuid),
Group(Uuid), // schema reserved, no CRUD endpoints in v1
Token(Uuid), // refers to storage.shares.id
External(Uuid), // future: refers to auth.external_subjects.id (OCM / federated OIDC)
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Resource {
Folder(Uuid),
File(Uuid),
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Permission {
Read, Create, Share, Comment, Delete, Update,
}
pub struct Grant {
pub id: Uuid,
pub subject: Subject,
pub resource: Resource,
pub permission: Permission,
pub granted_by: Uuid,
pub granted_at: chrono::DateTime<chrono::Utc>,
}
```
Conversion helpers (`as_str()` for SQL binding, `TryFrom<&str>` for row decoding) live alongside.
---
## Port: `AuthorizationEngine`
New file `src/application/ports/authorization_ports.rs`:
```rust
use crate::common::errors::DomainError;
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
#[async_trait::async_trait]
pub trait AuthorizationEngine: Send + Sync + 'static {
/// Returns true if `subject` has `permission` on `resource`,
/// considering owner short-circuit AND cascading from folder ancestors.
async fn check(
&self,
subject: Subject,
permission: Permission,
resource: Resource,
) -> Result<bool, DomainError>;
/// Convenience: returns Ok(()) when check passes; DomainError::not_found
/// otherwise (anti-enumeration — same error for "no such resource" and
/// "exists but you can't see it").
async fn require(
&self,
subject: Subject,
permission: Permission,
resource: Resource,
) -> Result<(), DomainError> {
if self.check(subject, permission, resource).await? {
Ok(())
} else {
let (kind, id) = match resource {
Resource::Folder(id) => ("Folder", id),
Resource::File(id) => ("File", id),
};
Err(DomainError::not_found(kind, id.to_string()))
}
}
/// Resources explicitly granted to `subject`. Direct grants only — no
/// cascade expansion. Used by GET /api/grants/incoming.
async fn list_incoming_grants(
&self,
subject: Subject,
permission_filter: Option<Permission>,
) -> Result<Vec<Grant>, DomainError>;
/// All grants on a specific resource (for "Manage sharing" UI).
/// Caller-side must verify the caller has `share` on the resource.
async fn list_grants_on_resource(
&self,
resource: Resource,
) -> Result<Vec<Grant>, DomainError>;
/// Idempotent (UNIQUE constraint absorbs duplicates).
async fn grant(
&self,
granted_by: Uuid,
subject: Subject,
permission: Permission,
resource: Resource,
) -> Result<Grant, DomainError>;
/// Revoke by id.
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError>;
}
```
Wired into `AppState` in `src/common/di.rs` as `pub authorization: Arc<dyn AuthorizationEngine>`. The factory selects the implementation from `OXICLOUD_AUTHZ_ENGINE` env var (default: `postgres`).
---
## PgAclEngine implementation
New file `src/infrastructure/services/pg_acl_engine.rs`. Holds `Arc<DbPools>`, `Arc<FolderDbRepository>`, `Arc<FileBlobReadRepository>` (for owner lookups).
### `check()` algorithm
```rust
async fn check(&self, subject: Subject, perm: Permission, resource: Resource) -> Result<bool, _> {
// Step 1 — owner short-circuit (only for user subjects)
if let Subject::User(uid) = subject {
let owner = match resource {
Resource::Folder(id) => self.folder_repo.get_folder_user_id(&id.to_string()).await?,
Resource::File(id) => self.file_repo.get_file_user_id(&id.to_string()).await?,
};
if owner == uid { return Ok(true); }
}
// Step 2 — direct or cascading grant via SQL
self.grant_exists(subject, perm, resource).await
}
```
### Cascading SQL — folders
```sql
SELECT EXISTS (
SELECT 1
FROM storage.access_grants g
JOIN storage.folders gf ON gf.id = g.resource_id
WHERE g.subject_type = $1 AND g.subject_id = $2
AND g.permission = $3
AND g.resource_type = 'folder'
AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = $4)
)
```
`gf.lpath @> target.lpath` means "gf is an ancestor of (or equal to) target". Uses the existing GiST index `idx_folders_lpath` — O(log N).
### Cascading SQL — files
A file inherits from its containing folder. Two-branch query:
```sql
SELECT EXISTS (
-- direct file grant
SELECT 1 FROM storage.access_grants
WHERE subject_type = $1 AND subject_id = $2 AND permission = $3
AND resource_type = 'file' AND resource_id = $4
UNION ALL
-- cascading from any ancestor folder of the file's containing folder
SELECT 1
FROM storage.access_grants g
JOIN storage.folders gf ON gf.id = g.resource_id
JOIN storage.files target_f ON target_f.id = $4
WHERE g.subject_type = $1 AND g.subject_id = $2
AND g.permission = $3
AND g.resource_type = 'folder'
AND target_f.folder_id IS NOT NULL
AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = target_f.folder_id)
)
```
Files at root (`folder_id IS NULL`) only match the direct branch.
### Engine selection in `AppState`
```rust
// src/common/di.rs (build_app_state)
let authz: Arc<dyn AuthorizationEngine> = match env::var("OXICLOUD_AUTHZ_ENGINE").as_deref() {
Ok("openfga") => unimplemented!("OpenFgaEngine — future"),
_ => Arc::new(PgAclEngine::new(
pools.clone(),
repositories.folder_repository.clone(),
repositories.file_read_repository.clone(),
)),
};
```
---
## Service integration
Each `*_with_perms` method already calls `verify_owner`. Replace the call with `authz.require(...)`. The semantics broaden (grants count, not just ownership) but the signature and error mapping stay the same.
### Folder permission mapping (folder_service.rs)
| Method | Permission(s) checked |
|---|---|
| `create_folder_with_perms(dto, caller)` | `Create` on `Folder(parent_id)` |
| `get_folder_with_perms(id, caller)` | `Read` on `Folder(id)` |
| `rename_folder_with_perms(id, dto, caller)` | `Update` on `Folder(id)` |
| `move_folder_with_perms(id, dto, caller)` | `Update` on `Folder(id)` AND `Create` on `Folder(new_parent)` |
| `delete_folder_with_perms(id, caller)` | `Delete` on `Folder(id)` |
### File permission mapping (file_management_service.rs)
| Method | Permission(s) checked |
|---|---|
| `move_file_with_perms(file_id, caller, target)` | `Update` on `File(file_id)` AND `Create` on `Folder(target)` if target is Some |
| `copy_file_with_perms(file_id, caller, target)` | `Read` on `File(file_id)` AND `Create` on `Folder(target)` if target is Some |
| `rename_file_with_perms(file_id, caller, name)` | `Update` on `File(file_id)` |
| `delete_file_with_perms(id, caller)` | `Delete` on `File(id)` |
| `copy_folder_tree_with_perms(src, caller, target, name)` | `Read` on `Folder(src)` AND `Create` on `Folder(target)` if target is Some |
### File retrieval mapping (file_retrieval_service.rs)
`get_file_owned`, `list_files_owned`, `get_file_stream_owned`, `get_file_optimized_owned`, `get_file_range_stream_owned`, `list_files_batch_for_owner` → each becomes `authz.require(caller, Read, File(id))` before delegating to the unchecked variant.
### Path-based lookups (currently unchecked IDOR risk)
`folder_service::get_folder_by_path(path)` and `file_retrieval_service::get_file_by_path(path)` resolve a path then return the resource without any check. After this plan: resolve, then `authz.require(caller, Read, …)`. This closes a known IDOR documented in the previous plan's "Out of scope" section.
### Owner short-circuit ensures zero behavior change for current users
Because every existing user-vs-own-resource interaction is an owner check, the engine's owner short-circuit makes those calls equivalent to the current `verify_owner`. No grant lookups on the hot path until a real cross-user grant exists.
---
## REST endpoints
New handler `src/interfaces/api/handlers/grant_handler.rs`. Registered under `/api/grants`.
### `POST /api/grants` — create a grant
```json
{
"subject": { "type": "user", "id": "<uuid>" },
"resource": { "type": "folder", "id": "<uuid>" },
"permissions": ["read", "comment"]
}
```
Behavior:
1. Authenticated caller required.
2. `authz.require(caller, Share, resource)` — caller must have `share` on the resource (owners always pass via short-circuit).
3. For each permission in the list: `authz.grant(caller_id, subject, perm, resource)`. UNIQUE constraint makes repeats no-ops.
4. Returns 201 with the list of created/existing grants.
### `DELETE /api/grants/{id}` — revoke a grant
1. Look up the grant.
2. Allow if caller is the grant's `granted_by` user OR caller has `share` on the underlying resource.
3. `authz.revoke(id)`.
4. Returns 204.
### `GET /api/grants/incoming?permission=read&type=folder` — what others have shared with me
Direct grants only (per user decision). Subject is the authenticated caller's `User(id)`. Optional filters by permission and resource type.
Returns:
```json
[
{
"id": "<grant-uuid>",
"resource": { "type": "folder", "id": "<uuid>", "name": "Photos", "path": "..." },
"permission": "read",
"granted_by": { "id": "<uuid>", "username": "alice" },
"granted_at": "2026-05-20T10:51:13Z"
}
]
```
Resource name/path is enriched via a JOIN to `storage.folders` / `storage.files`.
### `GET /api/grants?resource_type=folder&resource_id={id}` — list grants on a resource
Requires `authz.require(caller, Share, resource)` (you can see who has access only if you can manage sharing).
Returns the same shape as incoming, but for the specified resource.
### `GET /api/grants/outgoing` — grants I have created
Filtered by `granted_by = caller_id`. Useful for "Manage all my shares" UI.
---
## Roles (UX / DTO layer)
Roles are **preset bundles of permissions** that the API exposes for UI convenience. The server expands a role into its underlying permission list before writing rows; storage and engine know nothing about roles.
### Role catalog
| Role | Permissions |
|---|---|
| `Viewer` | `read` |
| `Commenter` | `read`, `comment` |
| `Editor` | `read`, `comment`, `create`, `update` |
| `Manager` | `read`, `comment`, `create`, `update`, `share` |
| `Admin` | `read`, `comment`, `create`, `update`, `share`, `delete` |
Defined as a Rust enum in `src/application/dtos/grant_dto.rs`:
```rust
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role { Viewer, Commenter, Editor, Manager, Admin }
impl Role {
pub fn expand(self) -> &'static [Permission] {
match self {
Role::Viewer => &[Permission::Read],
Role::Commenter => &[Permission::Read, Permission::Comment],
Role::Editor => &[Permission::Read, Permission::Comment,
Permission::Create, Permission::Update],
Role::Manager => &[Permission::Read, Permission::Comment,
Permission::Create, Permission::Update,
Permission::Share],
Role::Admin => &[Permission::Read, Permission::Comment,
Permission::Create, Permission::Update,
Permission::Share, Permission::Delete],
}
}
}
```
### `POST /api/grants` accepts either shape
```json
// Either explicit permissions:
{ "subject": { "type": "user", "id": "<uuid>" },
"resource": { "type": "folder", "id": "<uuid>" },
"permissions": ["read", "comment"] }
// Or a role:
{ "subject": { "type": "user", "id": "<uuid>" },
"resource": { "type": "folder", "id": "<uuid>" },
"role": "editor" }
```
The DTO uses `#[serde(untagged)]` or two separate fields with server-side validation that exactly one is provided. Server expands `role` → permission list, then writes the rows.
### `PUT /api/grants/role` — reconcile a subject's role on a resource
```json
{ "subject": { "type": "user", "id": "<uuid>" },
"resource": { "type": "folder", "id": "<uuid>" },
"role": "manager" }
```
Behavior:
1. `authz.require(caller, Share, resource)`.
2. Read the current set of permissions held by `subject` on `resource`.
3. Compute the diff vs `role.expand()`: which permissions to INSERT, which to DELETE.
4. Apply both in one transaction.
5. Returns 200 with the new full set.
This is the canonical way for a UI to set "Bob is now Editor of /Photos" — the frontend doesn't track which specific rows exist.
### Why roles are pure DTO sugar (not stored)
- **Roles can evolve without schema migrations** — adding "Reviewer" tomorrow is a code change, no ALTER.
- **Mixing is allowed** — a future UI can start from "Editor" and add `share` manually; the result is a custom mixture, not "Editor + share".
- **OpenFGA migration unaffected** — tuples are per-permission regardless of how they were granted.
- **Revocation is granular** — removing a single permission doesn't require touching a "role" abstraction.
---
## File changes
### New files
- `migrations/2026MMDDHHMMSS_rebac_access_grants.sql` — table + indexes + data migration from `storage.shares`
- `src/domain/services/authorization.rs` — `Subject` (including `External` variant), `Resource`, `Permission`, `Grant` enums/structs
- `src/application/ports/authorization_ports.rs` — `AuthorizationEngine` trait
- `src/infrastructure/services/pg_acl_engine.rs` — default impl
- `src/interfaces/api/handlers/grant_handler.rs` — REST endpoints (`POST/DELETE/GET /api/grants`, `PUT /api/grants/role`, `GET /api/grants/incoming|outgoing`)
- `src/application/dtos/grant_dto.rs` — request/response DTOs including `Role` enum + `Role::expand()`
### Modified
- `CLAUDE.md` — add a section under the Backend Architecture documenting the rule: **AuthZ is enforced exclusively in the application service layer. HTTP handlers (REST, WebDAV, NextCloud, CalDAV, CardDAV) only authenticate the caller and pass `caller_id` to the service. Never duplicate permission checks at the exposition layer.** This prevents drift between layers and matches the existing pattern of `*_with_perms` methods.
- `src/common/di.rs` — wire `authz` into `AppState`; inject into Folder/FileManagement/FileRetrieval services
- `src/application/services/folder_service.rs` — replace `verify_owner` calls with `authz.require`; add path-based check to `get_folder_by_path`
- `src/application/services/file_management_service.rs` — same; remove the private `verify_target_folder_owner` wrapper (engine does both)
- `src/application/services/file_retrieval_service.rs` — replace owner checks; add path-based check to `get_file_by_path`
- `src/application/services/share_service.rs` — on `create_shared_link`, also write the corresponding `access_grants` rows so that token-based access goes through the engine uniformly
- `src/interfaces/api/routes.rs` — register `/api/grants` routes
- `src/application/ports/mod.rs` — `pub mod authorization_ports`
- `src/domain/services/mod.rs` — `pub mod authorization`
- `src/infrastructure/services/mod.rs` — `pub mod pg_acl_engine`
### Removed
- The fields `permissions_read`, `permissions_write`, `permissions_reshare` from `storage.shares` (and their domain/dto representations) — replaced by `access_grants` rows. Migration script preserves existing data.
---
## Verification
### Build & lint
```
cargo fmt --all
cargo clippy --all-features --all-targets -- -D warnings
cargo test --workspace
```
### Hurl integration tests (new file `tests/api/grants.hurl`)
Run via the existing `tests/api/run.sh` (add `permissions.hurl` AND the new `grants.hurl` to the runner).
Setup (admin token + bob token, both already available from `permissions.hurl`):
1. **Grant + check**
- Alice creates folder `/api/folders {parent: home, name: "Shared"}` → captures `folder_id`
- Alice grants Bob `read` on the folder: `POST /api/grants` with subject=user/bob, resource=folder/Shared, perms=[read]
- Bob calls `GET /api/folders/{folder_id}/contents` → 200 (was 404 before grant)
- Bob calls `PUT /api/folders/{folder_id}/rename` → 404 (no `update` grant)
2. **Cascading**
- Alice creates a sub-folder `Shared/Inner`
- Alice uploads a file `vacation.jpg` inside `Inner`
- Bob (with `read` on `Shared`) calls `GET /api/files/{file_id}` → 200 (cascaded via lpath)
3. **Incoming list**
- Bob calls `GET /api/grants/incoming` → returns 1 entry with the folder, permission=read
4. **Re-share**
- Carol (new user) — Alice grants Bob `share` additionally
- Bob now successfully calls `POST /api/grants` to grant Carol `read`
- Carol calls `GET /api/folders/{folder_id}/contents` → 200
5. **Revoke**
- Alice deletes Bob's grant via `DELETE /api/grants/{grant_id}` → 204
- Bob's `GET /api/folders/{folder_id}/contents` → 404
6. **Roles**
- Alice grants `POST /api/grants` with `role: "editor"` for Bob on a new folder
- Bob can read AND rename a file inside (Editor includes `update`)
- Bob CANNOT delete the folder (Editor excludes `delete`) → 404
- Alice calls `PUT /api/grants/role` with `role: "admin"` for Bob
- Bob can now delete the folder → 200
- Alice calls `PUT /api/grants/role` with `role: "viewer"` for Bob
- Bob loses update/delete/comment/create/share; can only read → rename returns 404
7. **Token unification (regression)**
- `permissions.hurl` already covers existing share-link flows. After migration, those still pass — the engine reads from `access_grants` for token subjects, transparently.
### Unit tests
- New tests in `src/application/services/idor_protection_test.rs`:
- `engine.check(non_owner, Read, file)` with no grant → false
- `engine.check(owner, _, _)` → true (owner short-circuit) without touching `access_grants`
- `engine.check(grantee, Read, file)` after `grant()` → true
- Cascade: grant on parent folder → child file check returns true
- Revoke removes the row → next check returns false
- Tests use a stub repo for owners and an in-memory grant store, OR run against the real PG via the existing test harness.
### Storage growth sanity check (manual)
- Before migration: count rows in `storage.shares`.
- After migration: count rows in `storage.access_grants` with `subject_type='token'` ≈ shares × {1 + flag count}.
- Confirm no owner-self rows were created (validates implicit-owner choice).
---
## Rollout sequencing
1. **PR 1** — migration + schema (creates `access_grants`, migrates `storage.shares` permission flags). No code changes yet. Deploy and verify the migration runs cleanly.
2. **PR 2** — `AuthorizationEngine` trait + `PgAclEngine` + DI wiring. No services changed yet — engine is built but unused.
3. **PR 3** — service integration. Replace `verify_owner` with `authz.require` in `*_with_perms` methods. Add path-based checks. Hurl integration: `permissions.hurl` must still pass (engine's owner short-circuit ensures no behavior change for existing flows).
4. **PR 4** — REST endpoints (`/api/grants/*`) + new `grants.hurl` tests covering cross-user grant/revoke/cascade scenarios.
5. **PR 5** — `share_service` writes `access_grants` rows for new token shares (so token authz goes through the engine). At this point `storage.shares.permissions_*` columns are no longer read from anywhere — drop them.
Each PR is independently mergeable and the system stays functional throughout. PR 1-3 ship with zero observable change to users; PR 4 introduces the new feature; PR 5 retires the dead columns.
---
## Future: caching layer (in-process + Redis)
### Why
Every mutating service operation calls `authz.require(...)` at least once. The cascading SQL (`gf.lpath @> target.lpath` joined against `access_grants`) is O(log N) per check thanks to the GiST index, but at scale these costs compound:
- A batch delete of 1000 files = 1000 checks
- WebDAV PROPFIND on a deep folder may call `read` for every descendant
- A user with many active sessions hammers the same `(subject, perm, resource)` repeatedly
- Cascading means even a "no" answer requires walking the full ancestor chain — short-circuited only when the GiST index returns empty
A cache changes the cost of repeat checks from "JOIN + ltree GiST lookup" to "HashMap get" (L1) or "Redis GET" (L2). For mostly-read workloads, hit rate should be very high.
### Architecture — decorator over the trait
The `AuthorizationEngine` trait is unchanged. A `CachedAuthorizationEngine` wraps any underlying engine:
```rust
pub struct CachedAuthorizationEngine<E: AuthorizationEngine> {
inner: E,
l1: moka::future::Cache<DecisionKey, bool>, // in-process, fast, per-instance
l2: Option<Arc<dyn DistributedCache>>, // Redis (or similar), shared across instances
}
#[derive(Hash, Eq, PartialEq, Clone)]
struct DecisionKey {
subject: Subject,
permission: Permission,
resource: Resource,
}
impl<E: AuthorizationEngine> AuthorizationEngine for CachedAuthorizationEngine<E> {
async fn check(&self, subject: Subject, perm: Permission, resource: Resource)
-> Result<bool, DomainError>
{
let key = DecisionKey { subject, permission: perm, resource };
// L1: in-process
if let Some(decision) = self.l1.get(&key).await { return Ok(decision); }
// L2: Redis
if let Some(l2) = &self.l2
&& let Some(decision) = l2.get(&key).await? {
self.l1.insert(key.clone(), decision).await;
return Ok(decision);
}
// Miss — query the underlying engine and backfill
let decision = self.inner.check(subject, perm, resource).await?;
self.l1.insert(key.clone(), decision).await;
if let Some(l2) = &self.l2 {
l2.set(&key, decision, CACHE_TTL).await?;
}
Ok(decision)
}
async fn grant(...) -> Result<Grant, _> {
let g = self.inner.grant(...).await?;
self.invalidate_for(g.subject, g.resource).await;
Ok(g)
}
async fn revoke(...) -> Result<(), _> {
self.inner.revoke(...).await?;
// need the affected (subject, resource) — revoke() takes only grant_id today,
// so the trait gains a small helper or returns the deleted grant for invalidation.
Ok(())
}
}
```
### Three tiers worth distinguishing
1. **Per-request cache** (cheapest to ship). A `HashMap<DecisionKey, bool>` lives in a request extension. Cleared at request end. Avoids repeat checks during a single batch op (e.g., a 1000-file delete only hits the DB once per unique `(subject, perm, file)`). No invalidation problem — request scope.
2. **In-process L1** (`moka::future::Cache`). Bounded LRU with TTL. Per-server-instance. Hit on hot resources, no network. Invalidated on local `grant`/`revoke`.
3. **Distributed L2** (Redis). Shared across multiple OxiCloud server instances. Worth adding only when running multi-instance (HA / horizontal scale). Cross-instance invalidation via Redis pub/sub or short TTL.
### Invalidation — the hard part
Cascading makes per-key invalidation hard. When Alice grants Bob `read` on folder F:
- Bob's `read` on F becomes true → invalidate `(bob, read, F)`
- Bob's `read` on every descendant of F also becomes true (live cascade) → invalidate `(bob, read, child)` for every child
There's no efficient way to enumerate all descendants and invalidate each entry. Three pragmatic options:
| Strategy | Granularity | Implementation cost | Trade-off |
|---|---|---|---|
| **Subject-scoped flush** | All cached entries for `subject` regardless of resource | Cheap (one `bucket -> drop`) | Coarse — bob's checks on unrelated resources also dropped |
| **Resource-scoped flush** | All entries on `resource` and its descendants | Need to walk ltree on invalidation OR mark a "version" on the folder root | More targeted but more code |
| **Short TTL + eventual consistency** | None — wait for TTL | Trivial | Stale `true` after revoke for up to TTL seconds (bad), stale `false` after grant for up to TTL seconds (mildly annoying) |
Recommendation when this lands: subject-scoped flush as the simple default; switch to resource-scoped flush if subject churn is too painful for cache hit rate.
### Cache key normalization for cascading
Important detail: the cached entry for "bob can read folder F" doesn't need a separate entry per descendant. The engine's `check(bob, read, child)` would still go through the SQL because the cache key is `(bob, read, child)`, distinct from `(bob, read, F)`. So caching gives no descendant boost UNLESS we:
- Pre-resolve to "bob's effective grants" once (list all `(subject_id, permission, resource_id)` rows for bob) and cache that bundle, then evaluate any `check()` against the in-memory bundle. This is a classic Zanzibar-style "user list" cache.
That's a separate L1 design: cache the **bundle** of bob's grants, not individual decisions. Hit rate is high (one cached blob per active user). Invalidation is per-subject (when bob receives/loses a grant). The check becomes "is the requested resource an ltree descendant of any folder in bob's grant bundle?" — done in process, no DB round-trip.
This is probably the right L1 shape for OxiCloud given the cascade semantics.
### Config
```rust
// In OxiCloud config:
OXICLOUD_AUTHZ_CACHE=disabled // default in v1
OXICLOUD_AUTHZ_CACHE=in_memory // L1 only
OXICLOUD_AUTHZ_CACHE=redis // L1 + L2 (requires OXICLOUD_REDIS_URL)
OXICLOUD_AUTHZ_CACHE_TTL=300 // seconds
```
The engine selection in `common/di.rs` wraps the underlying `PgAclEngine` based on this config. Disabled by default to keep v1 minimal.
### Why this is a clean follow-up, not v1
- The `AuthorizationEngine` trait is unchanged → the cache is a pure decorator
- Owner short-circuit already avoids the DB for the most common case (caller acting on own resources) — caching's marginal value is highest only once cross-user grants are common
- Adding caching too early hides whether the uncached SQL is actually slow at production scale; better to measure first
- Redis adds a new infrastructure dependency; introducing it before there's measured pressure is premature
### When to revisit
Add per-request cache when batch ops show repeated DB checks in tracing. Add L1 in-process cache when single-instance `check` p99 latency exceeds a threshold under cross-user workloads. Add L2 Redis only when running multi-instance and cross-instance cache coherence becomes a hit-rate problem.
---
## Future (v2): extend ReBAC to calendars, address books, playlists
Three resource types already have user-to-user sharing implemented as bespoke per-feature tables. After v1 proves the engine shape on files/folders, absorb them in a follow-up plan per resource type.
### Existing share infrastructure to migrate
| Resource | Today's share table | Today's permission shape |
|---|---|---|
| Calendar (CalDAV) | `caldav.calendar_shares (calendar_id, user_id, access_level)` | `'read' | 'write' | 'owner'` |
| Address book (CardDAV) | `carddav.address_book_shares (address_book_id, user_id, can_write)` | binary `can_write` |
| Playlist (audio) | `audio.playlist_shares (playlist_id, user_id, can_write)` | binary `can_write` |
### Required changes per resource type
Each migration is small and self-contained:
1. **Schema** — extend `resource_type` CHECK constraint:
```sql
ALTER TABLE storage.access_grants
DROP CONSTRAINT access_grants_resource_type_check,
ADD CONSTRAINT access_grants_resource_type_check
CHECK (resource_type IN ('folder', 'file', 'calendar', 'address_book', 'playlist'));
```
2. **Domain** — extend `Resource` enum with `Calendar(Uuid)`, `AddressBook(Uuid)`, `Playlist(Uuid)`.
3. **Engine** — no cascading needed (these are flat containers, not trees). The `check()` SQL becomes a simple direct lookup with no ltree join for these branches.
4. **Service refactor** — remove the bespoke `share_calendar` / `share_address_book` / `share_playlist` methods. Sharing goes through `POST /api/grants` uniformly.
5. **Cleanup triggers** — add AFTER DELETE triggers on `caldav.calendars`, `carddav.address_books`, `audio.playlists` (same pattern as v1 triggers on `storage.folders`/`storage.files`).
6. **Data migration** — convert existing share rows:
```sql
INSERT INTO storage.access_grants (subject_type, subject_id, resource_type, resource_id, permission, granted_by)
SELECT 'user', cs.user_id, 'calendar', cs.calendar_id, p.perm, c.owner_id
FROM caldav.calendar_shares cs
JOIN caldav.calendars c ON c.id = cs.calendar_id
CROSS JOIN LATERAL (
SELECT unnest(CASE cs.access_level
WHEN 'read' THEN ARRAY['read']
WHEN 'write' THEN ARRAY['read','update','create','delete']
WHEN 'owner' THEN ARRAY['read','update','create','delete','share']
END) AS perm
) p;
-- Same shape for address_book_shares (FALSE → ['read'], TRUE → ['read','update','create','delete'])
-- Same shape for playlist_shares.
```
7. **Protocol mapping** (CalDAV / CardDAV only) — the WebDAV sharing properties (`<DAV:share-access>`, `<oc:invite>`) need to be re-implemented on top of the new grants. This is the largest unknown and the main reason for deferral.
### Why deferred, not in v1
- v1 must prove the `AuthorizationEngine` trait shape works before three more services land on it. If the trait needs an adjustment after running it on files, fixing it before three more migrations is much cheaper.
- The CalDAV/CardDAV protocol layer expects sharing semantics expressed via WebDAV properties — that's its own piece of work decoupled from the v1 grant table.
- Calendars/playlists are niche compared to file sharing — low migration risk if deferred.
- The 6-permission model already accommodates these without extension; the change is mechanical, just not yet.
### Suggested rollout (one PR per resource)
- **PR A** — calendars: schema constraint + Resource enum + CalendarService refactor + Hurl tests + CalDAV property mapping
- **PR B** — address books: same shape, simpler (binary `can_write`)
- **PR C** — playlists: same shape, also binary
- Each PR drops the corresponding bespoke share table at the end.
---
## Future: OpenFGA plug-in
Implementing `OpenFgaEngine` later requires:
1. Define the OpenFGA model:
```
type folder
relations
define parent: [folder]
define reader: [user, folder#reader]
define creator: [user, folder#creator]
define updater: [user, folder#updater]
define deleter: [user, folder#deleter]
define sharer: [user, folder#sharer]
define owner: [user]
type file
relations
define parent: [folder]
define reader: [user, folder#reader]
...
```
2. On engine init, sync owner relationships (walk `storage.folders` + `storage.files`).
3. On every `grant()`, also write the tuple to OpenFGA.
4. On `check()`, query OpenFGA's `/check` endpoint.
The `AuthorizationEngine` trait shape is identical, so swapping engines is a configuration change. The PG engine remains the source of truth for `storage.access_grants` rows; OpenFGA becomes an indexed read cache.