Merge pull request #383 from EdouardVanbelle/permissions

This commit is contained in:
Dionisio Pozo
2026-05-22 00:43:43 +02:00
committed by GitHub
55 changed files with 5322 additions and 808 deletions
+6
View File
@@ -119,6 +119,12 @@ Never duplicate logic across handlers or services. If the same behaviour is need
- Reusable infrastructure behaviour → method on the relevant service struct
- Shared port behaviour → default method on the trait
### Authorization (AuthZ)
**AuthZ is enforced exclusively in the application service layer, never in handlers.** All permission checks go through `AuthorizationEngine` (port: `application/ports/authorization_ports.rs`) via service methods named with the `_with_perms` suffix. HTTP handlers (REST, WebDAV, NextCloud, CalDAV, CardDAV) authenticate the caller and pass `caller_id` into the service — they MUST NOT perform their own ownership/permission checks. The authentication middleware extracts the caller; the service decides if the action is allowed.
This rule prevents drift between layers and ensures every code path goes through the same policy. New service methods that touch a user-scoped resource must take `caller_id: Uuid` and call `authz.require(...)` before any read or mutation.
# Frontend part
## Code conventions
+1
View File
@@ -0,0 +1 @@
# This directory contains plans and implementation architectures
@@ -0,0 +1,982 @@
# 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.
@@ -0,0 +1,161 @@
-- ════════════════════════════════════════════════════════════════════════════
-- ReBAC: access_grants table + lifecycle cleanup triggers + data migration
-- ════════════════════════════════════════════════════════════════════════════
-- PR 1 of the ReBAC rollout. Schema and data only — no code changes yet.
--
-- This migration:
-- 1. Creates storage.access_grants (the single grant table for ReBAC)
-- 2. Installs AFTER DELETE triggers so lifecycle cleanup is enforced at the
-- DB level even if a future code path bypasses the service layer
-- 3. Migrates existing storage.shares permission flags into access_grants
-- rows with subject_type='token'
--
-- The storage.shares.permissions_* columns are NOT dropped here. They stay
-- until PR 5 (share_service is updated to read from access_grants instead).
-- See /Users/ed/.claude/plans/compiled-shimmying-bonbon.md → "Rollout sequencing".
-- ── 1. The grant table ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS 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
-- (Open Cloud Mesh / 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,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (subject_type, subject_id, resource_type, resource_id, permission)
);
CREATE INDEX IF NOT EXISTS idx_grants_subject
ON storage.access_grants (subject_type, subject_id);
CREATE INDEX IF NOT EXISTS idx_grants_resource
ON storage.access_grants (resource_type, resource_id);
COMMENT ON TABLE storage.access_grants IS
'ReBAC grant table — subject × resource × permission. Owner is implicit '
'via storage.folders.user_id / storage.files.user_id (no rows here for owners).';
-- ── 2. Lifecycle cleanup triggers (defense-in-depth) ────────────────────────
-- These fire AFTER DELETE on the resource/subject tables so stale grants can
-- never outlive their target. The application layer also calls explicit
-- engine.revoke_all_for_* on the canonical paths.
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;
DROP TRIGGER IF EXISTS trg_cleanup_grants_folder ON storage.folders;
CREATE TRIGGER trg_cleanup_grants_folder
AFTER DELETE ON storage.folders
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_grants_on_resource_delete('folder');
DROP TRIGGER IF EXISTS trg_cleanup_grants_file ON storage.files;
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;
DROP TRIGGER IF EXISTS trg_cleanup_grants_user ON auth.users;
CREATE TRIGGER trg_cleanup_grants_user
AFTER DELETE ON auth.users
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_grants_on_subject_delete('user');
DROP TRIGGER IF EXISTS trg_cleanup_grants_token ON storage.shares;
CREATE TRIGGER trg_cleanup_grants_token
AFTER DELETE ON storage.shares
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_grants_on_subject_delete('token');
-- ── 3. Data migration from storage.shares ───────────────────────────────────
-- Each existing share row becomes one or more access_grants rows with
-- subject_type='token', subject_id=shares.id.
--
-- The old model's permission flags map to the new model as:
-- permissions_read → ['read']
-- permissions_write → ['read', 'create', 'update', 'delete']
-- (write implies full mutation rights)
-- permissions_reshare → ['share']
--
-- WHERE NOT EXISTS guards make this idempotent — re-running the migration
-- won't create duplicates.
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
AND NOT EXISTS (
SELECT 1 FROM storage.access_grants g
WHERE g.subject_type = 'token'
AND g.subject_id = s.id
AND g.resource_id = s.item_id::uuid
AND g.permission = 'read'
);
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 ('read'), ('create'), ('update'), ('delete')) AS p(perm)
WHERE s.permissions_write
AND NOT EXISTS (
SELECT 1 FROM storage.access_grants g
WHERE g.subject_type = 'token'
AND g.subject_id = s.id
AND g.resource_id = s.item_id::uuid
AND g.permission = p.perm
);
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
AND NOT EXISTS (
SELECT 1 FROM storage.access_grants g
WHERE g.subject_type = 'token'
AND g.subject_id = s.id
AND g.resource_id = s.item_id::uuid
AND g.permission = 'share'
);
+222
View File
@@ -0,0 +1,222 @@
//! DTOs for the ReBAC `/api/grants` REST endpoints.
//!
//! The wire shapes are intentionally separate from the domain types
//! (`Subject`, `Resource`, `Permission`, `Grant`) so that domain stays
//! storage-agnostic and DTOs can evolve with the HTTP contract.
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
// ════════════════════════════════════════════════════════════════════════════
// Subject / Resource / Permission DTOs
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum SubjectTypeDto {
User,
Group,
Token,
External,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SubjectDto {
#[serde(rename = "type")]
pub kind: SubjectTypeDto,
pub id: Uuid,
}
impl From<SubjectDto> for Subject {
fn from(dto: SubjectDto) -> Self {
match dto.kind {
SubjectTypeDto::User => Subject::User(dto.id),
SubjectTypeDto::Group => Subject::Group(dto.id),
SubjectTypeDto::Token => Subject::Token(dto.id),
SubjectTypeDto::External => Subject::External(dto.id),
}
}
}
impl From<Subject> for SubjectDto {
fn from(s: Subject) -> Self {
let (kind, id) = match s {
Subject::User(id) => (SubjectTypeDto::User, id),
Subject::Group(id) => (SubjectTypeDto::Group, id),
Subject::Token(id) => (SubjectTypeDto::Token, id),
Subject::External(id) => (SubjectTypeDto::External, id),
};
SubjectDto { kind, id }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum ResourceTypeDto {
Folder,
File,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ResourceDto {
#[serde(rename = "type")]
pub kind: ResourceTypeDto,
pub id: Uuid,
}
impl From<ResourceDto> for Resource {
fn from(dto: ResourceDto) -> Self {
match dto.kind {
ResourceTypeDto::Folder => Resource::Folder(dto.id),
ResourceTypeDto::File => Resource::File(dto.id),
}
}
}
impl From<Resource> for ResourceDto {
fn from(r: Resource) -> Self {
let (kind, id) = match r {
Resource::Folder(id) => (ResourceTypeDto::Folder, id),
Resource::File(id) => (ResourceTypeDto::File, id),
};
ResourceDto { kind, id }
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PermissionDto {
Read,
Create,
Share,
Comment,
Delete,
Update,
}
impl From<PermissionDto> for Permission {
fn from(p: PermissionDto) -> Self {
match p {
PermissionDto::Read => Permission::Read,
PermissionDto::Create => Permission::Create,
PermissionDto::Share => Permission::Share,
PermissionDto::Comment => Permission::Comment,
PermissionDto::Delete => Permission::Delete,
PermissionDto::Update => Permission::Update,
}
}
}
impl From<Permission> for PermissionDto {
fn from(p: Permission) -> Self {
match p {
Permission::Read => PermissionDto::Read,
Permission::Create => PermissionDto::Create,
Permission::Share => PermissionDto::Share,
Permission::Comment => PermissionDto::Comment,
Permission::Delete => PermissionDto::Delete,
Permission::Update => PermissionDto::Update,
}
}
}
// ════════════════════════════════════════════════════════════════════════════
// Roles (DTO-layer sugar)
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Viewer,
Commenter,
Editor,
Manager,
Admin,
}
impl Role {
/// Expands a role into its constituent raw permissions. Storage and
/// engine know nothing about roles — the server normalizes here before
/// writing rows.
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,
],
}
}
}
// ════════════════════════════════════════════════════════════════════════════
// Request DTOs
// ════════════════════════════════════════════════════════════════════════════
/// `POST /api/grants` — accepts either `permissions` (explicit) or `role`.
/// Server-side validation requires exactly one of the two to be present.
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateGrantDto {
pub subject: SubjectDto,
pub resource: ResourceDto,
#[serde(default)]
pub permissions: Option<Vec<PermissionDto>>,
#[serde(default)]
pub role: Option<Role>,
}
/// `PUT /api/grants/role` — reconcile a subject's role on a resource.
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateRoleDto {
pub subject: SubjectDto,
pub resource: ResourceDto,
pub role: Role,
}
// ════════════════════════════════════════════════════════════════════════════
// Response DTOs
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct GrantDto {
pub id: Uuid,
pub subject: SubjectDto,
pub resource: ResourceDto,
pub permission: PermissionDto,
pub granted_by: Uuid,
pub granted_at: chrono::DateTime<chrono::Utc>,
}
impl From<Grant> for GrantDto {
fn from(g: Grant) -> Self {
Self {
id: g.id,
subject: g.subject.into(),
resource: g.resource.into(),
permission: g.permission.into(),
granted_by: g.granted_by,
granted_at: g.granted_at,
}
}
}
+1
View File
@@ -8,6 +8,7 @@ pub mod favorites_dto;
pub mod file_dto;
pub mod folder_dto;
pub mod folder_listing_dto;
pub mod grant_dto;
pub mod i18n_dto;
pub mod pagination;
pub mod playlist_dto;
@@ -0,0 +1,100 @@
//! Authorization port — the trait every service depends on for permission
//! decisions. Implementations: `PgAclEngine` (v1 default), `OpenFgaEngine`
//! (future). A `CachedAuthorizationEngine` decorator over either is planned
//! as a future optimization.
//!
//! Architectural rule (see CLAUDE.md):
//! **AuthZ is enforced exclusively in the application service layer.**
//! Handlers authenticate the caller and pass `caller_id` to the service;
//! they never call this trait directly.
use uuid::Uuid;
use crate::common::errors::DomainError;
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
pub trait AuthorizationEngine: Send + Sync + 'static {
/// Returns true if `subject` has `permission` on `resource`, considering
/// owner short-circuit AND cascading from folder ancestors.
///
/// `check` never errors for "permission denied" — that's a `false` return.
/// `Err` is reserved for infrastructure failures (DB down, etc.).
async fn check(
&self,
subject: Subject,
permission: Permission,
resource: Resource,
) -> Result<bool, DomainError>;
/// Convenience wrapper around `check`: returns `Ok(())` when allowed and
/// `DomainError::not_found` when denied (anti-enumeration — same error as
/// "resource doesn't exist" so attackers can't probe IDs by error shape).
async fn require(
&self,
subject: Subject,
permission: Permission,
resource: Resource,
) -> Result<(), DomainError> {
if self.check(subject, permission, resource).await? {
tracing::debug!(
"👮🏻‍♂️ perms: ✔ Subject '{}' has permission to '{}' on resource '{}'",
subject,
permission,
resource
);
Ok(())
} else {
let (kind, id) = match resource {
Resource::Folder(id) => ("Folder", id),
Resource::File(id) => ("File", id),
};
// log it for audit
tracing::info!(
"👮🏻‍♂️ perms: ⛔ Subject '{}' hasn't permission to '{}' on resource '{}'",
subject,
permission,
resource
);
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
/// must verify the caller has `Share` on the resource before invoking.
async fn list_grants_on_resource(&self, resource: Resource) -> Result<Vec<Grant>, DomainError>;
/// Grants Outgoing — grants created by `granted_by`. Used by
/// `GET /api/grants/outgoing` ("things I've shared with others").
async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result<Vec<Grant>, DomainError>;
/// Create a grant. Idempotent — duplicates are absorbed by the UNIQUE
/// constraint and the existing row is returned.
async fn grant(
&self,
granted_by: Uuid,
subject: Subject,
permission: Permission,
resource: Resource,
) -> Result<Grant, DomainError>;
/// Revoke a specific grant by its UUID. Returns `Ok(())` whether or not
/// the row existed (idempotent revoke).
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError>;
/// Removes every grant whose `resource` matches. Called by lifecycle
/// hooks when a resource is permanently deleted. Returns the count of
/// rows removed.
async fn revoke_all_for_resource(&self, resource: Resource) -> Result<usize, DomainError>;
/// Removes every grant whose `subject` matches. Called when a user/token
/// /group is deleted. Returns the count of rows removed.
async fn revoke_all_for_subject(&self, subject: Subject) -> Result<usize, DomainError>;
}
+23 -47
View File
@@ -11,6 +11,7 @@ use crate::application::services::file_management_service::FileManagementService
use crate::application::services::file_retrieval_service::FileRetrievalService;
use crate::application::services::file_upload_service::FileUploadService;
use crate::common::errors::DomainError;
use crate::domain::services::authorization::Permission;
// ─────────────────────────────────────────────────────
// Upload port
@@ -119,7 +120,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
///
/// Returns `NotFound` if the file does not exist **or** belongs to
/// another user. All user-facing handlers should use this method.
async fn get_file_owned(&self, id: &str, caller_id: Uuid) -> Result<FileDto, DomainError>;
async fn get_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<FileDto, DomainError>;
/// Gets a file by its path (for WebDAV)
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError>;
@@ -131,7 +132,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
///
/// Uses SQL-level `AND user_id` filtering — no in-memory post-filter.
/// All user-facing list handlers should use this method.
async fn list_files_owned(
async fn list_files_with_perms(
&self,
folder_id: Option<&str>,
owner_id: Uuid,
@@ -144,7 +145,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
/// Gets file content as a stream, enforcing that `caller_id` is the owner.
async fn get_file_stream_owned(
async fn get_file_stream_with_perms(
&self,
id: &str,
caller_id: Uuid,
@@ -166,7 +167,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
///
/// Verifies `caller_id` owns the file before returning content.
/// All user-facing download handlers should use this.
async fn get_file_optimized_owned(
async fn get_file_optimized_with_perms(
&self,
id: &str,
caller_id: Uuid,
@@ -198,7 +199,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
/// Ownership-scoped range stream — verifies caller owns the file first.
async fn get_file_range_stream_owned(
async fn get_file_range_stream_with_perms(
&self,
id: &str,
caller_id: Uuid,
@@ -238,7 +239,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
///
/// Used by streaming WebDAV PROPFIND so that each user only sees their
/// own files, even in shared folder_id namespaces.
async fn list_files_batch_for_owner(
async fn list_files_batch_with_perms(
&self,
folder_id: Option<&str>,
owner_id: Uuid,
@@ -254,58 +255,41 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
}
}
// ─────────────────────────────────────────────────────
// Management port (delete, move)
// ─────────────────────────────────────────────────────
/// Primary port for file management operations
pub trait FileManagementUseCase: Send + Sync + 'static {
/// Moves a file to another folder (system/internal — no ownership check).
async fn move_file(
async fn require_permission(
&self,
caller_id: Uuid,
permission: Permission,
file_id: &str,
folder_id: Option<String>,
) -> Result<FileDto, DomainError>;
) -> Result<(), DomainError>;
/// Moves a file, enforcing that `caller_id` is the owner.
async fn move_file_owned(
async fn move_file_with_perms(
&self,
file_id: &str,
caller_id: Uuid,
folder_id: Option<String>,
) -> Result<FileDto, DomainError>;
/// Copies a file to another folder (zero-copy with dedup).
async fn copy_file(
&self,
file_id: &str,
target_folder_id: Option<String>,
) -> Result<FileDto, DomainError>;
/// Copies a file, enforcing that `caller_id` is the owner.
async fn copy_file_owned(
async fn copy_file_with_perms(
&self,
file_id: &str,
caller_id: Uuid,
target_folder_id: Option<String>,
) -> Result<FileDto, DomainError>;
/// Renames a file (system/internal — no ownership check).
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<FileDto, DomainError>;
/// Renames a file, enforcing that `caller_id` is the owner.
async fn rename_file_owned(
async fn rename_file_with_perms(
&self,
file_id: &str,
caller_id: Uuid,
new_name: &str,
) -> Result<FileDto, DomainError>;
/// Deletes a file (system/internal — no ownership check).
async fn delete_file(&self, id: &str) -> Result<(), DomainError>;
/// Deletes a file, enforcing that `caller_id` is the owner.
async fn delete_file_owned(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError>;
async fn delete_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError>;
/// Smart delete: trash-first with dedup reference cleanup.
///
@@ -314,30 +298,22 @@ pub trait FileManagementUseCase: Send + Sync + 'static {
/// 3. Decrements the dedup reference count for the content hash.
///
/// Returns `Ok(true)` when trashed, `Ok(false)` when permanently deleted.
async fn delete_with_cleanup(&self, id: &str, user_id: Uuid) -> Result<bool, DomainError>;
async fn delete_and_cleanup_with_perms(
&self,
id: &str,
user_id: Uuid,
) -> Result<bool, DomainError>;
/// Copies an entire folder subtree atomically (WebDAV COPY Depth: infinity).
/// enforcing that `caller_id` owns both the source folder
/// and the target parent folder.
///
/// Creates a copy of `source_folder_id` (with optional name override) under
/// `target_parent_id`, including ALL sub-folders and files. Files are
/// zero-copy (blob ref_counts incremented in batch).
///
/// Default: returns error (only available with PostgreSQL backend).
async fn copy_folder_tree(
&self,
_source_folder_id: &str,
_target_parent_id: Option<String>,
_dest_name: Option<String>,
) -> Result<CopyFolderTreeResult, DomainError> {
Err(DomainError::internal_error(
"FileManagement",
"copy_folder_tree not implemented",
))
}
/// Copies a folder tree, enforcing that `caller_id` owns both the source folder
/// and the target parent folder.
async fn copy_folder_tree_owned(
async fn copy_folder_tree_with_perms(
&self,
source_folder_id: &str,
caller_id: Uuid,
+102
View File
@@ -0,0 +1,102 @@
/// Primary port for folder operations
use uuid::Uuid;
use crate::application::dtos::folder_dto::{
CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto,
};
use crate::common::errors::DomainError;
use crate::domain::services::authorization::Permission;
pub trait FolderUseCase: Send + Sync + 'static {
async fn require_permission(
&self,
caller_id: Uuid,
permission: Permission,
folder_id: &str,
) -> Result<(), DomainError>;
/// Creates a new folder
async fn create_folder_with_perms(
&self,
dto: CreateFolderDto,
caller_id: Uuid,
) -> Result<FolderDto, DomainError>;
/// Gets a folder by its ID
async fn get_folder(&self, id: &str) -> Result<FolderDto, DomainError>;
/// Gets a folder by its ID, enforcing that `caller_id` is the owner.
///
/// Returns `NotFound` if the folder does not exist **or** belongs to
/// another user. All user-facing handlers should use this method.
async fn get_folder_with_perms(
&self,
id: &str,
caller_id: Uuid,
) -> Result<FolderDto, DomainError>;
/// Gets a folder by its path
async fn get_folder_by_path(&self, path: &str) -> Result<FolderDto, DomainError>;
/// Lists folders within a parent folder
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<FolderDto>, DomainError>;
/// Lists folders scoped to a specific owner (for user-facing endpoints).
/// At root level, only returns folders belonging to this user.
async fn list_folders_with_perms(
&self,
parent_id: Option<&str>,
owner_id: Uuid,
) -> Result<Vec<FolderDto>, DomainError>;
/// Lists folders with pagination
async fn list_folders_paginated(
&self,
parent_id: Option<&str>,
pagination: &crate::application::dtos::pagination::PaginationRequestDto,
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>;
/// Lists folders with pagination, scoped to a specific owner.
async fn list_folders_paginated_with_perms(
&self,
parent_id: Option<&str>,
owner_id: Uuid,
pagination: &crate::application::dtos::pagination::PaginationRequestDto,
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>;
/// Renames a folder (ownership verified against caller_id)
async fn rename_folder_with_perms(
&self,
id: &str,
dto: RenameFolderDto,
caller_id: Uuid,
) -> Result<FolderDto, DomainError>;
/// Moves a folder to another parent (ownership verified against caller_id)
async fn move_folder_with_perms(
&self,
id: &str,
dto: MoveFolderDto,
caller_id: Uuid,
) -> Result<FolderDto, DomainError>;
/// Deletes a folder (ownership verified against caller_id)
async fn delete_folder_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError>;
/// Creates a root-level home folder for a user during registration.
async fn create_home_folder(
&self,
user_id: Uuid,
name: String,
) -> Result<FolderDto, DomainError>;
/// Lists every folder in a subtree rooted at `folder_id` (inclusive),
/// ordered by path. Uses ltree `<@` — single GiST-indexed query.
///
/// Default: returns an empty vec (stubs / mocks).
async fn list_subtree_folders(&self, folder_id: &str) -> Result<Vec<FolderDto>, DomainError> {
let _ = folder_id;
Ok(Vec::new())
}
}
-82
View File
@@ -2,93 +2,11 @@ use std::sync::Arc;
use uuid::Uuid;
use crate::application::dtos::folder_dto::{
CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto,
};
use crate::application::dtos::search_dto::{
SearchCriteriaDto, SearchResultsDto, SearchSuggestionsDto,
};
use crate::common::errors::DomainError;
/// Primary port for folder operations
pub trait FolderUseCase: Send + Sync + 'static {
/// Creates a new folder
async fn create_folder(&self, dto: CreateFolderDto) -> Result<FolderDto, DomainError>;
/// Gets a folder by its ID
async fn get_folder(&self, id: &str) -> Result<FolderDto, DomainError>;
/// Gets a folder by its ID, enforcing that `caller_id` is the owner.
///
/// Returns `NotFound` if the folder does not exist **or** belongs to
/// another user. All user-facing handlers should use this method.
async fn get_folder_owned(&self, id: &str, caller_id: Uuid) -> Result<FolderDto, DomainError>;
/// Gets a folder by its path
async fn get_folder_by_path(&self, path: &str) -> Result<FolderDto, DomainError>;
/// Lists folders within a parent folder
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<FolderDto>, DomainError>;
/// Lists folders scoped to a specific owner (for user-facing endpoints).
/// At root level, only returns folders belonging to this user.
async fn list_folders_for_owner(
&self,
parent_id: Option<&str>,
owner_id: Uuid,
) -> Result<Vec<FolderDto>, DomainError>;
/// Lists folders with pagination
async fn list_folders_paginated(
&self,
parent_id: Option<&str>,
pagination: &crate::application::dtos::pagination::PaginationRequestDto,
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>;
/// Lists folders with pagination, scoped to a specific owner.
async fn list_folders_for_owner_paginated(
&self,
parent_id: Option<&str>,
owner_id: Uuid,
pagination: &crate::application::dtos::pagination::PaginationRequestDto,
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>;
/// Renames a folder (ownership verified against caller_id)
async fn rename_folder(
&self,
id: &str,
dto: RenameFolderDto,
caller_id: Uuid,
) -> Result<FolderDto, DomainError>;
/// Moves a folder to another parent (ownership verified against caller_id)
async fn move_folder(
&self,
id: &str,
dto: MoveFolderDto,
caller_id: Uuid,
) -> Result<FolderDto, DomainError>;
/// Deletes a folder (ownership verified against caller_id)
async fn delete_folder(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError>;
/// Creates a root-level home folder for a user during registration.
async fn create_home_folder(
&self,
user_id: Uuid,
name: String,
) -> Result<FolderDto, DomainError>;
/// Lists every folder in a subtree rooted at `folder_id` (inclusive),
/// ordered by path. Uses ltree `<@` — single GiST-indexed query.
///
/// Default: returns an empty vec (stubs / mocks).
async fn list_subtree_folders(&self, folder_id: &str) -> Result<Vec<FolderDto>, DomainError> {
let _ = folder_id;
Ok(Vec::new())
}
}
/**
* Primary port for file and folder search.
*
+2
View File
@@ -1,4 +1,5 @@
pub mod auth_ports;
pub mod authorization_ports;
pub mod blob_lifecycle;
pub mod blob_storage_ports;
pub mod cache_ports;
@@ -10,6 +11,7 @@ pub mod dedup_ports;
pub mod favorites_ports;
pub mod file_lifecycle;
pub mod file_ports;
pub mod folder_ports;
pub mod inbound;
pub mod music_ports;
pub mod outbound;
@@ -5,7 +5,7 @@ use crate::application::ports::auth_ports::{
OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort,
UserStoragePort,
};
use crate::application::ports::inbound::FolderUseCase;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::services::folder_service::FolderService;
use crate::common::config::OidcConfig;
use crate::common::errors::{DomainError, ErrorKind};
+46 -24
View File
@@ -12,7 +12,7 @@ use tracing::info;
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::{FolderDto, MoveFolderDto};
use crate::application::ports::file_ports::{FileManagementUseCase, FileRetrievalUseCase};
use crate::application::ports::inbound::FolderUseCase;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::storage_ports::CopyFolderTreeResult;
use crate::application::ports::trash_ports::TrashUseCase;
use crate::application::services::file_management_service::FileManagementService;
@@ -145,7 +145,7 @@ impl BatchOperationService {
async move {
let copy_result = mgmt
.copy_file_owned(&file_id, user_id, target_folder.map(|s| s.to_string()))
.copy_file_with_perms(&file_id, user_id, target_folder.map(|s| s.to_string()))
.await;
(file_id, copy_result)
}
@@ -211,7 +211,7 @@ impl BatchOperationService {
async move {
let move_result = mgmt
.move_file_owned(&file_id, user_id, target_folder.map(|s| s.to_string()))
.move_file_with_perms(&file_id, user_id, target_folder.map(|s| s.to_string()))
.await;
(file_id, move_result)
}
@@ -270,7 +270,7 @@ impl BatchOperationService {
let mgmt = self.file_management.clone();
async move {
let delete_result = mgmt.delete_file_owned(&file_id, user_id).await;
let delete_result = mgmt.delete_file_with_perms(&file_id, user_id).await;
let id_for_result = file_id.clone();
(file_id, delete_result.map(|_| id_for_result))
}
@@ -330,7 +330,7 @@ impl BatchOperationService {
let retrieval = self.file_retrieval.clone();
async move {
let get_result = retrieval.get_file_owned(&file_id, user_id).await;
let get_result = retrieval.get_file_with_perms(&file_id, user_id).await;
(file_id, get_result)
}
}))
@@ -390,7 +390,9 @@ impl BatchOperationService {
let folder_service = self.folder_service.clone();
async move {
let delete_result = folder_service.delete_folder(&folder_id, user_id).await;
let delete_result = folder_service
.delete_folder_with_perms(&folder_id, user_id)
.await;
let id_for_result = folder_id.clone();
(folder_id, delete_result.map(|_| id_for_result))
}
@@ -583,7 +585,9 @@ impl BatchOperationService {
let dto = MoveFolderDto {
parent_id: target.map(|s| s.to_string()),
};
let move_result = folder_service.move_folder(&folder_id, dto, user_id).await;
let move_result = folder_service
.move_folder_with_perms(&folder_id, dto, user_id)
.await;
(folder_id, move_result)
}
}))
@@ -644,7 +648,7 @@ impl BatchOperationService {
async move {
let copy_result = file_management
.copy_folder_tree_owned(
.copy_folder_tree_with_perms(
&folder_id,
user_id,
target.map(|s| s.to_string()),
@@ -711,15 +715,27 @@ impl BatchOperationService {
let buf_writer = BufWriter::with_capacity(256 * 1024, tokio_file);
let mut zip = ZipFileWriter::with_tokio(buf_writer);
// Track whether any item was authorized + added to the ZIP. If
// none were, return NotFound — empty ZIPs are useless and mask
// authz failures from the client.
let mut items_added: usize = 0;
// ── Add individual files at the root of the ZIP ──────────────────
for file_id in &file_ids {
match self.file_retrieval.get_file_owned(file_id, user_id).await {
match self
.file_retrieval
.get_file_with_perms(file_id, user_id)
.await
{
Ok(file_dto) => {
if let Err(e) = self
match self
.add_file_entry_streamed(&mut zip, file_id, &file_dto.name, user_id)
.await
{
info!("Could not add file {} to ZIP: {}", file_dto.name, e);
Ok(_) => items_added += 1,
Err(e) => {
info!("Could not add file {} to ZIP: {}", file_dto.name, e);
}
}
}
Err(e) => {
@@ -732,15 +748,18 @@ impl BatchOperationService {
for folder_id in &folder_ids {
match self
.folder_service
.get_folder_owned(folder_id, user_id)
.get_folder_with_perms(folder_id, user_id)
.await
{
Ok(root_folder) => {
if let Err(e) = self
match self
.add_folder_subtree_to_zip(&mut zip, folder_id, &root_folder, user_id)
.await
{
info!("Could not add folder {} to ZIP: {}", root_folder.name, e);
Ok(_) => items_added += 1,
Err(e) => {
info!("Could not add folder {} to ZIP: {}", root_folder.name, e);
}
}
}
Err(e) => {
@@ -749,6 +768,14 @@ impl BatchOperationService {
}
}
// Bail out before finalizing the ZIP if nothing was authorized.
if items_added == 0 {
return Err(BatchOperationError::Domain(DomainError::not_found(
"BatchDownload",
"No accessible files or folders in the request",
)));
}
// ── Finalize ─────────────────────────────────────────────────────
let mut compat_writer = zip
.close()
@@ -786,7 +813,7 @@ impl BatchOperationService {
let stream = self
.file_retrieval
.get_file_stream_owned(file_id, caller_id)
.get_file_stream_with_perms(file_id, caller_id)
.await
.map_err(BatchOperationError::Domain)?;
let mut stream = std::pin::Pin::from(stream);
@@ -975,18 +1002,11 @@ impl BatchOperationService {
let folder_service = self.folder_service.clone();
async move {
// If a parent is specified, verify the caller owns it
if let Some(ref pid) = parent_id
&& let Err(e) = folder_service.get_folder_owned(pid, user_id).await
{
let id = format!("{}:{}", name, pid);
return (id, Err(e));
}
let dto = crate::application::dtos::folder_dto::CreateFolderDto {
name: name.clone(),
parent_id: parent_id.clone(),
};
let create_result = folder_service.create_folder(dto).await;
let create_result = folder_service.create_folder_with_perms(dto, user_id).await;
let id = format!("{}:{}", name, parent_id.unwrap_or_default());
(id, create_result)
}
@@ -1046,7 +1066,9 @@ impl BatchOperationService {
let folder_service = self.folder_service.clone();
async move {
let get_result = folder_service.get_folder_owned(&folder_id, user_id).await;
let get_result = folder_service
.get_folder_with_perms(&folder_id, user_id)
.await;
(folder_id, get_result)
}
}))
@@ -89,9 +89,18 @@ mod tests {
let file_read_repo = Arc::new(FileBlobReadRepository::new_stub());
let file_write_repo = Arc::new(FileBlobWriteRepository::new_stub());
let file_retrieval = Arc::new(FileRetrievalService::new(file_read_repo));
let file_management = Arc::new(FileManagementService::new(file_write_repo));
let folder_service = Arc::new(FolderService::new(folder_repo));
let authz =
Arc::new(crate::infrastructure::services::pg_acl_engine::PgAclEngine::new_stub());
let file_retrieval = Arc::new(FileRetrievalService::new(file_read_repo.clone()));
let file_management = Arc::new(FileManagementService::with_trash(
file_write_repo,
None,
Some(file_read_repo),
None,
None,
authz.clone(),
));
let folder_service = Arc::new(FolderService::new(folder_repo, authz));
let _batch_service = BatchOperationService::new(
file_retrieval,
@@ -0,0 +1,47 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use crate::application::ports::file_lifecycle::FileDeletedHook;
/// Composite dispatcher for file lifecycle events.
///
/// Aggregates all `FileDeletedHook` implementations and fans out each event to
/// every registered handler. Services hold a single `Arc<dyn FileDeletedHook>`
/// pointing here — new handlers are added once, in DI, without touching the
/// services themselves.
pub struct FileLifecycleService {
deleted: Vec<Arc<dyn FileDeletedHook>>,
}
impl Default for FileLifecycleService {
fn default() -> Self {
Self::new()
}
}
impl FileLifecycleService {
pub fn new() -> Self {
Self {
deleted: Vec::new(),
}
}
pub fn with_deleted_hook(mut self, hook: Arc<dyn FileDeletedHook>) -> Self {
self.deleted.push(hook);
self
}
}
impl FileDeletedHook for FileLifecycleService {
fn on_file_deleted<'a>(
&'a self,
file_id: &'a str,
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
Box::pin(async move {
for hook in &self.deleted {
hook.on_file_deleted(file_id).await;
}
})
}
}
@@ -1,17 +1,20 @@
use std::sync::Arc;
use crate::application::dtos::file_dto::FileDto;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::file_lifecycle::FileDeletedHook;
use crate::application::ports::file_ports::FileManagementUseCase;
use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileReadPort, FileWritePort};
use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileWritePort};
use crate::application::ports::trash_ports::TrashUseCase;
use crate::application::services::trash_service::TrashService;
use crate::common::errors::DomainError;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::domain::services::path_service::validate_storage_name;
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
use crate::infrastructure::services::file_content_cache::FileContentCache;
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
use tracing::{error, info, warn};
use uuid::Uuid;
@@ -23,96 +26,74 @@ use uuid::Uuid;
/// touches ref_count directly.
pub struct FileManagementService {
file_repository: Arc<FileBlobWriteRepository>,
file_read: Option<Arc<FileBlobReadRepository>>,
folder_repo: Option<Arc<FolderDbRepository>>,
trash_service: Option<Arc<TrashService>>,
content_cache: Option<Arc<FileContentCache>>,
/// Hooks fired after a file is permanently deleted.
file_deleted_hooks: Vec<Arc<dyn FileDeletedHook>>,
authz: Arc<PgAclEngine>,
/// Hook fired after a file is permanently deleted (typically the FileLifecycleService composite).
file_deleted_hook: Option<Arc<dyn FileDeletedHook>>,
}
impl FileManagementService {
/// Creates a new FileManagementService.
pub fn new(file_repository: Arc<FileBlobWriteRepository>) -> Self {
Self {
file_repository,
file_read: None,
folder_repo: None,
trash_service: None,
content_cache: None,
file_deleted_hooks: Vec::new(),
}
}
/// Creates a FileManagementService with a trash service, read repo, and folder repo for ownership checks.
/// Creates a FileManagementService with a trash service, content cache
/// and the ReBAC authorization engine. File/folder owner lookups (used
/// for owner short-circuit inside the engine) are now the engine's
/// responsibility — this service no longer holds direct repo references
/// for ownership.
pub fn with_trash(
file_repository: Arc<FileBlobWriteRepository>,
trash_service: Option<Arc<TrashService>>,
file_read: Option<Arc<FileBlobReadRepository>>,
folder_repo: Option<Arc<FolderDbRepository>>,
_file_read: Option<Arc<FileBlobReadRepository>>,
_folder_repo: Option<Arc<FolderDbRepository>>,
content_cache: Option<Arc<FileContentCache>>,
authz: Arc<PgAclEngine>,
) -> Self {
Self {
file_repository,
file_read,
folder_repo,
trash_service,
content_cache,
file_deleted_hooks: Vec::new(),
authz,
file_deleted_hook: None,
}
}
/// Registers a hook to fire after a file is permanently deleted.
/// Sets the lifecycle hook fired after a file is permanently deleted.
pub fn with_file_deleted_hook(mut self, hook: Arc<dyn FileDeletedHook>) -> Self {
self.file_deleted_hooks.push(hook);
self.file_deleted_hook = Some(hook);
self
}
/// Verifies ownership via the read repository.
async fn verify_owner(&self, file_id: &str, caller_id: Uuid) -> Result<(), DomainError> {
if let Some(read) = &self.file_read {
read.verify_file_owner(file_id, caller_id).await
} else {
// Fallback: no read repo injected — deny by default (fail-closed)
Err(DomainError::internal_error(
"FileManagement",
"Ownership verification unavailable",
))
}
}
/// Verifies that the target folder is owned by the caller.
/// If folder_id is None (root), ownership is implicitly granted.
async fn verify_target_folder_owner(
/// Engine check for a file resource. Parses the id into a `Uuid` and
/// requires the specified permission.
async fn require_file_perm(
&self,
folder_id: &Option<String>,
file_id: &str,
perm: Permission,
caller_id: Uuid,
) -> Result<(), DomainError> {
let folder_id = match folder_id {
Some(id) => id,
None => return Ok(()), // Moving to root is always allowed
};
if let Some(folder_repo) = &self.folder_repo {
let folder_owner = folder_repo.get_folder_user_id(folder_id).await?;
if folder_owner != caller_id {
return Err(DomainError::not_found(
"Folder",
"Target folder not found or access denied",
));
}
Ok(())
} else {
// Fallback: no folder repo injected — deny by default (fail-closed)
Err(DomainError::internal_error(
"FileManagement",
"Folder ownership verification unavailable",
))
}
let uuid = Uuid::parse_str(file_id).map_err(|_| DomainError::not_found("File", file_id))?;
self.authz
.require(Subject::User(caller_id), perm, Resource::File(uuid))
.await
}
}
impl FileManagementUseCase for FileManagementService {
/// Engine check for a target folder. `None` is allowed (root namespace,
/// implicitly owned by the caller).
async fn require_target_folder_perm(
&self,
folder_id: Option<&str>,
perm: Permission,
caller_id: Uuid,
) -> Result<(), DomainError> {
let Some(target) = folder_id else {
return Ok(());
};
let uuid = Uuid::parse_str(target).map_err(|_| DomainError::not_found("Folder", target))?;
self.authz
.require(Subject::User(caller_id), perm, Resource::Folder(uuid))
.await
}
//impl FileManagementPrivateUseCase for FileManagementService {
async fn move_file(
&self,
file_id: &str,
@@ -142,20 +123,6 @@ impl FileManagementUseCase for FileManagementService {
Ok(FileDto::from(moved_file))
}
async fn move_file_owned(
&self,
file_id: &str,
caller_id: Uuid,
folder_id: Option<String>,
) -> Result<FileDto, DomainError> {
// Verify file ownership first
self.verify_owner(file_id, caller_id).await?;
// Verify target folder ownership (prevents file from "disappearing")
self.verify_target_folder_owner(&folder_id, caller_id)
.await?;
self.move_file(file_id, folder_id).await
}
async fn copy_file(
&self,
file_id: &str,
@@ -185,18 +152,6 @@ impl FileManagementUseCase for FileManagementService {
Ok(FileDto::from(copied_file))
}
async fn copy_file_owned(
&self,
file_id: &str,
caller_id: Uuid,
target_folder_id: Option<String>,
) -> Result<FileDto, DomainError> {
self.verify_owner(file_id, caller_id).await?;
self.verify_target_folder_owner(&target_folder_id, caller_id)
.await?;
self.copy_file(file_id, target_folder_id).await
}
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<FileDto, DomainError> {
if let Err(reason) = validate_storage_name(new_name) {
return Err(DomainError::validation_error(format!(
@@ -224,76 +179,17 @@ impl FileManagementUseCase for FileManagementService {
Ok(FileDto::from(renamed_file))
}
async fn rename_file_owned(
&self,
file_id: &str,
caller_id: Uuid,
new_name: &str,
) -> Result<FileDto, DomainError> {
self.verify_owner(file_id, caller_id).await?;
self.rename_file(file_id, new_name).await
}
async fn delete_file(&self, id: &str) -> Result<(), DomainError> {
self.file_repository.delete_file(id).await?;
if let Some(cc) = &self.content_cache {
cc.invalidate(id).await;
}
for hook in &self.file_deleted_hooks {
hook.on_file_deleted(id).await;
}
Ok(())
}
async fn delete_file_owned(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> {
self.verify_owner(id, caller_id).await?;
self.delete_file(id).await
}
/// Smart delete: trash-first with dedup reference cleanup.
///
/// Blob ref_count bookkeeping is handled entirely by the PG trigger
/// `trg_files_decrement_blob_ref` which fires on DELETE FROM storage.files.
/// We do NOT decrement here — trashing is a soft-delete (UPDATE, not DELETE)
/// so the blob must remain referenced until the file is permanently deleted.
async fn delete_with_cleanup(&self, id: &str, user_id: Uuid) -> Result<bool, DomainError> {
// Step 1: Try trash (soft delete — file row stays, blob stays referenced)
if let Some(trash) = &self.trash_service {
info!("Moving file to trash: {}", id);
match trash.move_to_trash(id, "file", user_id).await {
Ok(_) => {
info!("File successfully moved to trash: {}", id);
// Invalidate content cache — trashed files must not be served.
if let Some(cc) = &self.content_cache {
cc.invalidate(id).await;
}
// Do NOT decrement blob ref here — the file row still exists
// (is_trashed = TRUE). The trigger will decrement when the
// row is actually DELETEd during trash emptying.
return Ok(true); // trashed
}
Err(err) => {
error!("Could not move file to trash: {:?}", err);
warn!("Falling back to permanent delete");
// fall through
}
}
} else {
warn!("Trash service not available, using permanent delete");
}
// Step 2: Permanent delete — trigger handles blob ref_count
warn!("Permanently deleting file: {}", id);
self.file_repository.delete_file(id).await?;
if let Some(cc) = &self.content_cache {
cc.invalidate(id).await;
}
for hook in &self.file_deleted_hooks {
if let Some(hook) = &self.file_deleted_hook {
hook.on_file_deleted(id).await;
}
info!("File permanently deleted: {}", id);
Ok(false) // permanently deleted
Ok(())
}
async fn copy_folder_tree(
@@ -326,29 +222,122 @@ impl FileManagementUseCase for FileManagementService {
Ok(result)
}
}
async fn copy_folder_tree_owned(
impl FileManagementUseCase for FileManagementService {
async fn require_permission(
&self,
caller_id: Uuid,
permission: Permission,
file_id: &str,
) -> Result<(), DomainError> {
let uuid = Uuid::parse_str(file_id).map_err(|_| DomainError::not_found("File", file_id))?;
self.authz
.require(Subject::User(caller_id), permission, Resource::File(uuid))
.await
}
async fn move_file_with_perms(
&self,
file_id: &str,
caller_id: Uuid,
folder_id: Option<String>,
) -> Result<FileDto, DomainError> {
// Move = Update on the file + Create on the target folder (if any).
self.require_file_perm(file_id, Permission::Update, caller_id)
.await?;
self.require_target_folder_perm(folder_id.as_deref(), Permission::Create, caller_id)
.await?;
self.move_file(file_id, folder_id).await
}
async fn copy_file_with_perms(
&self,
file_id: &str,
caller_id: Uuid,
target_folder_id: Option<String>,
) -> Result<FileDto, DomainError> {
// Copy = Read on the source file + Create on the target folder.
self.require_file_perm(file_id, Permission::Read, caller_id)
.await?;
self.require_target_folder_perm(target_folder_id.as_deref(), Permission::Create, caller_id)
.await?;
self.copy_file(file_id, target_folder_id).await
}
async fn rename_file_with_perms(
&self,
file_id: &str,
caller_id: Uuid,
new_name: &str,
) -> Result<FileDto, DomainError> {
self.require_file_perm(file_id, Permission::Update, caller_id)
.await?;
self.rename_file(file_id, new_name).await
}
async fn delete_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> {
self.require_file_perm(id, Permission::Delete, caller_id)
.await?;
self.delete_file(id).await
}
/// Smart delete: trash-first with dedup reference cleanup.
///
/// Blob ref_count bookkeeping is handled entirely by the PG trigger
/// `trg_files_decrement_blob_ref` which fires on DELETE FROM storage.files.
/// We do NOT decrement here — trashing is a soft-delete (UPDATE, not DELETE)
/// so the blob must remain referenced until the file is permanently deleted.
async fn delete_and_cleanup_with_perms(
&self,
id: &str,
caller_id: Uuid,
) -> Result<bool, DomainError> {
self.require_file_perm(id, Permission::Delete, caller_id)
.await?;
// Step 1: Try trash (soft delete — file row stays, blob stays referenced)
if let Some(trash) = &self.trash_service {
info!("Moving file to trash: {}", id);
match trash.move_to_trash(id, "file", caller_id).await {
Ok(_) => {
info!("File successfully moved to trash: {}", id);
// Invalidate content cache — trashed files must not be served.
if let Some(cc) = &self.content_cache {
cc.invalidate(id).await;
}
// Do NOT decrement blob ref here — the file row still exists
// (is_trashed = TRUE). The trigger will decrement when the
// row is actually DELETEd during trash emptying.
return Ok(true); // trashed
}
Err(err) => {
error!("Could not move file to trash: {:?}", err);
warn!("Falling back to permanent delete");
// fall through
}
}
} else {
warn!("Trash service not available, using permanent delete");
}
// Step 2: Permanent delete — trigger handles blob ref_count
self.delete_file(id).await?;
Ok(false) // permanently deleted
}
async fn copy_folder_tree_with_perms(
&self,
source_folder_id: &str,
caller_id: Uuid,
target_parent_id: Option<String>,
dest_name: Option<String>,
) -> Result<CopyFolderTreeResult, DomainError> {
if let Some(folder_repo) = &self.folder_repo {
let owner = folder_repo.get_folder_user_id(source_folder_id).await?;
if owner != caller_id {
return Err(DomainError::not_found(
"Folder",
"Source folder not found or access denied",
));
}
} else {
return Err(DomainError::internal_error(
"FileManagement",
"Folder ownership verification unavailable",
));
}
self.verify_target_folder_owner(&target_parent_id, caller_id)
// copy_folder_tree = Read on the source folder + Create on the target parent.
self.require_target_folder_perm(Some(source_folder_id), Permission::Read, caller_id)
.await?;
self.require_target_folder_perm(target_parent_id.as_deref(), Permission::Create, caller_id)
.await?;
self.copy_folder_tree(source_folder_id, target_parent_id, dest_name)
.await
@@ -4,14 +4,17 @@ use std::pin::Pin;
use std::sync::Arc;
use crate::application::dtos::file_dto::FileDto;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::file_ports::{FileRetrievalUseCase, OptimizedFileContent};
use crate::application::ports::storage_ports::FileReadPort;
use crate::common::errors::DomainError;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::services::file_content_cache::FileContentCache;
use crate::infrastructure::services::image_transcode_service::{
ImageTranscodeService, OutputFormat,
};
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
use tracing::{debug, info};
use uuid::Uuid;
@@ -29,33 +32,77 @@ pub struct FileRetrievalService {
file_read: Arc<FileBlobReadRepository>,
content_cache: Option<Arc<FileContentCache>>,
transcode: Option<Arc<ImageTranscodeService>>,
authz: Option<Arc<PgAclEngine>>,
}
impl FileRetrievalService {
/// Backward-compatible constructor (simple pass-through).
/// Backward-compatible constructor (simple pass-through). Without the
/// authorization engine, the `*_owned`/`*_with_perms` methods fail closed.
/// Use `new_with_cache` in production.
pub fn new(file_repository: Arc<FileBlobReadRepository>) -> Self {
Self {
file_read: file_repository,
content_cache: None,
transcode: None,
authz: None,
}
}
/// Constructor for blob-storage model: read + content cache + transcode.
/// Constructor for blob-storage model: read + content cache + transcode +
/// ReBAC authorization.
pub fn new_with_cache(
file_read: Arc<FileBlobReadRepository>,
content_cache: Arc<FileContentCache>,
transcode: Arc<ImageTranscodeService>,
authz: Arc<PgAclEngine>,
) -> Self {
Self {
file_read,
content_cache: Some(content_cache),
transcode: Some(transcode),
authz: Some(authz),
}
}
// ── private helpers ──────────────────────────────────────────
/// Helper: require the caller has `perm` on the given file id.
/// Fail-closed if no engine was injected (stub/test path).
async fn require_file(
&self,
file_id: &str,
perm: Permission,
caller_id: Uuid,
) -> Result<(), DomainError> {
let authz = self.authz.as_ref().ok_or_else(|| {
DomainError::internal_error("FileRetrieval", "Authorization engine unavailable")
})?;
let uuid = Uuid::parse_str(file_id).map_err(|_| DomainError::not_found("File", file_id))?;
authz
.require(Subject::User(caller_id), perm, Resource::File(uuid))
.await
}
/// Engine check for a target folder. `None` is allowed (root namespace,
/// implicitly owned by the caller).
async fn require_target_folder_perm(
&self,
folder_id: Option<&str>,
perm: Permission,
caller_id: Uuid,
) -> Result<(), DomainError> {
let Some(target) = folder_id else {
return Ok(());
};
let authz = self.authz.as_ref().ok_or_else(|| {
DomainError::internal_error("FileRetrieval", "Authorization engine unavailable")
})?;
let uuid = Uuid::parse_str(target).map_err(|_| DomainError::not_found("Folder", target))?;
authz
.require(Subject::User(caller_id), perm, Resource::Folder(uuid))
.await
}
/// Try to transcode image content to WebP and return transcoded variant.
async fn try_transcode(
&self,
@@ -202,13 +249,19 @@ impl FileRetrievalUseCase for FileRetrievalService {
Ok(FileDto::from(file))
}
async fn get_file_owned(&self, id: &str, caller_id: Uuid) -> Result<FileDto, DomainError> {
let file = self.file_read.get_file_for_owner(id, caller_id).await?;
async fn get_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<FileDto, DomainError> {
self.require_file(id, Permission::Read, caller_id).await?;
let file = self.file_read.get_file(id).await?;
Ok(FileDto::from(file))
}
// FIXME no authorisation at all
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError> {
// Direct SQL lookup — O(folder_depth) queries instead of O(total_files)
// NOTE: This method does NOT perform any authorization check. Callers
// that surface its result to a user-driven request MUST resolve the
// file via get_file_owned afterwards, or call authz.require directly.
// (Tracked in the audit punch-list under "path-based lookups".)
if let Some(file) = self.file_read.find_file_by_path(path).await? {
return Ok(FileDto::from(file));
}
@@ -224,16 +277,24 @@ impl FileRetrievalUseCase for FileRetrievalService {
Ok(files.into_iter().map(FileDto::from).collect())
}
async fn list_files_owned(
async fn list_files_with_perms(
&self,
folder_id: Option<&str>,
owner_id: Uuid,
) -> Result<Vec<FileDto>, DomainError> {
let files = self
.file_read
.list_files_for_owner(folder_id, owner_id)
.await?;
Ok(files.into_iter().map(FileDto::from).collect())
if folder_id.is_some() {
// folder id is defined, check permissions
self.require_target_folder_perm(folder_id, Permission::Read, owner_id)
.await?;
self.list_files(folder_id).await
} else {
// no folder id, get owners's files' root
let files = self
.file_read
.list_files_for_owner(folder_id, owner_id)
.await?;
Ok(files.into_iter().map(FileDto::from).collect())
}
}
async fn get_file_stream(
@@ -243,12 +304,12 @@ impl FileRetrievalUseCase for FileRetrievalService {
self.file_read.get_file_stream(id).await
}
async fn get_file_stream_owned(
async fn get_file_stream_with_perms(
&self,
id: &str,
caller_id: Uuid,
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
self.file_read.verify_file_owner(id, caller_id).await?;
self.require_file(id, Permission::Read, caller_id).await?;
self.file_read.get_file_stream(id).await
}
@@ -265,14 +326,15 @@ impl FileRetrievalUseCase for FileRetrievalService {
.await
}
async fn get_file_optimized_owned(
async fn get_file_optimized_with_perms(
&self,
id: &str,
caller_id: Uuid,
accept_webp: bool,
prefer_original: bool,
) -> Result<(FileDto, OptimizedFileContent), DomainError> {
let file = self.file_read.get_file_for_owner(id, caller_id).await?;
self.require_file(id, Permission::Read, caller_id).await?;
let file = self.file_read.get_file(id).await?;
let dto = FileDto::from(file);
self.optimized_inner(id, dto, accept_webp, prefer_original)
.await
@@ -300,18 +362,18 @@ impl FileRetrievalUseCase for FileRetrievalService {
self.file_read.get_file_range_stream(id, start, end).await
}
async fn get_file_range_stream_owned(
async fn get_file_range_stream_with_perms(
&self,
id: &str,
caller_id: Uuid,
start: u64,
end: Option<u64>,
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
// Verify ownership first, then delegate to the unscoped stream
self.file_read.verify_file_owner(id, caller_id).await?;
self.require_file(id, Permission::Read, caller_id).await?;
self.file_read.get_file_range_stream(id, start, end).await
}
// TODO: check: no permission check
async fn stream_files_in_subtree(
&self,
folder_id: &str,
@@ -334,13 +396,24 @@ impl FileRetrievalUseCase for FileRetrievalService {
Ok(files.into_iter().map(FileDto::from).collect())
}
async fn list_files_batch_for_owner(
async fn list_files_batch_with_perms(
&self,
folder_id: Option<&str>,
owner_id: Uuid,
offset: i64,
limit: i64,
) -> Result<Vec<FileDto>, DomainError> {
if folder_id.is_some() {
// folder id is defined, check permissions
self.require_target_folder_perm(folder_id, Permission::Read, owner_id)
.await?;
let files = self
.file_read
.list_files_batch(folder_id, offset, limit)
.await?;
return Ok(files.into_iter().map(FileDto::from).collect());
}
let files = self
.file_read
.list_files_batch_for_owner(folder_id, owner_id, offset, limit)
@@ -6,11 +6,13 @@ use crate::application::services::file_retrieval_service::FileRetrievalService;
use crate::application::services::file_upload_service::FileUploadService;
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
/// Factory for creating file use case implementations
pub struct AppFileUseCaseFactory {
file_read_repository: Arc<FileBlobReadRepository>,
file_write_repository: Arc<FileBlobWriteRepository>,
authz: Arc<PgAclEngine>,
}
impl AppFileUseCaseFactory {
@@ -18,10 +20,12 @@ impl AppFileUseCaseFactory {
pub fn new(
file_read_repository: Arc<FileBlobReadRepository>,
file_write_repository: Arc<FileBlobWriteRepository>,
authz: Arc<PgAclEngine>,
) -> Self {
Self {
file_read_repository,
file_write_repository,
authz,
}
}
}
@@ -36,8 +40,13 @@ impl FileUseCaseFactory for AppFileUseCaseFactory {
}
fn create_file_management_use_case(&self) -> Arc<FileManagementService> {
Arc::new(FileManagementService::new(
Arc::new(FileManagementService::with_trash(
self.file_write_repository.clone(),
None,
Some(self.file_read_repository.clone()),
None,
None,
self.authz.clone(),
))
}
}
+221 -154
View File
@@ -1,23 +1,39 @@
use crate::application::dtos::folder_dto::{
CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto,
};
use crate::application::ports::inbound::FolderUseCase;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::common::errors::{DomainError, ErrorKind};
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::domain::services::path_service::{StoragePath, validate_storage_name};
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
use std::sync::Arc;
use uuid::Uuid;
/// Implementation of the use case for folder operations
pub struct FolderService {
folder_storage: Arc<FolderDbRepository>,
authz: Arc<PgAclEngine>,
}
impl FolderService {
/// Creates a new folder service
pub fn new(folder_storage: Arc<FolderDbRepository>) -> Self {
Self { folder_storage }
pub fn new(folder_storage: Arc<FolderDbRepository>, authz: Arc<PgAclEngine>) -> Self {
Self {
folder_storage,
authz,
}
}
/// Helper: parse a folder id string into a `Resource::Folder`. Returns
/// `DomainError::not_found` on parse error (anti-enumeration — the same
/// error as "folder does not exist").
fn folder_resource(id: &str) -> Result<Resource, DomainError> {
Uuid::parse_str(id)
.map(Resource::Folder)
.map_err(|_| DomainError::not_found("Folder", id))
}
/// Creates a stub implementation for testing and middleware
@@ -25,7 +41,19 @@ impl FolderService {
struct FolderServiceStub;
impl FolderUseCase for FolderServiceStub {
async fn create_folder(&self, _dto: CreateFolderDto) -> Result<FolderDto, DomainError> {
async fn require_permission(
&self,
_caller_id: Uuid,
_permission: Permission,
_folder_id: &str,
) -> Result<(), DomainError> {
Ok(())
}
async fn create_folder_with_perms(
&self,
_dto: CreateFolderDto,
_user_id: Uuid,
) -> Result<FolderDto, DomainError> {
Ok(FolderDto::empty())
}
@@ -33,7 +61,7 @@ impl FolderService {
Ok(FolderDto::empty())
}
async fn get_folder_owned(
async fn get_folder_with_perms(
&self,
_id: &str,
_caller_id: Uuid,
@@ -52,7 +80,7 @@ impl FolderService {
Ok(vec![])
}
async fn list_folders_for_owner(
async fn list_folders_with_perms(
&self,
_parent_id: Option<&str>,
_owner_id: Uuid,
@@ -78,7 +106,7 @@ impl FolderService {
)
}
async fn list_folders_for_owner_paginated(
async fn list_folders_paginated_with_perms(
&self,
_parent_id: Option<&str>,
_owner_id: Uuid,
@@ -97,7 +125,7 @@ impl FolderService {
)
}
async fn rename_folder(
async fn rename_folder_with_perms(
&self,
_id: &str,
_dto: RenameFolderDto,
@@ -106,7 +134,7 @@ impl FolderService {
Ok(FolderDto::empty())
}
async fn move_folder(
async fn move_folder_with_perms(
&self,
_id: &str,
_dto: MoveFolderDto,
@@ -115,7 +143,11 @@ impl FolderService {
Ok(FolderDto::empty())
}
async fn delete_folder(&self, _id: &str, _caller_id: Uuid) -> Result<(), DomainError> {
async fn delete_folder_with_perms(
&self,
_id: &str,
_caller_id: Uuid,
) -> Result<(), DomainError> {
Ok(())
}
@@ -133,9 +165,34 @@ impl FolderService {
}
impl FolderUseCase for FolderService {
/// Verifies the caller has the given permition on a resource
/// `folder_id`. `None` is the caller's root namespace and always allowed.
///
/// Returns `Ok(())` when permitted, `DomainError::not_found(...)` when not
/// (anti-enumeration — same error as "folder doesn't exist").
///
/// Used by handlers that need a fail-fast pre-check BEFORE spooling
/// large request bodies (file upload, chunked upload). The authoritative
/// check happens again inside the upload/management services before any
/// DB write — this is a UX/resource optimization, not a security boundary.
async fn require_permission(
&self,
caller_id: Uuid,
permission: Permission,
folder_id: &str,
) -> Result<(), DomainError> {
let resource = Self::folder_resource(folder_id)?;
self.authz
.require(Subject::User(caller_id), permission, resource)
.await
}
/// Creates a new folder
async fn create_folder(&self, dto: CreateFolderDto) -> Result<FolderDto, DomainError> {
// Input validation
async fn create_folder_with_perms(
&self,
dto: CreateFolderDto,
caller_id: Uuid,
) -> Result<FolderDto, DomainError> {
if let Err(reason) = validate_storage_name(&dto.name) {
return Err(DomainError::validation_error(format!(
"Invalid folder name '{}': {reason}",
@@ -143,21 +200,24 @@ impl FolderUseCase for FolderService {
)));
}
// If a parent_id is provided, verify it exists
if let Some(parent_id) = &dto.parent_id {
let parent_exists = self.folder_storage.get_folder(parent_id).await.is_ok();
if !parent_exists {
return Err(DomainError::not_found("Folder", parent_id));
}
}
let Some(parent_id) = dto.parent_id.as_deref() else {
return Err(DomainError::validation_error(
"Root folder creation is reserved for registration",
));
};
let parent_resource = Self::folder_resource(parent_id)?;
self.authz
.require(
Subject::User(caller_id),
Permission::Create,
parent_resource,
)
.await?;
// Create the folder
let folder = self
.folder_storage
.create_folder(dto.name, dto.parent_id)
.await?;
// Convert to DTO
Ok(FolderDto::from(folder))
}
@@ -198,19 +258,21 @@ impl FolderUseCase for FolderService {
Ok(FolderDto::from(folder))
}
/// Gets a folder by its ID, enforcing that `caller_id` is the owner.
async fn get_folder_owned(&self, id: &str, caller_id: Uuid) -> Result<FolderDto, DomainError> {
let folder_dto = self.get_folder(id).await?;
if folder_dto.owner_id.as_deref() != Some(&caller_id.to_string()) {
tracing::warn!(
"get_folder_owned: user '{}' attempted to access folder '{}' owned by '{:?}'",
caller_id,
id,
folder_dto.owner_id
);
return Err(DomainError::not_found("Folder", id));
}
Ok(folder_dto)
/// Gets a folder by its ID, enforcing that `caller_id` has `Read` access
/// (via ownership or a grant — including cascading from ancestor folders).
async fn get_folder_with_perms(
&self,
id: &str,
caller_id: Uuid,
) -> Result<FolderDto, DomainError> {
self.authz
.require(
Subject::User(caller_id),
Permission::Read,
Self::folder_resource(id)?,
)
.await?;
self.get_folder(id).await
}
/// Gets a folder by its path
@@ -239,6 +301,7 @@ impl FolderUseCase for FolderService {
.list_folders(parent_id)
.await
.map_err(|e| {
tracing::warn!("errror while fetching folders {}", e);
DomainError::internal_error(
"FolderStorage",
format!("Failed to list folders in parent: {:?}: {}", parent_id, e),
@@ -251,59 +314,77 @@ impl FolderUseCase for FolderService {
/// Lists folders scoped to a specific owner.
/// Self-healing: if listing root folders and none exist, creates a home folder.
async fn list_folders_for_owner(
async fn list_folders_with_perms(
&self,
parent_id: Option<&str>,
owner_id: Uuid,
caller_id: Uuid,
) -> Result<Vec<FolderDto>, DomainError> {
let owner_id_short = {
let s = owner_id.to_string();
s[..8.min(s.len())].to_string()
};
let folders = self
.folder_storage
.list_folders_by_owner(parent_id, owner_id)
.await
.map_err(|e| {
DomainError::internal_error(
"FolderStorage",
format!(
"Failed to list folders for owner '{}' in parent {:?}: {}",
owner_id, parent_id, e
),
if let Some(parent_id_unwrapped) = parent_id {
// check authorisation
self.authz
.require(
Subject::User(caller_id),
Permission::Read,
Self::folder_resource(parent_id_unwrapped)?,
)
})?;
// Self-healing: if listing root folders and none exist, create a home folder
// This ensures the frontend always gets a valid userHomeFolderId
if parent_id.is_none() && folders.is_empty() {
tracing::info!(
"No root folders found for user {}, creating home folder automatically",
owner_id
);
let folder_name = format!("My Folder - {}", owner_id_short);
match self
.await?;
return self.list_folders(parent_id).await;
} else {
// No parent defined grab user's homes
let folders = self
.folder_storage
.create_home_folder(owner_id, folder_name.clone())
.list_folders_by_owner(parent_id, caller_id)
.await
{
Ok(home_folder) => {
tracing::info!(
"Created home folder '{}' for user {}",
folder_name,
owner_id
);
return Ok(vec![FolderDto::from(home_folder)]);
}
Err(e) => {
tracing::warn!("Failed to create home folder for user {}: {}", owner_id, e);
// Return empty list rather than failing - user might not have storage quota, etc.
.map_err(|e| {
DomainError::internal_error(
"FolderStorage",
format!(
"Failed to list folders for owner '{}' in parent {:?}: {}",
caller_id, parent_id, e
),
)
})?;
if folders.is_empty() {
// Self-healing: if listing root folders and none exist, create a home folder
// This ensures the frontend always gets a valid userHomeFolderId
tracing::info!(
"No root folders found for user {}, creating home folder automatically",
caller_id
);
let owner_id_short = {
let s = caller_id.to_string();
s[..8.min(s.len())].to_string()
};
// TODO: what about i18n ?
let folder_name = format!("My Folder - {}", owner_id_short);
match self
.folder_storage
.create_home_folder(caller_id, folder_name.clone())
.await
{
Ok(home_folder) => {
tracing::info!(
"Created home folder '{}' for user {}",
folder_name,
caller_id
);
return Ok(vec![FolderDto::from(home_folder)]);
}
Err(e) => {
tracing::warn!(
"Failed to create home folder for user {}: {}",
caller_id,
e
);
// Return empty list rather than failing - user might not have storage quota, etc.
}
}
}
Ok(folders.into_iter().map(FolderDto::from).collect())
}
Ok(folders.into_iter().map(FolderDto::from).collect())
}
// TODO: move self healing in other part (on account creation on or login ?)
/// Lists folders with pagination
async fn list_folders_paginated(
@@ -341,7 +422,7 @@ impl FolderUseCase for FolderService {
}
/// Lists folders with pagination, scoped to a specific owner.
async fn list_folders_for_owner_paginated(
async fn list_folders_paginated_with_perms(
&self,
parent_id: Option<&str>,
owner_id: Uuid,
@@ -350,7 +431,17 @@ impl FolderUseCase for FolderService {
{
let pagination = pagination.validate_and_adjust();
let (folders, total_items) = self
if let Some(parent_id_unwrapped) = parent_id {
self.authz
.require(
Subject::User(owner_id),
Permission::Read,
Self::folder_resource(parent_id_unwrapped)?,
)
.await?;
return self.list_folders_paginated(parent_id, &pagination).await;
} else {
let (folders, total_items) = self
.folder_storage
.list_folders_by_owner_paginated(
parent_id,
@@ -370,26 +461,26 @@ impl FolderUseCase for FolderService {
)
})?;
let total = total_items.unwrap_or(folders.len());
let total = total_items.unwrap_or(folders.len());
let response = crate::application::dtos::pagination::PaginatedResponseDto::new(
folders.into_iter().map(FolderDto::from).collect(),
pagination.page,
pagination.page_size,
total,
);
let response = crate::application::dtos::pagination::PaginatedResponseDto::new(
folders.into_iter().map(FolderDto::from).collect(),
pagination.page,
pagination.page_size,
total,
);
Ok(response)
Ok(response)
}
}
/// Renames a folder after verifying ownership.
async fn rename_folder(
/// Renames a folder after verifying the caller has `Update` permission.
async fn rename_folder_with_perms(
&self,
id: &str,
dto: RenameFolderDto,
caller_id: Uuid,
) -> Result<FolderDto, DomainError> {
// Input validation
if let Err(reason) = validate_storage_name(&dto.name) {
return Err(DomainError::validation_error(format!(
"Invalid folder name '{}': {reason}",
@@ -397,20 +488,14 @@ impl FolderUseCase for FolderService {
)));
}
// Verify the folder exists and belongs to the caller
let existing_folder = self.folder_storage.get_folder(id).await?;
self.authz
.require(
Subject::User(caller_id),
Permission::Update,
Self::folder_resource(id)?,
)
.await?;
if existing_folder.owner_id() != Some(caller_id) {
tracing::warn!(
"rename_folder: user '{}' attempted to rename folder '{}' owned by '{:?}'",
caller_id,
id,
existing_folder.owner_id()
);
return Err(DomainError::not_found("Folder", id));
}
// Rename folder — UPDATE RETURNING gives us the updated row directly
let folder = self
.folder_storage
.rename_folder(id, dto.name)
@@ -425,29 +510,25 @@ impl FolderUseCase for FolderService {
Ok(FolderDto::from(folder))
}
/// Moves a folder to a new parent after verifying ownership.
async fn move_folder(
/// Moves a folder to a new parent. Requires `Update` on the source and
/// `Create` on the destination parent (if any).
async fn move_folder_with_perms(
&self,
id: &str,
dto: MoveFolderDto,
caller_id: Uuid,
) -> Result<FolderDto, DomainError> {
// Verify the source folder exists and belongs to the caller
let source_folder = self.folder_storage.get_folder(id).await?;
let source_resource = Self::folder_resource(id)?;
self.authz
.require(
Subject::User(caller_id),
Permission::Update,
source_resource,
)
.await?;
if source_folder.owner_id() != Some(caller_id) {
tracing::warn!(
"move_folder: user '{}' attempted to move folder '{}' owned by '{:?}'",
caller_id,
id,
source_folder.owner_id()
);
return Err(DomainError::not_found("Folder", id));
}
// If a parent_id is specified, verify it exists and belongs to the caller
if let Some(parent_id) = &dto.parent_id {
// Verify we are not trying to move the folder into itself or one of its descendants
// Cannot move a folder into itself (cycle guard).
if parent_id == id {
return Err(DomainError::new(
ErrorKind::InvalidInput,
@@ -455,27 +536,17 @@ impl FolderUseCase for FolderService {
"Cannot move a folder into itself",
));
}
// Verify the destination exists and is owned by the caller
let parent = self
.folder_storage
.get_folder(parent_id)
.await
.map_err(|_| DomainError::not_found("Folder", parent_id))?;
if parent.owner_id() != Some(caller_id) {
tracing::warn!(
"move_folder: user '{}' attempted to move into folder '{}' owned by '{:?}'",
caller_id,
parent_id,
parent.owner_id()
);
return Err(DomainError::not_found("Folder", parent_id));
}
// TODO: Ideally we should verify the entire hierarchy to prevent cycles
let parent_resource = Self::folder_resource(parent_id)?;
self.authz
.require(
Subject::User(caller_id),
Permission::Create,
parent_resource,
)
.await?;
// TODO: full descendant-cycle check (moving a folder into one of its own descendants)
}
// Move folder — UPDATE RETURNING gives us the updated row directly
let parent_ref = dto.parent_id.as_deref();
let folder = self
.folder_storage
@@ -491,22 +562,18 @@ impl FolderUseCase for FolderService {
Ok(FolderDto::from(folder))
}
/// Deletes a folder after verifying ownership.
async fn delete_folder(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> {
// Verify the folder exists and belongs to the caller
let folder = self.folder_storage.get_folder(id).await?;
/// Deletes a folder after verifying the caller has `Delete` permission.
/// The DB trigger `trg_cleanup_grants_folder` cleans up `access_grants`
/// rows targeting the deleted folder automatically.
async fn delete_folder_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> {
self.authz
.require(
Subject::User(caller_id),
Permission::Delete,
Self::folder_resource(id)?,
)
.await?;
if folder.owner_id() != Some(caller_id) {
tracing::warn!(
"delete_folder: user '{}' attempted to delete folder '{}' owned by '{:?}'",
caller_id,
id,
folder.owner_id()
);
return Err(DomainError::not_found("Folder", id));
}
// Delete the folder
self.folder_storage.delete_folder(id).await.map_err(|e| {
DomainError::internal_error(
"FolderStorage",
@@ -362,7 +362,7 @@ async fn stub_move_file_owned_returns_ok() {
let user_id = Uuid::new_v4();
let stub = StubFileManagementUseCase;
let result = stub
.move_file_owned("file-1", user_id, Some("folder-2".to_string()))
.move_file_with_perms("file-1", user_id, Some("folder-2".to_string()))
.await;
assert!(result.is_ok(), "stub should return Ok for move_file_owned");
}
@@ -372,7 +372,7 @@ async fn stub_rename_file_owned_returns_ok() {
let user_id = Uuid::new_v4();
let stub = StubFileManagementUseCase;
let result = stub
.rename_file_owned("file-1", user_id, "new-name.txt")
.rename_file_with_perms("file-1", user_id, "new-name.txt")
.await;
assert!(
result.is_ok(),
@@ -387,7 +387,7 @@ use crate::common::stubs::StubFileRetrievalUseCase;
async fn stub_get_file_owned_returns_ok() {
let user_id = Uuid::new_v4();
let stub = StubFileRetrievalUseCase;
let result = stub.get_file_owned("file-1", user_id).await;
let result = stub.get_file_with_perms("file-1", user_id).await;
assert!(result.is_ok(), "stub should return Ok for get_file_owned");
}
@@ -396,7 +396,7 @@ async fn stub_get_file_optimized_owned_returns_ok() {
let user_id = Uuid::new_v4();
let stub = StubFileRetrievalUseCase;
let result = stub
.get_file_optimized_owned("file-1", user_id, true, false)
.get_file_optimized_with_perms("file-1", user_id, true, false)
.await;
assert!(
result.is_ok(),
+1
View File
@@ -6,6 +6,7 @@ pub mod calendar_service;
pub mod contact_service;
pub mod device_auth_service;
pub mod favorites_service;
pub mod file_lifecycle_service;
pub mod file_management_service;
pub mod file_retrieval_service;
pub mod file_upload_service;
@@ -6,7 +6,7 @@ use uuid::Uuid;
use crate::application::dtos::folder_listing_dto::FolderListingDto;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::inbound::FolderUseCase;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::services::file_retrieval_service::FileRetrievalService;
use crate::application::services::folder_service::FolderService;
use crate::application::services::share_service::ShareService;
@@ -179,9 +179,9 @@ impl ShareBrowseService {
) -> Result<FolderListingDto, DomainError> {
let (folders_res, files_res) = tokio::join!(
self.folder_service
.list_folders_for_owner(Some(parent_folder_id), owner_id),
.list_folders_with_perms(Some(parent_folder_id), owner_id),
self.file_retrieval
.list_files_owned(Some(parent_folder_id), owner_id),
.list_files_with_perms(Some(parent_folder_id), owner_id),
);
Ok(FolderListingDto {
folders: folders_res?,
+62 -90
View File
@@ -6,19 +6,22 @@ use crate::application::dtos::display_helpers::{
category_for, icon_class_for, icon_special_class_for,
};
use crate::application::dtos::trash_dto::TrashedItemDto;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::file_lifecycle::FileDeletedHook;
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::repositories::trash_repository::TrashRepository;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbRepository;
use crate::infrastructure::services::dedup_service::DedupService;
use crate::infrastructure::services::file_content_cache::FileContentCache;
use crate::infrastructure::services::thumbnail_service::ThumbnailService;
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
/**
* Application service for trash operations.
@@ -50,12 +53,15 @@ pub struct TrashService {
/// orphaned blob files and thumbnails that the PG trigger cannot reach.
dedup_service: Arc<DedupService>,
/// Thumbnail service for cleaning up thumbnails on permanent delete
thumbnail_service: Option<Arc<ThumbnailService>>,
/// Hook fired after a file is permanently deleted (typically the FileLifecycleService composite).
file_deleted_hook: Option<Arc<dyn FileDeletedHook>>,
/// Content cache — invalidated when files are permanently deleted from trash.
content_cache: Option<Arc<FileContentCache>>,
/// Authz engine
authz: Arc<PgAclEngine>,
/// Number of days items should be kept in trash before automatic cleanup
retention_days: u32,
}
@@ -69,8 +75,8 @@ impl TrashService {
folder_storage_port: Arc<FolderDbRepository>,
retention_days: u32,
dedup_service: Arc<DedupService>,
thumbnail_service: Option<Arc<ThumbnailService>>,
content_cache: Option<Arc<FileContentCache>>,
authz: Arc<PgAclEngine>,
) -> Self {
Self {
trash_repository,
@@ -78,12 +84,19 @@ impl TrashService {
file_write_port,
folder_storage_port,
dedup_service,
thumbnail_service,
file_deleted_hook: None,
content_cache,
authz,
retention_days,
}
}
/// Sets the lifecycle hook fired after a file is permanently deleted.
pub fn with_file_deleted_hook(mut self, hook: Arc<dyn FileDeletedHook>) -> Self {
self.file_deleted_hook = Some(hook);
self
}
/// Converts a TrashedItem entity to a DTO
fn to_dto(&self, item: TrashedItem) -> TrashedItemDto {
// Calculate days_until_deletion before moving item fields
@@ -122,46 +135,6 @@ impl TrashService {
icon_special_class,
}
}
/// Validates that the given user owns the trashed item.
/// Returns an error if the item does not exist or belongs to a different user.
#[instrument(skip(self))]
async fn _validate_user_ownership(&self, item_id: &str, user_id: &str) -> Result<()> {
let item_uuid = Uuid::parse_str(item_id)
.map_err(|e| DomainError::validation_error(format!("Invalid item ID: {}", e)))?;
let user_uuid = Uuid::parse_str(user_id)
.map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?;
match self
.trash_repository
.get_trash_item(&item_uuid, &user_uuid)
.await?
{
Some(item) => {
if item.user_id() != user_uuid {
error!(
"User {} attempted to access trash item {} owned by {}",
user_id,
item_id,
item.user_id()
);
return Err(DomainError::access_denied(
"TrashItem",
"You do not have permission to access this trash item",
));
}
Ok(())
}
None => {
// Item not found for this user — treat as authorization error
// to avoid leaking existence information
Err(DomainError::not_found(
"TrashItem",
format!("{} (user: {})", item_id, user_id),
))
}
}
}
}
impl TrashUseCase for TrashService {
@@ -176,6 +149,7 @@ impl TrashUseCase for TrashService {
Ok(dtos)
}
// TODO: change item_type into Resource enum
#[instrument(skip(self))]
async fn move_to_trash(&self, item_id: &str, item_type: &str, user_id: Uuid) -> Result<()> {
info!(
@@ -209,15 +183,21 @@ impl TrashUseCase for TrashService {
"file" => {
info!("Processing file to move to trash: {}", item_id);
// Get the file — ownership-verified at SQL level.
// Returns NotFound if the file does not exist OR belongs to
// another user, preventing cross-user trash operations.
debug!("Getting file data (owner-scoped): {}", item_id);
let file = match self
.file_read_port
.get_file_for_owner(item_id, user_id)
.await
{
let file_id = Uuid::parse_str(item_id)
.map_err(|_| DomainError::not_found("File", item_id))?;
self.authz
.require(
Subject::User(user_id),
Permission::Delete,
Resource::File(file_id),
)
.await?;
// Authz already passed — use the non-owner-scoped read so that
// grantees with Delete permission can trash files they don't own.
// The file's user_id in storage.files is unchanged, so the item
// will appear in the original owner's trash view.
let file = match self.file_read_port.get_file(item_id).await {
Ok(file) => {
debug!("File found: {} ({})", file.name(), item_id);
file
@@ -235,7 +215,6 @@ impl TrashUseCase for TrashService {
let original_path = file.storage_path().to_string();
debug!("Original file path: {}", original_path);
// Create the trash item
debug!("Creating TrashedItem object for the file");
let trashed_item = TrashedItem::new(
item_uuid,
@@ -286,9 +265,17 @@ impl TrashUseCase for TrashService {
Ok(())
}
"folder" => {
// Get the folder and verify ownership.
// Returns NotFound if the folder does not exist or belongs
// to another user — prevents cross-user trash operations.
// check deletion permition
let folder_id = Uuid::parse_str(item_id)
.map_err(|_| DomainError::not_found("Folder", item_id))?;
self.authz
.require(
Subject::User(user_id),
Permission::Delete,
Resource::Folder(folder_id),
)
.await?;
let folder = self
.folder_storage_port
.get_folder(item_id)
@@ -301,18 +288,8 @@ impl TrashUseCase for TrashService {
)
})?;
// Ownership check — return NotFound (not Forbidden) to
// prevent leaking whether the folder exists.
if folder.owner_id() != Some(user_id) {
return Err(DomainError::not_found(
"Folder",
format!("Folder not found: {}", item_id),
));
}
let original_path = folder.storage_path().to_string();
// Create the trash item
let trashed_item = TrashedItem::new(
item_uuid,
user_uuid,
@@ -605,12 +582,8 @@ impl TrashUseCase for TrashService {
}
}
// Best-effort thumbnail cleanup — thumbnails are cache
// artifacts, so failure must not block file deletion.
if let Some(thumb) = &self.thumbnail_service
&& let Err(e) = thumb.delete_thumbnails(&file_id).await
{
warn!("Failed to delete thumbnails for file {}: {}", file_id, e);
if let Some(hook) = &self.file_deleted_hook {
hook.on_file_deleted(&file_id).await;
}
}
TrashedItemType::Folder => {
@@ -702,18 +675,20 @@ impl TrashUseCase for TrashService {
async fn empty_trash(&self, user_id: Uuid) -> Result<()> {
info!("Emptying trash for user {}", user_id);
// Collect trashed file IDs BEFORE bulk-deleting so we can clean up
// their thumbnails afterward. This is best-effort — if the query
// fails we still proceed with the bulk delete.
let trashed_file_ids: Vec<String> = if self.thumbnail_service.is_some() {
match self.trash_repository.get_trash_items(&user_id).await {
Ok(items) => items
.iter()
.filter(|i| matches!(i.item_type(), TrashedItemType::File))
.map(|i| i.original_id().to_string())
.collect(),
// Collect ALL trashed file IDs BEFORE bulk-deleting so hooks (thumbnail
// cleanup, etc.) can run afterward. We use get_all_trashed_file_ids (not
// get_trash_items) because the trash_items view excludes files inside a
// trashed folder — those files will still be deleted by clear_trash via
// the folder CASCADE, but their hooks would otherwise be missed.
let trashed_file_ids: Vec<String> = if self.file_deleted_hook.is_some() {
match self
.trash_repository
.get_all_trashed_file_ids(&user_id)
.await
{
Ok(ids) => ids,
Err(e) => {
warn!("Could not list trashed items for thumbnail cleanup: {}", e);
warn!("Could not list trashed files for hook cleanup: {}", e);
Vec::new()
}
}
@@ -746,12 +721,9 @@ impl TrashUseCase for TrashService {
}
}
// Best-effort thumbnail cleanup for all deleted files
if let Some(thumb) = &self.thumbnail_service {
if let Some(hook) = &self.file_deleted_hook {
for file_id in &trashed_file_ids {
if let Err(e) = thumb.delete_thumbnails(file_id).await {
warn!("Failed to delete thumbnails for file {}: {}", file_id, e);
}
hook.on_file_deleted(file_id).await;
}
}
@@ -402,6 +402,11 @@ impl TrashRepository for MockTrashRepository {
Ok(())
}
async fn get_all_trashed_file_ids(&self, _user_id: &Uuid) -> Result<Vec<String>> {
let files = self.trashed_files.lock().unwrap();
Ok(files.keys().cloned().collect())
}
async fn delete_expired_bulk(&self) -> Result<(u64, u64)> {
let mut items = self.trash_items.lock().unwrap();
let now = Utc::now();
+84 -17
View File
@@ -38,11 +38,13 @@ use crate::infrastructure::services::file_content_cache::{
use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nService;
use crate::infrastructure::services::nextcloud_chunked_upload_service::NextcloudChunkedUploadService;
use crate::infrastructure::services::path_service::PathService;
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService;
use crate::application::services::app_password_service::AppPasswordService;
use crate::application::services::calendar_service::CalendarService;
use crate::application::services::device_auth_service::DeviceAuthService;
use crate::application::services::file_lifecycle_service::FileLifecycleService;
use crate::application::services::music_service::MusicService;
use crate::application::services::storage_usage_service::StorageUsageService;
use crate::application::services::wopi_lock_service::WopiLockService;
@@ -273,10 +275,14 @@ impl AppServiceFactory {
"Core services initialized: path service, file content cache, thumbnails, chunked upload, image transcode, dedup (PRIMARY blob storage)"
);
let file_lifecycle =
Arc::new(FileLifecycleService::new().with_deleted_hook(thumbnail_service.clone()));
Ok(CoreServices {
path_service,
file_content_cache,
thumbnail_service,
file_lifecycle,
chunked_upload_service,
image_transcode_service,
dedup_service,
@@ -350,9 +356,13 @@ impl AppServiceFactory {
repos: &RepositoryServices,
trash_service: Option<Arc<TrashService>>,
db_pool: &Arc<PgPool>,
authz: &Arc<PgAclEngine>,
) -> ApplicationServices {
// Main services
let folder_service = Arc::new(FolderService::new(repos.folder_repository.clone()));
let folder_service = Arc::new(FolderService::new(
repos.folder_repository.clone(),
authz.clone(),
));
// Refactored services with all infrastructure ports
// In blob model, dedup is handled by the repository — no separate write-behind needed
@@ -374,6 +384,7 @@ impl AppServiceFactory {
repos.file_read_repository.clone(),
core.file_content_cache.clone(),
core.image_transcode_service.clone(),
authz.clone(),
));
// FileManagementService — ref_count handled by PG trigger, no dedup port needed
@@ -384,13 +395,15 @@ impl AppServiceFactory {
Some(repos.file_read_repository.clone()),
Some(repos.folder_repository.clone()),
Some(core.file_content_cache.clone()),
authz.clone(),
)
.with_file_deleted_hook(core.thumbnail_service.clone()),
.with_file_deleted_hook(core.file_lifecycle.clone()),
);
let file_use_case_factory = Arc::new(AppFileUseCaseFactory::new(
repos.file_read_repository.clone(),
repos.file_write_repository.clone(),
authz.clone(),
));
let i18n_service = Arc::new(I18nApplicationService::new(repos.i18n_repository.clone()));
@@ -445,6 +458,7 @@ impl AppServiceFactory {
&self,
repos: &RepositoryServices,
core: &CoreServices,
authz: &Arc<PgAclEngine>,
) -> Option<Arc<TrashService>> {
if !self.config.features.enable_trash {
tracing::info!("Trash service is disabled in configuration");
@@ -454,16 +468,19 @@ impl AppServiceFactory {
let trash_repo = repos.trash_repository.as_ref()?;
// Wire ports directly to TrashService — no adapter layer needed
let service = Arc::new(TrashService::new(
trash_repo.clone(),
repos.file_read_repository.clone(),
repos.file_write_repository.clone(),
repos.folder_repository.clone(),
self.config.storage.trash_retention_days,
core.dedup_service.clone(),
Some(core.thumbnail_service.clone()),
Some(core.file_content_cache.clone()),
));
let service = Arc::new(
TrashService::new(
trash_repo.clone(),
repos.file_read_repository.clone(),
repos.file_write_repository.clone(),
repos.folder_repository.clone(),
self.config.storage.trash_retention_days,
core.dedup_service.clone(),
Some(core.file_content_cache.clone()),
authz.clone(),
)
.with_file_deleted_hook(core.file_lifecycle.clone()),
);
// Initialize cleanup service (bulk-deletes expired items in 2 SQL queries)
let cleanup_service = TrashCleanupService::new(
@@ -602,12 +619,27 @@ impl AppServiceFactory {
// 2. Repository services (requires PgPool for all metadata)
let repos = self.create_repository_services(&core, &pool);
// 3. Trash service (needed before application services)
let trash_service = self.create_trash_service(&repos, &core).await;
// 3a. Authorization engine — must exist before application services
// because services hold an Arc<PgAclEngine> for ReBAC checks.
let authorization = build_authorization_engine(
pool.clone(),
repos.folder_repository.clone(),
repos.file_read_repository.clone(),
);
// 4. Application services (with trash already wired)
let mut apps =
self.create_application_services(&core, &repos, trash_service.clone(), &pool);
// 3b. Trash service (needed before application services)
let trash_service = self
.create_trash_service(&repos, &core, &authorization)
.await;
// 4. Application services (with trash + authz already wired)
let mut apps = self.create_application_services(
&core,
&repos,
trash_service.clone(),
&pool,
&authorization,
);
// 5. Share service
let share_service = self.create_share_service(&repos, &pool);
@@ -777,6 +809,7 @@ impl AppServiceFactory {
path_resolver: None,
webdav_lock_store:
crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(),
authorization,
};
// 9b. Wire admin settings service when auth is available
@@ -995,6 +1028,8 @@ pub struct CoreServices {
pub path_service: Arc<PathService>,
pub file_content_cache: Arc<FileContentCache>,
pub thumbnail_service: Arc<ThumbnailService>,
/// Composite lifecycle dispatcher — register new permanent-delete hooks here only.
pub file_lifecycle: Arc<FileLifecycleService>,
pub chunked_upload_service: Arc<ChunkedUploadService>,
pub image_transcode_service: Arc<ImageTranscodeService>,
pub dedup_service: Arc<DedupService>,
@@ -1092,6 +1127,38 @@ pub struct AppState {
Option<Arc<crate::infrastructure::services::path_resolver_service::PathResolverService>>,
pub webdav_lock_store:
Arc<crate::infrastructure::services::webdav_lock_service::WebDavLockStore>,
/// ReBAC authorization engine — all service-layer permission checks go
/// through this. Concrete type today is `PgAclEngine`; the
/// `AuthorizationEngine` trait describes the contract. When alternate
/// implementations land (OpenFGA, cached decorator), swap this field for
/// an enum dispatcher or `Arc<dyn AuthorizationEngine>` (with
/// `async_trait` boxing).
pub authorization: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
}
// All AppState construction is done via struct literal in build_app_state().
/// Builds the authorization engine. Today this only constructs `PgAclEngine`;
/// the `OXICLOUD_AUTHZ_ENGINE` env var is reserved for future alternate
/// implementations (e.g. `openfga`).
fn build_authorization_engine(
pool: Arc<PgPool>,
folder_repo: Arc<
crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository,
>,
file_repo: Arc<
crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository,
>,
) -> Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine> {
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
if let Ok(other) = std::env::var("OXICLOUD_AUTHZ_ENGINE")
&& other != "postgres"
&& !other.is_empty()
{
panic!(
"OXICLOUD_AUTHZ_ENGINE={other:?} is not yet supported. Only 'postgres' is implemented; leave the variable unset to use the default."
);
}
Arc::new(PgAclEngine::new(pool, folder_repo, file_repo))
}
+52 -39
View File
@@ -25,13 +25,16 @@ use crate::application::dtos::search_dto::{
use crate::application::ports::file_ports::{
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, OptimizedFileContent,
};
use crate::application::ports::inbound::{FolderUseCase, SearchUseCase};
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::inbound::SearchUseCase;
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
use crate::application::ports::zip_ports::ZipPort;
use crate::common::errors::DomainError;
use crate::domain::entities::file::File;
use crate::domain::entities::folder::Folder;
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::services::authorization::Permission;
use crate::domain::services::i18n_service::{I18nResult, I18nService, Locale};
use crate::domain::services::path_service::StoragePath;
@@ -353,7 +356,20 @@ impl I18nService for StubI18nService {
pub struct StubFolderUseCase;
impl FolderUseCase for StubFolderUseCase {
async fn create_folder(&self, _dto: CreateFolderDto) -> Result<FolderDto, DomainError> {
async fn require_permission(
&self,
_caller_id: Uuid,
_permission: Permission,
_file_id: &str,
) -> Result<(), DomainError> {
Ok(())
}
async fn create_folder_with_perms(
&self,
_dto: CreateFolderDto,
_user_id: Uuid,
) -> Result<FolderDto, DomainError> {
Ok(FolderDto::default())
}
@@ -361,7 +377,7 @@ impl FolderUseCase for StubFolderUseCase {
Ok(FolderDto::default())
}
async fn get_folder_owned(
async fn get_folder_with_perms(
&self,
_id: &str,
_caller_id: Uuid,
@@ -377,7 +393,7 @@ impl FolderUseCase for StubFolderUseCase {
Ok(Vec::new())
}
async fn list_folders_for_owner(
async fn list_folders_with_perms(
&self,
_parent_id: Option<&str>,
_owner_id: Uuid,
@@ -393,7 +409,7 @@ impl FolderUseCase for StubFolderUseCase {
Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0))
}
async fn list_folders_for_owner_paginated(
async fn list_folders_paginated_with_perms(
&self,
_parent_id: Option<&str>,
_owner_id: Uuid,
@@ -402,7 +418,7 @@ impl FolderUseCase for StubFolderUseCase {
Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0))
}
async fn rename_folder(
async fn rename_folder_with_perms(
&self,
_id: &str,
_dto: RenameFolderDto,
@@ -411,7 +427,7 @@ impl FolderUseCase for StubFolderUseCase {
Ok(FolderDto::default())
}
async fn move_folder(
async fn move_folder_with_perms(
&self,
_id: &str,
_dto: MoveFolderDto,
@@ -420,7 +436,11 @@ impl FolderUseCase for StubFolderUseCase {
Ok(FolderDto::default())
}
async fn delete_folder(&self, _id: &str, _caller_id: Uuid) -> Result<(), DomainError> {
async fn delete_folder_with_perms(
&self,
_id: &str,
_caller_id: Uuid,
) -> Result<(), DomainError> {
Ok(())
}
@@ -511,7 +531,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
Ok(Vec::new())
}
async fn list_files_owned(
async fn list_files_with_perms(
&self,
_folder_id: Option<&str>,
_owner_id: Uuid,
@@ -527,7 +547,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
Ok(Box::new(empty_stream))
}
async fn get_file_stream_owned(
async fn get_file_stream_with_perms(
&self,
_id: &str,
_caller_id: Uuid,
@@ -573,11 +593,15 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
Ok(Box::pin(futures::stream::empty()))
}
async fn get_file_owned(&self, _id: &str, _caller_id: Uuid) -> Result<FileDto, DomainError> {
async fn get_file_with_perms(
&self,
_id: &str,
_caller_id: Uuid,
) -> Result<FileDto, DomainError> {
Ok(FileDto::default())
}
async fn get_file_optimized_owned(
async fn get_file_optimized_with_perms(
&self,
_id: &str,
_caller_id: Uuid,
@@ -594,7 +618,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
))
}
async fn get_file_range_stream_owned(
async fn get_file_range_stream_with_perms(
&self,
_id: &str,
_caller_id: Uuid,
@@ -613,23 +637,16 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
pub struct StubFileManagementUseCase;
impl FileManagementUseCase for StubFileManagementUseCase {
async fn move_file(
async fn require_permission(
&self,
_caller_id: Uuid,
_permission: Permission,
_file_id: &str,
_folder_id: Option<String>,
) -> Result<FileDto, DomainError> {
Ok(FileDto::default())
) -> Result<(), DomainError> {
Ok(())
}
async fn copy_file(
&self,
_file_id: &str,
_folder_id: Option<String>,
) -> Result<FileDto, DomainError> {
Ok(FileDto::default())
}
async fn copy_file_owned(
async fn copy_file_with_perms(
&self,
_file_id: &str,
_caller_id: Uuid,
@@ -638,23 +655,19 @@ impl FileManagementUseCase for StubFileManagementUseCase {
Ok(FileDto::default())
}
async fn rename_file(&self, _file_id: &str, _new_name: &str) -> Result<FileDto, DomainError> {
Ok(FileDto::default())
}
async fn delete_file(&self, _id: &str) -> Result<(), DomainError> {
async fn delete_file_with_perms(&self, _id: &str, _caller_id: Uuid) -> Result<(), DomainError> {
Ok(())
}
async fn delete_file_owned(&self, _id: &str, _caller_id: Uuid) -> Result<(), DomainError> {
Ok(())
}
async fn delete_with_cleanup(&self, _id: &str, _user_id: Uuid) -> Result<bool, DomainError> {
async fn delete_and_cleanup_with_perms(
&self,
_id: &str,
_user_id: Uuid,
) -> Result<bool, DomainError> {
Ok(false)
}
async fn move_file_owned(
async fn move_file_with_perms(
&self,
_file_id: &str,
_caller_id: Uuid,
@@ -663,7 +676,7 @@ impl FileManagementUseCase for StubFileManagementUseCase {
Ok(FileDto::default())
}
async fn rename_file_owned(
async fn rename_file_with_perms(
&self,
_file_id: &str,
_caller_id: Uuid,
@@ -672,7 +685,7 @@ impl FileManagementUseCase for StubFileManagementUseCase {
Ok(FileDto::default())
}
async fn copy_folder_tree_owned(
async fn copy_folder_tree_with_perms(
&self,
_source_folder_id: &str,
_caller_id: Uuid,
@@ -11,6 +11,11 @@ pub trait TrashRepository: Send + Sync {
async fn delete_permanently(&self, id: &Uuid, user_id: &Uuid) -> Result<()>;
async fn clear_trash(&self, user_id: &Uuid) -> Result<()>;
/// All trashed file IDs for this user, regardless of parent folder trash status.
/// Used by empty_trash for thumbnail cleanup — the view used by get_trash_items
/// excludes files inside trashed folders, which would miss their ext thumbnails.
async fn get_all_trashed_file_ids(&self, user_id: &Uuid) -> Result<Vec<String>>;
/// Bulk-delete all expired trash items (files + folders) in a single
/// transaction. Returns `(files_deleted, folders_deleted)`.
async fn delete_expired_bulk(&self) -> Result<(u64, u64)>;
+241
View File
@@ -0,0 +1,241 @@
//! Domain types for the ReBAC authorization model.
//!
//! These types are storage-agnostic — they describe the relationship between
//! a subject (who), a resource (what), and a permission (action). The
//! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation
//! maps them to / from `storage.access_grants` rows.
use std::fmt;
use uuid::Uuid;
// ════════════════════════════════════════════════════════════════════════════
// Subject — who has the permission
// ════════════════════════════════════════════════════════════════════════════
/// A principal that can be granted permissions.
///
/// All variants carry a `Uuid` that uniquely identifies the subject within
/// its type's namespace.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Subject {
/// A registered OxiCloud user (`auth.users.id`).
User(Uuid),
/// A user group (reserved for future use; no group CRUD in v1).
Group(Uuid),
/// An anonymous share token (`storage.shares.id`).
Token(Uuid),
/// A federated identity from another server — Open Cloud Mesh, external
/// OIDC, etc. Refers to `auth.external_subjects.id` (future table).
External(Uuid),
}
impl Subject {
/// SQL discriminator string matching the `subject_type` CHECK constraint.
pub fn type_str(&self) -> &'static str {
match self {
Subject::User(_) => "user",
Subject::Group(_) => "group",
Subject::Token(_) => "token",
Subject::External(_) => "external",
}
}
/// The raw UUID regardless of variant.
pub fn id(&self) -> Uuid {
match self {
Subject::User(id) | Subject::Group(id) | Subject::Token(id) | Subject::External(id) => {
*id
}
}
}
/// Reconstruct from a SQL row's `(subject_type, subject_id)` pair.
pub fn from_parts(subject_type: &str, id: Uuid) -> Option<Self> {
match subject_type {
"user" => Some(Subject::User(id)),
"group" => Some(Subject::Group(id)),
"token" => Some(Subject::Token(id)),
"external" => Some(Subject::External(id)),
_ => None,
}
}
}
impl fmt::Display for Subject {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}({})", self.type_str(), self.id())
}
}
// ════════════════════════════════════════════════════════════════════════════
// Resource — what the permission is on
// ════════════════════════════════════════════════════════════════════════════
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Resource {
Folder(Uuid),
File(Uuid),
// Reserved for future use:
// Calendar(Uuid),
// Reserved for future use:
// AddressBook(Uuid),
// Reserved for future use:
// Playlist(Uuid),
}
impl Resource {
pub fn type_str(&self) -> &'static str {
match self {
Resource::Folder(_) => "folder",
Resource::File(_) => "file",
//Resource::Calendar(_) => "calendar",
//Resource::AddressBook(_) => "adressbook",
//Resource::Playlist(_) => "playlist",
}
}
pub fn id(&self) -> Uuid {
match self {
Resource::Folder(id)
| Resource::File(id)
//| Resource::Calendar(id)
//| Resource::AddressBook(id)
//| Resource::Playlist(id)
=> *id,
}
}
pub fn from_parts(resource_type: &str, id: Uuid) -> Option<Self> {
match resource_type {
"folder" => Some(Resource::Folder(id)),
"file" => Some(Resource::File(id)),
//"calendar" => Some(Resource::Calendar(id)),
//"adressbook" => Some(Resource::AddressBook(id)),
//"playlist" => Some(Resource::Playlist(id)),
_ => None,
}
}
}
impl fmt::Display for Resource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}({})", self.type_str(), self.id())
}
}
// ════════════════════════════════════════════════════════════════════════════
// Permission — what action is allowed
// ════════════════════════════════════════════════════════════════════════════
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Permission {
/// View resource content / list folder contents.
Read,
/// Create child resources inside (only meaningful on folders).
Create,
/// Grant permissions to other subjects.
Share,
/// Add comments (reserved — comments feature not implemented yet).
Comment,
/// Delete the resource.
Delete,
/// Modify the resource (rename, move, edit content).
Update,
}
impl Permission {
/// Every permission, in a stable order. Used by `Role::expand()` and SQL
/// `permission = ANY(...)` lookups.
pub const ALL: [Permission; 6] = [
Permission::Read,
Permission::Create,
Permission::Share,
Permission::Comment,
Permission::Delete,
Permission::Update,
];
pub fn as_str(&self) -> &'static str {
match self {
Permission::Read => "read",
Permission::Create => "create",
Permission::Share => "share",
Permission::Comment => "comment",
Permission::Delete => "delete",
Permission::Update => "update",
}
}
/// Parse a permission from its SQL discriminator string. Returns None
/// for unknown values.
pub fn parse(s: &str) -> Option<Self> {
match s {
"read" => Some(Permission::Read),
"create" => Some(Permission::Create),
"share" => Some(Permission::Share),
"comment" => Some(Permission::Comment),
"delete" => Some(Permission::Delete),
"update" => Some(Permission::Update),
_ => None,
}
}
}
impl fmt::Display for Permission {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
// ════════════════════════════════════════════════════════════════════════════
// Grant — a row in storage.access_grants
// ════════════════════════════════════════════════════════════════════════════
#[derive(Clone, Debug)]
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>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subject_roundtrip() {
let id = Uuid::new_v4();
let cases = [
Subject::User(id),
Subject::Group(id),
Subject::Token(id),
Subject::External(id),
];
for s in cases {
let back = Subject::from_parts(s.type_str(), s.id()).unwrap();
assert_eq!(s, back);
}
assert!(Subject::from_parts("unknown", id).is_none());
}
#[test]
fn resource_roundtrip() {
let id = Uuid::new_v4();
for r in [Resource::Folder(id), Resource::File(id)] {
let back = Resource::from_parts(r.type_str(), r.id()).unwrap();
assert_eq!(r, back);
}
assert!(Resource::from_parts("calendar", id).is_none());
}
#[test]
fn permission_roundtrip() {
for p in Permission::ALL {
assert_eq!(Permission::parse(p.as_str()), Some(p));
}
assert!(Permission::parse("administrate").is_none());
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod authorization;
pub mod i18n_service;
pub mod path_service;
@@ -80,6 +80,20 @@ impl FileBlobReadRepository {
}
}
/// Returns the user_id (owner) for a given file ID.
/// Mirrors `FolderDbRepository::get_folder_user_id`.
/// Used by the AuthorizationEngine for owner short-circuit.
pub async fn get_file_user_id(&self, file_id: &str) -> Result<uuid::Uuid, DomainError> {
sqlx::query_scalar::<_, uuid::Uuid>(
"SELECT user_id FROM storage.files WHERE id = $1::uuid AND NOT is_trashed",
)
.bind(file_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("user_id lookup: {e}")))?
.ok_or_else(|| DomainError::not_found("File", file_id))
}
/// Creates a stub instance for testing — never hits PG.
#[cfg(test)]
pub fn new_stub() -> Self {
@@ -1044,4 +1044,20 @@ impl FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("user_id lookup: {e}")))?
.ok_or_else(|| DomainError::not_found("Folder", folder_id))
}
/// Verifies that `folder_id` is owned by `owner_id`.
///
/// Returns `DomainError::not_found(...)` for both "folder missing" and
/// "folder owned by someone else" — same error to avoid leaking the
/// existence of resources belonging to other users.
pub async fn verify_owner(&self, folder_id: &str, owner_id: Uuid) -> Result<(), DomainError> {
let actual = self.get_folder_user_id(folder_id).await?;
if actual != owner_id {
return Err(DomainError::not_found(
"Folder",
"Target folder not found or access denied",
));
}
Ok(())
}
}
@@ -157,6 +157,17 @@ impl TrashRepository for TrashDbRepository {
Ok(())
}
async fn get_all_trashed_file_ids(&self, user_id: &Uuid) -> Result<Vec<String>> {
let rows = sqlx::query_scalar::<_, String>(
"SELECT id::text FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE",
)
.bind(user_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("all_trashed_files: {e}")))?;
Ok(rows)
}
async fn delete_expired_bulk(&self) -> Result<(u64, u64)> {
let cutoff = Utc::now() - chrono::Duration::days(self.retention_days);
@@ -770,8 +770,15 @@ impl ChunkedUploadService {
}
/// Cancel an upload and cleanup — disk I/O outside lock.
async fn cancel_upload_inner(&self, upload_id: &str, user_id: &str) -> Result<(), String> {
self.verify_session_owner(upload_id, user_id)?;
///
/// Returns:
/// - `DomainError::NotFound` if no session matches `upload_id` for `user_id`
/// (covers both "session missing" and "owned by someone else" — same
/// error for anti-enumeration).
/// - `DomainError::InternalError` for unexpected disk I/O failures.
async fn cancel_upload_inner(&self, upload_id: &str, user_id: &str) -> Result<(), DomainError> {
self.verify_session_owner(upload_id, user_id)
.map_err(|_| DomainError::not_found("Upload", upload_id))?;
// Remove from map (~µs)
let removed = self.sessions.remove(upload_id).map(|(_, s)| s);
@@ -861,9 +868,11 @@ impl ChunkedUploadPort for ChunkedUploadService {
}
async fn cancel_upload(&self, upload_id: &str, user_id: Uuid) -> Result<(), DomainError> {
// Inner function now returns DomainError with proper variants
// (NotFound for missing/wrong-owner sessions, InternalError otherwise),
// so no mapping needed here.
self.cancel_upload_inner(upload_id, &user_id.to_string())
.await
.map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e))
}
fn should_use_chunked(&self, size: u64) -> bool {
+20 -4
View File
@@ -1388,12 +1388,23 @@ impl DedupService {
let mut total_bytes = 0u64;
// ── Phase 1: GC orphaned manifests ───────────────────────
// A manifest is collectible when:
// • ref_count has been decremented to 0 by cleanup_if_orphaned
// on the single-file-delete service path, OR
// • no `storage.files.blob_hash` references its file_hash
// (covers bulk-delete paths: user cascade, empty_trash —
// where the PG trigger only touches storage.blobs and the
// per-file cleanup_if_orphaned call is skipped).
loop {
let batch: Vec<(String, Vec<String>, i64)> = sqlx::query_as(
"DELETE FROM storage.chunk_manifests
WHERE ctid = ANY(
SELECT ctid FROM storage.chunk_manifests
WHERE ref_count <= 0
SELECT ctid FROM storage.chunk_manifests m
WHERE m.ref_count <= 0
OR NOT EXISTS (
SELECT 1 FROM storage.files f
WHERE f.blob_hash = m.file_hash
)
LIMIT $1
)
RETURNING file_hash, chunk_hashes, total_size",
@@ -1408,9 +1419,14 @@ impl DedupService {
}
for (file_hash, chunk_hashes, size) in &batch {
// Decrement chunk ref_counts
// Decrement chunk ref_counts. GREATEST(.., 0) guards against the
// single-chunk file case where the PG file-delete trigger already
// decremented blobs.ref_count (because file_hash == chunk_hash);
// without the clamp this would underflow the CHECK constraint.
sqlx::query(
"UPDATE storage.blobs SET ref_count = ref_count - 1 WHERE hash = ANY($1)",
"UPDATE storage.blobs
SET ref_count = GREATEST(ref_count - 1, 0)
WHERE hash = ANY($1)",
)
.bind(chunk_hashes)
.execute(self.maintenance_pool.as_ref())
+1
View File
@@ -19,6 +19,7 @@ pub mod oidc_service;
pub mod password_hasher;
pub mod path_resolver_service;
pub mod path_service;
pub mod pg_acl_engine;
pub mod retry_blob_backend;
pub mod s3_blob_backend;
pub mod share_unlock_cookie;
@@ -0,0 +1,436 @@
//! PostgreSQL-backed implementation of `AuthorizationEngine`.
//!
//! Stores grants in `storage.access_grants` (see migration
//! `20260520000000_rebac_access_grants.sql`). Cascading is resolved at check
//! time via PostgreSQL `ltree` `@>` (ancestor-of) on `storage.folders.lpath`,
//! using the existing GiST index for O(log N) traversal.
//!
//! Owner is implicit — `storage.folders.user_id` / `storage.files.user_id`
//! are checked first via dedicated helpers; if the caller is the owner, no
//! SQL against `access_grants` happens.
//!
//! ## Lifecycle cleanup
//!
//! In v1, cleanup of grant rows when a resource or subject is permanently
//! deleted is enforced by **DB triggers** (`trg_cleanup_grants_*` in the
//! migration). The application layer does not call `revoke_all_for_*`
//! explicitly today — the triggers are the canonical path because they
//! also catch bulk SQL maintenance, admin scripts, and any code path that
//! bypasses the service layer.
//!
//! The `revoke_all_for_resource` / `revoke_all_for_subject` methods exist
//! on the trait for future use cases:
//! - **Caching** (planned) — a `CachedAuthorizationEngine` decorator needs
//! to see the invalidation event at the engine boundary, not just at the
//! SQL level. When caching lands, services will start calling these
//! methods explicitly before/around delete operations.
//! - **Alternate engines** (OpenFGA, future) — engines that don't share a
//! DB transaction with the resource table need an explicit signal to
//! delete their tuples.
use std::sync::Arc;
use uuid::Uuid;
use sqlx::PgPool;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::common::errors::DomainError;
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
pub struct PgAclEngine {
pool: Arc<PgPool>,
folder_repo: Arc<FolderDbRepository>,
file_repo: Arc<FileBlobReadRepository>,
}
impl PgAclEngine {
pub fn new(
pool: Arc<PgPool>,
folder_repo: Arc<FolderDbRepository>,
file_repo: Arc<FileBlobReadRepository>,
) -> Self {
Self {
pool,
folder_repo,
file_repo,
}
}
/// Creates a stub instance for tests that need to construct services
/// without a real PostgreSQL pool. Connecting to the lazy pool will
/// fail at runtime — only safe in tests that exercise types, not actual
/// authz queries.
#[cfg(test)]
pub fn new_stub() -> Self {
let pool = sqlx::pool::PoolOptions::<sqlx::Postgres>::new()
.max_connections(1)
.connect_lazy("postgres://invalid:5432/none")
.unwrap();
Self {
pool: Arc::new(pool),
folder_repo: Arc::new(FolderDbRepository::new_stub()),
file_repo: Arc::new(FileBlobReadRepository::new_stub()),
}
}
/// Returns the owner UUID for any resource type.
async fn owner_of(&self, resource: Resource) -> Result<Uuid, DomainError> {
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,
}
}
/// Cascading check for folders: is there a grant on any ancestor folder
/// (including the target itself) in this subject + permission?
/// Uses GiST index on `storage.folders.lpath`.
async fn folder_cascade_grant_exists(
&self,
subject: Subject,
permission: Permission,
folder_id: Uuid,
) -> Result<bool, DomainError> {
let exists: Option<i32> = sqlx::query_scalar(
r#"
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)
LIMIT 1
"#,
)
.bind(subject.type_str())
.bind(subject.id())
.bind(permission.as_str())
.bind(folder_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("folder cascade: {e}")))?;
Ok(exists.is_some())
}
/// Cascading check for files: either a direct file grant OR a grant on
/// any ancestor folder of the file's containing folder.
async fn file_cascade_grant_exists(
&self,
subject: Subject,
permission: Permission,
file_id: Uuid,
) -> Result<bool, DomainError> {
let exists: Option<i32> = sqlx::query_scalar(
r#"
SELECT 1
FROM (
-- 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)
) any_match
LIMIT 1
"#,
)
.bind(subject.type_str())
.bind(subject.id())
.bind(permission.as_str())
.bind(file_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("file cascade: {e}")))?;
Ok(exists.is_some())
}
/// Look up a single grant by id. Returns `(resource, granted_by)` so
/// the REST `DELETE /api/grants/{id}` handler can decide authorization
/// without a second round-trip. Returns `Ok(None)` if no such grant.
pub async fn find_grant_by_id(
&self,
grant_id: Uuid,
) -> Result<Option<(Resource, Uuid)>, DomainError> {
let row: Option<(String, Uuid, Uuid)> = sqlx::query_as(
"SELECT resource_type, resource_id, granted_by FROM storage.access_grants WHERE id = $1",
)
.bind(grant_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("find_grant_by_id: {e}")))?;
let Some((rt, rid, granter)) = row else {
return Ok(None);
};
let res = Resource::from_parts(&rt, rid)
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?;
Ok(Some((res, granter)))
}
/// Decode a (id, subject_type, subject_id, resource_type, resource_id,
/// permission, granted_by, granted_at) row into a `Grant`.
fn row_to_grant(
row: (
Uuid,
String,
Uuid,
String,
Uuid,
String,
Uuid,
chrono::DateTime<chrono::Utc>,
),
) -> Result<Grant, DomainError> {
let subject = Subject::from_parts(&row.1, row.2)
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown subject_type"))?;
let resource = Resource::from_parts(&row.3, row.4)
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?;
let permission = Permission::parse(&row.5)
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown permission"))?;
Ok(Grant {
id: row.0,
subject,
resource,
permission,
granted_by: row.6,
granted_at: row.7,
})
}
}
impl AuthorizationEngine for PgAclEngine {
async fn check(
&self,
subject: Subject,
permission: Permission,
resource: Resource,
) -> Result<bool, DomainError> {
// Owner short-circuit (only for User subjects — groups/tokens/external
// are never owners of resources).
if let Subject::User(uid) = subject {
match self.owner_of(resource).await {
Ok(owner) if owner == uid => return Ok(true),
Ok(_) => { /* not owner — fall through to grants */ }
Err(e) if e.kind == crate::common::errors::ErrorKind::NotFound => {
// Resource doesn't exist — no permission. Return false
// rather than propagating NotFound; the caller (`require`)
// converts a false back to NotFound on its own.
return Ok(false);
}
Err(e) => return Err(e),
}
}
// Cascading grant check.
match resource {
Resource::Folder(id) => {
self.folder_cascade_grant_exists(subject, permission, id)
.await
}
Resource::File(id) => {
self.file_cascade_grant_exists(subject, permission, id)
.await
}
}
}
async fn list_incoming_grants(
&self,
subject: Subject,
permission_filter: Option<Permission>,
) -> Result<Vec<Grant>, DomainError> {
let perm_str = permission_filter.map(|p| p.as_str().to_string());
let rows = sqlx::query_as::<
_,
(
Uuid,
String,
Uuid,
String,
Uuid,
String,
Uuid,
chrono::DateTime<chrono::Utc>,
),
>(
r#"
SELECT id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at
FROM storage.access_grants
WHERE subject_type = $1
AND subject_id = $2
AND ($3::text IS NULL OR permission = $3)
ORDER BY granted_at DESC
"#,
)
.bind(subject.type_str())
.bind(subject.id())
.bind(perm_str)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("list incoming: {e}")))?;
rows.into_iter().map(Self::row_to_grant).collect()
}
async fn list_grants_on_resource(&self, resource: Resource) -> Result<Vec<Grant>, DomainError> {
let rows = sqlx::query_as::<
_,
(
Uuid,
String,
Uuid,
String,
Uuid,
String,
Uuid,
chrono::DateTime<chrono::Utc>,
),
>(
r#"
SELECT id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at
FROM storage.access_grants
WHERE resource_type = $1
AND resource_id = $2
ORDER BY granted_at DESC
"#,
)
.bind(resource.type_str())
.bind(resource.id())
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("list on resource: {e}")))?;
rows.into_iter().map(Self::row_to_grant).collect()
}
async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result<Vec<Grant>, DomainError> {
let rows = sqlx::query_as::<
_,
(
Uuid,
String,
Uuid,
String,
Uuid,
String,
Uuid,
chrono::DateTime<chrono::Utc>,
),
>(
r#"
SELECT id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at
FROM storage.access_grants
WHERE granted_by = $1
ORDER BY granted_at DESC
"#,
)
.bind(granted_by)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("list outgoing: {e}")))?;
rows.into_iter().map(Self::row_to_grant).collect()
}
async fn grant(
&self,
granted_by: Uuid,
subject: Subject,
permission: Permission,
resource: Resource,
) -> Result<Grant, DomainError> {
// Idempotent: ON CONFLICT DO UPDATE so we always return the row
// (whether newly inserted or pre-existing). The "update" is a no-op
// (granted_by/granted_at preserved from the existing row).
let row = sqlx::query_as::<
_,
(
Uuid,
String,
Uuid,
String,
Uuid,
String,
Uuid,
chrono::DateTime<chrono::Utc>,
),
>(
r#"
INSERT INTO storage.access_grants
(subject_type, subject_id, resource_type, resource_id, permission, granted_by)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (subject_type, subject_id, resource_type, resource_id, permission)
DO UPDATE SET subject_type = EXCLUDED.subject_type
RETURNING id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at
"#,
)
.bind(subject.type_str())
.bind(subject.id())
.bind(resource.type_str())
.bind(resource.id())
.bind(permission.as_str())
.bind(granted_by)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("insert grant: {e}")))?;
Self::row_to_grant(row)
}
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> {
sqlx::query("DELETE FROM storage.access_grants WHERE id = $1")
.bind(grant_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke: {e}")))?;
Ok(())
}
async fn revoke_all_for_resource(&self, resource: Resource) -> Result<usize, DomainError> {
let result = sqlx::query(
"DELETE FROM storage.access_grants WHERE resource_type = $1 AND resource_id = $2",
)
.bind(resource.type_str())
.bind(resource.id())
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for resource: {e}")))?;
Ok(result.rows_affected() as usize)
}
async fn revoke_all_for_subject(&self, subject: Subject) -> Result<usize, DomainError> {
let result = sqlx::query(
"DELETE FROM storage.access_grants WHERE subject_type = $1 AND subject_id = $2",
)
.bind(subject.type_str())
.bind(subject.id())
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for subject: {e}")))?;
Ok(result.rows_affected() as usize)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ use crate::application::services::folder_service::FolderService;
use crate::{
application::dtos::file_dto::FileDto,
application::ports::file_ports::FileRetrievalUseCase,
application::ports::inbound::FolderUseCase,
application::ports::folder_ports::FolderUseCase,
application::ports::zip_ports::ZipPort,
common::errors::{DomainError, ErrorKind, Result},
};
+14 -4
View File
@@ -15,6 +15,7 @@ use crate::application::services::batch_operations::{
};
use crate::interfaces::api::deserializer;
use crate::interfaces::api::handlers::ApiResult;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
/// Maximum number of items allowed in a single batch request.
@@ -1010,10 +1011,19 @@ async fn process_download_batch(
.await
.map_err(|e| {
tracing::error!("Batch download ZIP failed: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Batch download failed".to_string(),
)
// Surface DomainError variants (NotFound when no items were
// authorized) with their natural HTTP status code instead of
// collapsing everything to 500.
match e {
crate::application::services::batch_operations::BatchOperationError::Domain(de) => {
let app: AppError = de.into();
(app.status_code, app.message)
}
other => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Batch download failed: {}", other),
),
}
})?;
// Read file size for Content-Length before splitting ownership
@@ -21,8 +21,10 @@ use utoipa::ToSchema;
use crate::application::ports::chunked_upload_ports::ChunkedUploadPort;
use crate::application::ports::chunked_upload_ports::DEFAULT_CHUNK_SIZE;
use crate::application::ports::file_ports::FileUploadUseCase;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::storage_ports::StorageUsagePort;
use crate::common::di::AppState;
use crate::domain::services::authorization::Permission;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
@@ -118,6 +120,27 @@ impl ChunkedUploadHandler {
.into_response();
}
// ── Permission pre-check: caller must have Create on the target
// folder BEFORE we allocate a session and accept chunks. The
// upload service re-checks at finalize time, but failing here
// avoids wasting client+server resources on chunks that will be
// rejected. None = caller's root namespace, no check needed.
if let Some(ref fid) = request.folder_id
&& let Err(err) = state
.applications
.folder_service_concrete
.require_permission(auth_user.id, Permission::Create, fid)
.await
{
tracing::warn!(
"⛔ CHUNKED UPLOAD REJECTED (no perm): user='{}' folder='{}' err='{}'",
auth_user.username,
fid,
err
);
return AppError::from(err).into_response();
}
// ── Quota enforcement ────────────────────────────────────
if let Some(storage_svc) = state.storage_usage_service.as_ref()
&& let Err(err) = storage_svc
@@ -264,6 +287,7 @@ impl ChunkedUploadHandler {
/// POST /api/uploads/:upload_id/complete - Finalize upload
///
/// Assembles all chunks into the final file and creates the file record
// TODO: how is implemented security (owneship, permission ?)
pub(super) async fn complete_upload_impl(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
@@ -349,9 +373,7 @@ impl ChunkedUploadHandler {
.await
{
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
AppError::internal_error(format!("Failed to cancel upload: {}", e)).into_response()
}
Err(e) => AppError::from(e).into_response(),
}
}
}
+69 -37
View File
@@ -11,17 +11,17 @@ use serde::Deserialize;
use std::collections::HashMap;
use utoipa::ToSchema;
use crate::application::dtos::file_dto::FileDto;
use crate::application::ports::file_ports::OptimizedFileContent;
use crate::application::ports::file_ports::{
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
};
use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort};
use crate::application::ports::thumbnail_ports::ThumbnailPort;
use crate::application::ports::{file_ports::OptimizedFileContent, folder_ports::FolderUseCase};
use crate::common::di::AppState;
use crate::infrastructure::services::audio_metadata_service::AudioMetadataService;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
use crate::{application::dtos::file_dto::FileDto, domain::services::authorization::Permission};
use std::sync::Arc;
/**
@@ -85,6 +85,7 @@ impl FileHandler {
tracing::debug!("📤 Processing streaming file upload (hash-on-write)");
// caveat: if folder_id field is given after check can fails
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
let name = field.name().unwrap_or("").to_string();
@@ -115,24 +116,24 @@ impl FileHandler {
.unwrap_or("application/octet-stream")
.to_string();
// ── SECURITY: Verify folder ownership before upload (IDOR V-03 fix) ──
if let Some(ref fid) = folder_id {
use crate::application::ports::inbound::FolderUseCase;
let folder_service = &state.applications.folder_service;
if folder_service
.get_folder_owned(fid, auth_user.id)
// ── Fail-fast pre-check: verify the caller can Create inside
// the target folder BEFORE spooling the multipart body to disk.
// The upload service re-checks at write time — this is a
// UX/resource optimization, not the security boundary.
if let Some(ref fid) = folder_id
&& let Err(err) = state
.applications
.folder_service_concrete
.require_permission(auth_user.id, Permission::Create, fid)
.await
.is_err()
{
tracing::warn!(
"⛔ UPLOAD REJECTED (IDOR): user='{}' attempted upload to folder '{}' owned by another user",
auth_user.username,
fid,
);
return Err(Self::domain_error_response(
crate::common::errors::DomainError::not_found("Folder", fid),
));
}
{
tracing::warn!(
"⛔ UPLOAD REJECTED: user='{}' folder='{}' err='{}'",
auth_user.username,
fid,
err
);
return Err(Self::domain_error_response(err));
}
// ── Early quota check (before spooling to disk) ──────
@@ -328,6 +329,16 @@ impl FileHandler {
) -> impl IntoResponse {
use crate::application::ports::thumbnail_ports::ThumbnailSize;
// check first that user can access this resource
if let Err(err) = state
.applications
.file_management_service
.require_permission(auth_user.id, Permission::Read, &id)
.await
{
return AppError::from(err).into_response();
}
let thumbnail_service = &state.core.thumbnail_service;
let thumb_size = match size.as_str() {
@@ -385,7 +396,7 @@ impl FileHandler {
let file_retrieval_service = &state.applications.file_retrieval_service;
let file = match file_retrieval_service
.get_file_owned(&id, auth_user.id)
.get_file_with_perms(&id, auth_user.id)
.await
{
Ok(f) => f,
@@ -478,6 +489,16 @@ impl FileHandler {
) -> impl IntoResponse {
use crate::application::ports::thumbnail_ports::ThumbnailSize;
// check first that user can access this resource
if let Err(err) = state
.applications
.file_management_service
.require_permission(auth_user.id, Permission::Update, &id)
.await
{
return AppError::from(err).into_response();
}
let thumbnail_service = &state.core.thumbnail_service;
// Validate size
@@ -508,7 +529,7 @@ impl FileHandler {
// Validate file ownership
let file_retrieval_service = &state.applications.file_retrieval_service;
if let Err(err) = file_retrieval_service
.get_file_owned(&id, auth_user.id)
.get_file_with_perms(&id, auth_user.id)
.await
{
return AppError::from(err).into_response();
@@ -545,7 +566,7 @@ impl FileHandler {
let retrieval = &state.applications.file_retrieval_service;
// ── Get file metadata (ownership-scoped) ────────────────────────
let file_dto = match retrieval.get_file_owned(&id, auth_user.id).await {
let file_dto = match retrieval.get_file_with_perms(&id, auth_user.id).await {
Ok(f) => f,
Err(err) => {
return AppError::from(err).into_response();
@@ -603,7 +624,7 @@ impl FileHandler {
Self::content_disposition(&file_dto.name, &file_dto.mime_type, &params);
match retrieval
.get_file_range_stream_owned(&id, auth_user.id, start, Some(end + 1))
.get_file_range_stream_with_perms(&id, auth_user.id, start, Some(end + 1))
.await
{
Ok(stream) => {
@@ -713,7 +734,10 @@ impl FileHandler {
tracing::info!("API: Listing files with folder_id: {:?}", folder_id);
let retrieval = &state.applications.file_retrieval_service;
match retrieval.list_files_owned(folder_id, auth_user.id).await {
match retrieval
.list_files_with_perms(folder_id, auth_user.id)
.await
{
Ok(files) => {
// Compute lightweight ETag from max modified_at + count
let max_mod = files.iter().map(|f| f.modified_at).max().unwrap_or(0);
@@ -751,6 +775,7 @@ impl FileHandler {
/// Delegates to [`Self::upload_file_inner`] and, on success, spawns
/// a background task to generate all thumbnail sizes before serialising
/// the `FileDto` once.
/// TODO: should move thumbnail generation to a generic hook ? (onfileUploaded, other services will beneficiate it)
pub(super) async fn upload_file_with_thumbnails_impl(
State(state): State<GlobalState>,
auth_user: AuthUser,
@@ -797,6 +822,7 @@ impl FileHandler {
});
}
// TODO: same remark: a hook to handle easily audio service
// Extract audio metadata for supported audio files in background.
if let Some(ref audio_service) = state.applications.audio_metadata_service
&& AudioMetadataService::is_audio_file(&file.mime_type)
@@ -825,15 +851,14 @@ impl FileHandler {
auth_user: AuthUser,
Path(file_id): Path<String>,
) -> impl IntoResponse {
// Verify ownership
let file_read = &state.repositories.file_read_repository;
if let Err(e) = file_read.verify_file_owner(&file_id, auth_user.id).await {
let msg = e.to_string();
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": msg })),
)
.into_response();
// check first that user can access this resource
if let Err(err) = state
.applications
.file_management_service
.require_permission(auth_user.id, Permission::Read, &file_id)
.await
{
return AppError::from(err).into_response();
}
let metadata_repo = &state.repositories.file_metadata_repository;
@@ -875,7 +900,7 @@ impl FileHandler {
// Auth required: trash-first with dedup cleanup + ownership verification
let result = mgmt
.delete_with_cleanup(&id, auth_user.id)
.delete_and_cleanup_with_perms(&id, auth_user.id)
.await
.map(|was_trashed| {
if was_trashed {
@@ -917,13 +942,17 @@ impl FileHandler {
tracing::info!("Renaming file {} to \"{}\"", id, new_name);
let mgmt = &state.applications.file_management_service;
match mgmt.rename_file_owned(&id, auth_user.id, &new_name).await {
match mgmt
.rename_file_with_perms(&id, auth_user.id, &new_name)
.await
{
Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(),
Err(err) => AppError::from(err).into_response(),
}
}
/// Moves a file to a different folder (ownership-verified)
/// TODO: dead function ?
pub async fn move_file(
State(state): State<GlobalState>,
auth_user: AuthUser,
@@ -935,7 +964,7 @@ impl FileHandler {
let mgmt = &state.applications.file_management_service;
match mgmt
.move_file_owned(&id, auth_user.id, payload.folder_id)
.move_file_with_perms(&id, auth_user.id, payload.folder_id)
.await
{
Ok(file) => (StatusCode::OK, Json(file)).into_response(),
@@ -956,7 +985,10 @@ impl FileHandler {
.map(|s| s.to_string());
let mgmt = &state.applications.file_management_service;
match mgmt.move_file_owned(&id, auth_user.id, folder_id).await {
match mgmt
.move_file_with_perms(&id, auth_user.id, folder_id)
.await
{
Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(),
Err(err) => AppError::from(err).into_response(),
}
+21 -61
View File
@@ -16,7 +16,7 @@ use crate::application::dtos::folder_dto::{
use crate::application::dtos::folder_listing_dto::FolderListingDto;
use crate::application::dtos::pagination::PaginationRequestDto;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::inbound::FolderUseCase;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::trash_ports::TrashUseCase;
use crate::application::services::folder_service::FolderService;
use crate::common::di::AppState as GlobalAppState;
@@ -51,7 +51,7 @@ impl FolderHandler {
"create_folder: parent_id is None for user '{}', resolving home folder",
auth_user.username
);
match service.list_folders_for_owner(None, auth_user.id).await {
match service.list_folders_with_perms(None, auth_user.id).await {
Ok(folders) => {
if let Some(home) = folders.first() {
tracing::info!(
@@ -76,25 +76,7 @@ impl FolderHandler {
}
}
// ── SECURITY: Verify parent folder ownership (IDOR V-04 fix) ──
if let Some(ref parent_id) = dto.parent_id {
use crate::application::ports::inbound::FolderUseCase;
if service
.get_folder_owned(parent_id, auth_user.id)
.await
.is_err()
{
tracing::warn!(
"create_folder: user '{}' attempted to create folder in parent '{}' owned by another user",
auth_user.username,
parent_id,
);
return AppError::not_found(format!("Parent folder not found: {}", parent_id))
.into_response();
}
}
match service.create_folder(dto).await {
match service.create_folder_with_perms(dto, auth_user.id).await {
Ok(folder) => (StatusCode::CREATED, Json(folder)).into_response(),
Err(err) => AppError::from(err).into_response(),
}
@@ -107,22 +89,8 @@ impl FolderHandler {
auth_user: AuthUser,
Path(id): Path<String>,
) -> impl IntoResponse {
match service.get_folder(&id).await {
Ok(folder) => {
// Access check: folder must belong to the requesting user
if let Some(ref owner) = folder.owner_id
&& owner != &auth_user.id.to_string()
{
tracing::warn!(
"get_folder: user '{}' attempted to access folder '{}' owned by '{}'",
auth_user.id,
id,
owner
);
return AppError::not_found("Folder not found").into_response();
}
(StatusCode::OK, Json(folder)).into_response()
}
match service.get_folder_with_perms(&id, auth_user.id).await {
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
Err(err) => AppError::from(err).into_response(),
}
}
@@ -164,7 +132,7 @@ impl FolderHandler {
pagination: Query<PaginationRequestDto>,
) -> axum::response::Response {
match service
.list_folders_for_owner_paginated(Some(&id), auth_user.id, &pagination)
.list_folders_paginated_with_perms(Some(&id), auth_user.id, &pagination)
.await
{
Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(),
@@ -181,7 +149,7 @@ impl FolderHandler {
auth_user: &AuthUser,
) -> axum::response::Response {
match service
.list_folders_for_owner(parent_id, auth_user.id)
.list_folders_with_perms(parent_id, auth_user.id)
.await
{
Ok(folders) => (StatusCode::OK, Json(folders)).into_response(),
@@ -224,8 +192,8 @@ impl FolderHandler {
// Run both queries concurrently — no sequential wait.
let (folders_result, files_result) = tokio::join!(
folder_service.list_folders_for_owner(Some(&id), auth_user.id),
file_service.list_files_owned(Some(&id), auth_user.id)
folder_service.list_folders_with_perms(Some(&id), auth_user.id),
file_service.list_files_with_perms(Some(&id), auth_user.id)
);
match (folders_result, files_result) {
@@ -244,7 +212,6 @@ impl FolderHandler {
.unwrap()
.into_response();
}
let listing = FolderListingDto { folders, files };
let mut resp = (StatusCode::OK, Json(listing)).into_response();
resp.headers_mut()
@@ -262,7 +229,10 @@ impl FolderHandler {
Path(id): Path<String>,
Json(dto): Json<RenameFolderDto>,
) -> impl IntoResponse {
match service.rename_folder(&id, dto, auth_user.id).await {
match service
.rename_folder_with_perms(&id, dto, auth_user.id)
.await
{
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
Err(err) => AppError::from(err).into_response(),
}
@@ -275,7 +245,7 @@ impl FolderHandler {
Path(id): Path<String>,
Json(dto): Json<MoveFolderDto>,
) -> impl IntoResponse {
match service.move_folder(&id, dto, auth_user.id).await {
match service.move_folder_with_perms(&id, dto, auth_user.id).await {
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
Err(err) => AppError::from(err).into_response(),
}
@@ -287,7 +257,7 @@ impl FolderHandler {
auth_user: AuthUser,
Path(id): Path<String>,
) -> impl IntoResponse {
match service.delete_folder(&id, auth_user.id).await {
match service.delete_folder_with_perms(&id, auth_user.id).await {
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(err) => AppError::from(err).into_response(),
}
@@ -301,6 +271,7 @@ impl FolderHandler {
) -> impl IntoResponse {
let user_id = auth_user.id;
// Check if trash service is available
// FIXME: permissions !!
if let Some(trash_service) = &state.trash_service {
tracing::info!("Moving folder to trash: {}", id);
@@ -322,7 +293,7 @@ impl FolderHandler {
// Fallback to permanent delete if trash is unavailable or failed
let folder_service = &state.applications.folder_service;
match folder_service.delete_folder(&id, user_id).await {
match folder_service.delete_folder_with_perms(&id, user_id).await {
Ok(_) => {
tracing::info!("Folder permanently deleted: {}", id);
StatusCode::NO_CONTENT.into_response()
@@ -343,22 +314,11 @@ impl FolderHandler {
// Get folder information and verify ownership
let folder_service = &state.applications.folder_service;
match folder_service.get_folder(&id).await {
match folder_service
.get_folder_with_perms(&id, auth_user.id)
.await
{
Ok(folder) => {
// Access check: folder must belong to the requesting user
if folder.owner_id.as_deref() != Some(&auth_user.id.to_string()) {
tracing::warn!(
"download_folder_zip: user '{}' attempted to download folder '{}' owned by '{:?}'",
auth_user.id,
id,
folder.owner_id
);
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Folder not found" })),
)
.into_response();
}
tracing::info!("Preparing ZIP for folder: {} ({})", folder.name, id);
// Use ZIP service from DI container
@@ -0,0 +1,360 @@
//! REST handlers for the ReBAC grant management endpoints.
//!
//! All endpoints under `/api/grants`. The authenticated caller is taken from
//! the `AuthUser` extractor. Authorization for sharing operations is enforced
//! via `authz.require(caller, Share, resource)` — handlers never embed their
//! own checks (see CLAUDE.md § Authorization).
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use serde::Deserialize;
use std::sync::Arc;
use tracing::{error, info};
use utoipa::IntoParams;
use uuid::Uuid;
use crate::application::dtos::grant_dto::{
CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, SubjectDto,
UpdateRoleDto,
};
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::common::errors::DomainError;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
// ════════════════════════════════════════════════════════════════════════════
// POST /api/grants
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
post,
path = "/api/grants",
request_body = CreateGrantDto,
responses(
(status = 201, description = "Grant(s) created", body = Vec<GrantDto>),
(status = 400, description = "Invalid input (both/neither of permissions+role provided)"),
(status = 404, description = "Resource not found OR caller lacks Share permission"),
),
tag = "grants"
)]
pub async fn create_grant(
State(authz): State<Arc<PgAclEngine>>,
auth_user: AuthUser,
Json(dto): Json<CreateGrantDto>,
) -> impl IntoResponse {
let caller_id = auth_user.id;
// Validate: exactly one of permissions/role
let permissions: Vec<Permission> = match (dto.permissions, dto.role) {
(Some(perms), None) if !perms.is_empty() => perms.into_iter().map(Into::into).collect(),
(None, Some(role)) => role.expand().to_vec(),
(Some(_), Some(_)) => {
return AppError::new(
StatusCode::BAD_REQUEST,
"Provide either 'permissions' or 'role', not both",
"InvalidInput",
)
.into_response();
}
_ => {
return AppError::new(
StatusCode::BAD_REQUEST,
"Either 'permissions' (non-empty) or 'role' is required",
"InvalidInput",
)
.into_response();
}
};
let subject: Subject = dto.subject.into();
let resource: Resource = dto.resource.into();
// Caller must have Share on the resource (owners pass via short-circuit).
if let Err(e) = authz
.require(Subject::User(caller_id), Permission::Share, resource)
.await
{
return AppError::from(e).into_response();
}
let mut results: Vec<GrantDto> = Vec::with_capacity(permissions.len());
for perm in permissions {
match authz.grant(caller_id, subject, perm, resource).await {
Ok(grant) => results.push(grant.into()),
Err(err) => {
error!("grant insert failed for {perm:?}: {err}");
return AppError::from(err).into_response();
}
}
}
info!(
"Created {} grant(s) for subject={:?} on resource={:?} by user {}",
results.len(),
subject,
resource,
caller_id
);
(StatusCode::CREATED, Json(results)).into_response()
}
// ════════════════════════════════════════════════════════════════════════════
// DELETE /api/grants/{id}
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
delete,
path = "/api/grants/{id}",
params(("id" = String, Path, description = "Grant UUID")),
responses(
(status = 204, description = "Grant revoked (or did not exist)"),
(status = 404, description = "Caller lacks Share permission on the underlying resource"),
),
tag = "grants"
)]
pub async fn revoke_grant(
State(authz): State<Arc<PgAclEngine>>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> impl IntoResponse {
let caller_id = auth_user.id;
let grant_id = match Uuid::parse_str(&id) {
Ok(u) => u,
Err(_) => return AppError::not_found(format!("Grant {id} not found")).into_response(),
};
// Look up the grant to find the underlying resource (and granter).
let on_resource = match find_grant_resource(&authz, grant_id).await {
Ok(Some((res, granter))) => (res, granter),
Ok(None) => return StatusCode::NO_CONTENT.into_response(), // idempotent
Err(e) => return AppError::from(e).into_response(),
};
// Caller is authorized if they are the granter OR have Share on the resource.
if on_resource.1 != caller_id
&& let Err(e) = authz
.require(Subject::User(caller_id), Permission::Share, on_resource.0)
.await
{
return AppError::from(e).into_response();
}
if let Err(e) = authz.revoke(grant_id).await {
return AppError::from(e).into_response();
}
info!("Revoked grant {grant_id} (caller {caller_id})");
StatusCode::NO_CONTENT.into_response()
}
/// Look up a grant by id and return (resource, granted_by) so the caller-auth
/// check in revoke_grant can determine if the caller is the granter or needs
/// the Share permission on the resource. Returns `Ok(None)` if no such grant.
async fn find_grant_resource(
authz: &PgAclEngine,
grant_id: Uuid,
) -> Result<Option<(Resource, Uuid)>, DomainError> {
authz.find_grant_by_id(grant_id).await
}
// ════════════════════════════════════════════════════════════════════════════
// PUT /api/grants/role
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
put,
path = "/api/grants/role",
request_body = UpdateRoleDto,
responses(
(status = 200, description = "Role applied; returns the new full grant set", body = Vec<GrantDto>),
(status = 404, description = "Resource not found or caller lacks Share"),
),
tag = "grants"
)]
pub async fn set_role(
State(authz): State<Arc<PgAclEngine>>,
auth_user: AuthUser,
Json(dto): Json<UpdateRoleDto>,
) -> impl IntoResponse {
let caller_id = auth_user.id;
let subject: Subject = dto.subject.into();
let resource: Resource = dto.resource.into();
let target_perms: std::collections::HashSet<Permission> =
dto.role.expand().iter().copied().collect();
// Caller must have Share on the resource.
if let Err(e) = authz
.require(Subject::User(caller_id), Permission::Share, resource)
.await
{
return AppError::from(e).into_response();
}
// Fetch current grants on the resource for this subject.
let current = match authz.list_grants_on_resource(resource).await {
Ok(g) => g,
Err(e) => return AppError::from(e).into_response(),
};
let current_perms: std::collections::HashSet<Permission> = current
.iter()
.filter(|g| g.subject == subject)
.map(|g| g.permission)
.collect();
// Diff and apply.
let to_add: Vec<Permission> = target_perms.difference(&current_perms).copied().collect();
let to_remove: Vec<Permission> = current_perms.difference(&target_perms).copied().collect();
for perm in &to_remove {
if let Some(g) = current
.iter()
.find(|g| g.subject == subject && g.permission == *perm)
&& let Err(e) = authz.revoke(g.id).await
{
return AppError::from(e).into_response();
}
}
for perm in &to_add {
if let Err(e) = authz.grant(caller_id, subject, *perm, resource).await {
return AppError::from(e).into_response();
}
}
// Return the new full set.
let after = match authz.list_grants_on_resource(resource).await {
Ok(g) => g,
Err(e) => return AppError::from(e).into_response(),
};
let mine: Vec<GrantDto> = after
.into_iter()
.filter(|g| g.subject == subject)
.map(Into::into)
.collect();
info!(
"Role applied: caller={} subject={:?} resource={:?} added={:?} removed={:?}",
caller_id, subject, resource, to_add, to_remove
);
(StatusCode::OK, Json(mine)).into_response()
}
// ════════════════════════════════════════════════════════════════════════════
// GET /api/grants/incoming
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Deserialize, IntoParams)]
pub struct IncomingQuery {
#[serde(default)]
pub permission: Option<PermissionDto>,
}
#[utoipa::path(
get,
path = "/api/grants/incoming",
params(IncomingQuery),
responses(
(status = 200, description = "Direct grants targeting the caller", body = Vec<GrantDto>),
),
tag = "grants"
)]
pub async fn list_incoming(
State(authz): State<Arc<PgAclEngine>>,
auth_user: AuthUser,
Query(q): Query<IncomingQuery>,
) -> impl IntoResponse {
let caller_id = auth_user.id;
match authz
.list_incoming_grants(Subject::User(caller_id), q.permission.map(Into::into))
.await
{
Ok(grants) => {
let dtos: Vec<GrantDto> = grants.into_iter().map(Into::into).collect();
(StatusCode::OK, Json(dtos)).into_response()
}
Err(e) => AppError::from(e).into_response(),
}
}
// ════════════════════════════════════════════════════════════════════════════
// GET /api/grants/outgoing
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
get,
path = "/api/grants/outgoing",
responses(
(status = 200, description = "Grants the caller has created", body = Vec<GrantDto>),
),
tag = "grants"
)]
pub async fn list_outgoing(
State(authz): State<Arc<PgAclEngine>>,
auth_user: AuthUser,
) -> impl IntoResponse {
let caller_id = auth_user.id;
match authz.list_outgoing_grants(caller_id).await {
Ok(grants) => {
let dtos: Vec<GrantDto> = grants.into_iter().map(Into::into).collect();
(StatusCode::OK, Json(dtos)).into_response()
}
Err(e) => AppError::from(e).into_response(),
}
}
// ════════════════════════════════════════════════════════════════════════════
// GET /api/grants?resource_type=...&resource_id=...
// (list grants on a specific resource — requires Share on it)
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Deserialize, IntoParams)]
pub struct OnResourceQuery {
pub resource_type: ResourceTypeDto,
pub resource_id: Uuid,
}
#[utoipa::path(
get,
path = "/api/grants",
params(OnResourceQuery),
responses(
(status = 200, description = "Grants on the specified resource", body = Vec<GrantDto>),
(status = 404, description = "Resource not found or caller lacks Share"),
),
tag = "grants"
)]
pub async fn list_on_resource(
State(authz): State<Arc<PgAclEngine>>,
auth_user: AuthUser,
Query(q): Query<OnResourceQuery>,
) -> impl IntoResponse {
let caller_id = auth_user.id;
let resource: Resource = ResourceDto {
kind: q.resource_type,
id: q.resource_id,
}
.into();
if let Err(e) = authz
.require(Subject::User(caller_id), Permission::Share, resource)
.await
{
return AppError::from(e).into_response();
}
match authz.list_grants_on_resource(resource).await {
Ok(grants) => {
let dtos: Vec<GrantDto> = grants.into_iter().map(Into::into).collect();
(StatusCode::OK, Json(dtos)).into_response()
}
Err(e) => AppError::from(e).into_response(),
}
}
// Silence unused-import warnings for SubjectDto when only certain endpoints
// touch it directly.
#[allow(dead_code)]
fn _ensure_subject_dto_compiles(_: SubjectDto) {}
+1
View File
@@ -11,6 +11,7 @@ pub mod device_auth_handler;
pub mod favorites_handler;
pub mod file_handler;
pub mod folder_handler;
pub mod grant_handler;
pub mod i18n_handler;
pub mod music_handler;
pub mod photos_handler;
@@ -561,6 +561,7 @@ fn share_browse_error_response(err: crate::common::errors::DomainError) -> Respo
AppError::from(err).into_response()
}
// TODO: remove this and use the classic /api/files & /api/folders get, but with the token as session ?
#[utoipa::path(
get,
path = "/api/s/{token}/contents",
+30 -29
View File
@@ -22,7 +22,7 @@ use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::file_ports::{FileManagementUseCase, FileUploadUseCase};
use crate::application::ports::inbound::FolderUseCase;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::storage_ports::StorageUsagePort;
use crate::application::services::file_retrieval_service::FileRetrievalService;
use crate::application::services::folder_service::FolderService;
@@ -189,7 +189,7 @@ async fn handle_webdav_methods(
async fn resolve_webdav_path(state: &Arc<AppState>, user_id: Uuid, path: &str) -> Option<String> {
let folder_service = &state.applications.folder_service;
let home_folders = folder_service
.list_folders_for_owner(None, user_id)
.list_folders_with_perms(None, user_id)
.await
.ok()?;
let home = home_folders.first()?;
@@ -514,7 +514,7 @@ async fn build_streaming_propfind_response(
page_size: pagination.page_size,
};
let result = folder_service
.list_folders_for_owner_paginated(fid_ref, user_id, &pag)
.list_folders_paginated_with_perms(fid_ref, user_id, &pag)
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
@@ -544,7 +544,7 @@ async fn build_streaming_propfind_response(
let mut offset: i64 = 0;
loop {
let batch: Vec<FileDto> = file_retrieval_service
.list_files_batch_for_owner(fid_ref, user_id, offset, PROPFIND_BATCH_SIZE)
.list_files_batch_with_perms(fid_ref, user_id, offset, PROPFIND_BATCH_SIZE)
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
@@ -999,6 +999,7 @@ async fn handle_mkcol(
req: Request<Body>,
path: String,
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let folder_service = &state.applications.folder_service;
if path.is_empty() || path == "/" {
@@ -1041,15 +1042,13 @@ async fn handle_mkcol(
name: segment.to_string(),
parent_id: parent_id.clone(),
};
// Propagate DomainError -> AppError so NotFound/Conflict map to
// their proper HTTP status codes (was: blanket 500 swallowed
// ownership-rejection NotFound from verify_owner).
let created = folder_service
.create_folder(create_dto)
.create_folder_with_perms(create_dto, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!(
"Failed to create folder '{}': {}",
accumulated_path, e
))
})?;
.map_err(AppError::from)?;
parent_id = Some(created.id);
}
}
@@ -1093,7 +1092,7 @@ async fn handle_delete(
match resolver.resolve_path_for_user(&path, user.id).await {
Ok(ResolvedResource::Folder(folder)) => {
folder_service
.delete_folder(&folder.id, user.id)
.delete_folder_with_perms(&folder.id, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to delete folder: {}", e))
@@ -1101,7 +1100,7 @@ async fn handle_delete(
}
Ok(ResolvedResource::File(file)) => {
file_management_service
.delete_file(&file.id)
.delete_file_with_perms(&file.id, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to delete file: {}", e))
@@ -1116,7 +1115,7 @@ async fn handle_delete(
if let Ok(folder) = folder_result {
assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?;
folder_service
.delete_folder(&folder.id, user.id)
.delete_folder_with_perms(&folder.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?;
} else {
@@ -1127,7 +1126,7 @@ async fn handle_delete(
assert_owner(file.owner_id.as_deref(), &user.id.to_string(), &path)?;
file_management_service
.delete_file(&file.id)
.delete_file_with_perms(&file.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?;
}
@@ -1249,7 +1248,7 @@ async fn handle_move(
};
folder_service
.move_folder(&folder.id, move_dto, user.id)
.move_folder_with_perms(&folder.id, move_dto, user.id)
.await
.map_err(AppError::from)?;
@@ -1258,7 +1257,7 @@ async fn handle_move(
name: dest_folder_name.to_string(),
};
folder_service
.rename_folder(&folder.id, rename_dto, user.id)
.rename_folder_with_perms(&folder.id, rename_dto, user.id)
.await
.map_err(AppError::from)?;
}
@@ -1292,13 +1291,13 @@ async fn handle_move(
)?;
}
file_management_service
.move_file(&file.id, Some(dest_parent_path.to_string()))
.move_file_with_perms(&file.id, user.id, Some(dest_parent_path.to_string()))
.await
.map_err(AppError::from)?;
}
if file.name != dest_filename {
file_management_service
.rename_file(&file.id, dest_filename)
.rename_file_with_perms(&file.id, user.id, dest_filename)
.await
.map_err(AppError::from)?;
}
@@ -1350,7 +1349,7 @@ async fn handle_move(
};
folder_service
.move_folder(&folder.id, move_dto, user.id)
.move_folder_with_perms(&folder.id, move_dto, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?;
@@ -1359,7 +1358,7 @@ async fn handle_move(
name: dest_folder_name.to_string(),
};
folder_service
.rename_folder(&folder.id, rename_dto, user.id)
.rename_folder_with_perms(&folder.id, rename_dto, user.id)
.await
.map_err(AppError::from)?;
}
@@ -1399,13 +1398,13 @@ async fn handle_move(
)?;
}
file_management_service
.move_file(&file.id, Some(dest_parent_path.to_string()))
.move_file_with_perms(&file.id, user.id, Some(dest_parent_path.to_string()))
.await
.map_err(AppError::from)?;
}
if file.name != dest_filename {
file_management_service
.rename_file(&file.id, dest_filename)
.rename_file_with_perms(&file.id, user.id, dest_filename)
.await
.map_err(AppError::from)?;
}
@@ -1536,8 +1535,9 @@ async fn handle_copy(
if recursive {
let file_management_service = &state.applications.file_management_service;
file_management_service
.copy_folder_tree(
.copy_folder_tree_with_perms(
&folder.id,
user.id,
target_parent_id,
Some(dest_folder_name.to_string()),
)
@@ -1551,7 +1551,7 @@ async fn handle_copy(
parent_id: target_parent_id,
};
folder_service
.create_folder(create_dto)
.create_folder_with_perms(create_dto, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!(
@@ -1587,7 +1587,7 @@ async fn handle_copy(
let file_management_service = &state.applications.file_management_service;
file_management_service
.copy_file(&file.id, target_folder_id)
.copy_file_with_perms(&file.id, user.id, target_folder_id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to copy file: {}", e)))?;
}
@@ -1640,8 +1640,9 @@ async fn handle_copy(
if recursive {
let file_management_service = &state.applications.file_management_service;
file_management_service
.copy_folder_tree(
.copy_folder_tree_with_perms(
&folder.id,
user.id,
target_parent_id,
Some(dest_folder_name.to_string()),
)
@@ -1655,7 +1656,7 @@ async fn handle_copy(
parent_id: target_parent_id,
};
folder_service
.create_folder(create_dto)
.create_folder_with_perms(create_dto, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!(
@@ -1698,7 +1699,7 @@ async fn handle_copy(
let file_management_service = &state.applications.file_management_service;
file_management_service
.copy_file(&file.id, target_folder_id)
.copy_file_with_perms(&file.id, user.id, target_folder_id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to copy file: {}", e)))?;
}
+1 -1
View File
@@ -401,7 +401,7 @@ async fn authorize_wopi_access<S: FileRetrievalUseCase>(
requested_action: &str,
) -> Result<(crate::application::dtos::file_dto::FileDto, bool), StatusCode> {
let file = file_retrieval
.get_file_owned(file_id, caller_id)
.get_file_with_perms(file_id, caller_id)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
// Owner verified — grant write unless explicitly requesting view-only.
+15
View File
@@ -165,6 +165,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
let share_service = app_state.share_service.clone();
let favorites_service = app_state.favorites_service.clone();
let recent_service = app_state.recent_service.clone();
let authorization = app_state.authorization.clone();
// Initialize the batch operations service
let mut batch_service_builder = BatchOperationService::default(
@@ -301,6 +302,19 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
Router::new()
};
// Create routes for ReBAC grants (/api/grants) — single state: the authz engine.
let grants_router = {
use crate::interfaces::api::handlers::grant_handler;
Router::new()
.route("/", post(grant_handler::create_grant))
.route("/", get(grant_handler::list_on_resource))
.route("/{id}", delete(grant_handler::revoke_grant))
.route("/role", put(grant_handler::set_role))
.route("/incoming", get(grant_handler::list_incoming))
.route("/outgoing", get(grant_handler::list_outgoing))
.with_state(authorization.clone())
};
// Create a router without the i18n routes
// Create routes for favorites if the service is available
let favorites_router = if let Some(favorites_service) = favorites_service.clone() {
@@ -378,6 +392,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
.nest("/batch", batch_router)
.nest("/search", search_router)
.nest("/shares", share_router)
.nest("/grants", grants_router)
.nest("/favorites", favorites_router)
.nest("/recent", recent_router);
+2 -1
View File
@@ -18,7 +18,8 @@ use crate::application::dtos::folder_dto::FolderDto;
use crate::application::dtos::search_dto::SearchCriteriaDto;
use crate::application::ports::favorites_ports::FavoritesUseCase;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::inbound::{FolderUseCase, SearchUseCase};
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::inbound::SearchUseCase;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser;
+11 -10
View File
@@ -17,7 +17,7 @@ use crate::application::ports::favorites_ports::FavoritesUseCase;
use crate::application::ports::file_ports::{
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
};
use crate::application::ports::inbound::FolderUseCase;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::di::AppState;
use crate::common::mime_detect::{filename_from_path, refine_content_type};
@@ -551,6 +551,7 @@ async fn handle_put(
.map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?;
// Update audio metadata for supported audio files.
// TODO: use notification service or hook
if let Some(ref audio_service) = state.applications.audio_metadata_service
&& let Ok(file_id) = uuid::Uuid::parse_str(&updated.id)
{
@@ -655,7 +656,7 @@ async fn handle_mkcol(
name: segment.to_string(),
parent_id: Some(parent_id.clone()),
};
match folder_service.create_folder(dto).await {
match folder_service.create_folder_with_perms(dto, user.id).await {
Ok(created) => {
parent_id = created.id.clone();
}
@@ -731,7 +732,7 @@ async fn handle_delete(
if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await {
folder_service
.delete_folder(&folder.id, user.id)
.delete_folder_with_perms(&folder.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?;
@@ -743,7 +744,7 @@ async fn handle_delete(
if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
file_mgmt
.delete_file(&file.id)
.delete_file_with_perms(&file.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?;
@@ -797,7 +798,7 @@ async fn handle_move(
if src_parent_sub == dest_parent_sub {
// Same parent → rename.
file_mgmt
.rename_file(&file.id, dest_name)
.rename_file_with_perms(&file.id, user.id, dest_name)
.await
.map_err(|e| AppError::internal_error(format!("Rename failed: {}", e)))?;
} else {
@@ -808,14 +809,14 @@ async fn handle_move(
.map_err(|_| AppError::not_found("Destination folder not found"))?;
file_mgmt
.move_file(&file.id, Some(dest_parent.id.clone()))
.move_file_with_perms(&file.id, user.id, Some(dest_parent.id.clone()))
.await
.map_err(|e| AppError::internal_error(format!("Move failed: {}", e)))?;
// If the filename changed too, rename after move.
if file.name != dest_name {
file_mgmt
.rename_file(&file.id, dest_name)
.rename_file_with_perms(&file.id, user.id, dest_name)
.await
.map_err(|e| AppError::internal_error(format!("Rename failed: {}", e)))?;
}
@@ -850,7 +851,7 @@ async fn handle_move(
// Same parent → rename.
use crate::application::dtos::folder_dto::RenameFolderDto;
folder_service
.rename_folder(
.rename_folder_with_perms(
&folder.id,
RenameFolderDto {
name: dest_name.to_string(),
@@ -868,7 +869,7 @@ async fn handle_move(
use crate::application::dtos::folder_dto::MoveFolderDto;
folder_service
.move_folder(
.move_folder_with_perms(
&folder.id,
MoveFolderDto {
parent_id: Some(dest_parent.id.clone()),
@@ -882,7 +883,7 @@ async fn handle_move(
if folder.name != dest_name {
use crate::application::dtos::folder_dto::RenameFolderDto;
folder_service
.rename_folder(
.rename_folder_with_perms(
&folder.id,
RenameFolderDto {
name: dest_name.to_string(),
File diff suppressed because it is too large Load Diff
+344
View File
@@ -0,0 +1,344 @@
# =============================================================
# OxiCloud – Cross-user permission / IDOR scenarios
# =============================================================
# Verifies the ownership checks added to FolderService::create_folder
# and FileManagementService move/copy/rename, plus the shared
# FolderDbRepository::verify_owner helper.
#
# Plan reference: /Users/ed/.claude/plans/compiled-shimmying-bonbon.md
# — "Verification → 2. Manual integration tests"
#
# Run via tests/api/run.sh; must be ordered LAST in the runner because
# it creates a second user (bob) and writes into admin's home folder.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 – Login as admin (the user created by setup.hurl)
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{
"username": "{{username}}",
"password": "{{password}}"
}
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 2 – Capture admin's home folder
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders
Authorization: Bearer {{admin_token}}
HTTP 200
[Captures]
admin_home_id: jsonpath "$[0].id"
# ─────────────────────────────────────────────────────────────
# Step 3 – Admin creates a private folder inside their home
# This is the resource bob will attempt to attack.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/folders
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"name": "admin-private-folder",
"parent_id": "{{admin_home_id}}"
}
HTTP 201
[Captures]
admin_private_id: jsonpath "$.id"
[Asserts]
jsonpath "$.name" == "admin-private-folder"
jsonpath "$.parent_id" == {{admin_home_id}}
# ─────────────────────────────────────────────────────────────
# Step 4 – Admin uploads a file into their home
# This is the file bob will attempt to access.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/upload
Authorization: Bearer {{admin_token}}
[MultipartFormData]
folder_id: {{admin_home_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 201
[Captures]
admin_file_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# Step 5 – Admin creates user bob (via /api/admin/users)
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"username": "bob",
"password": "BobPassword1!",
"email": "bob@example.com",
"role": "user"
}
HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 6 – Login as bob, capture his token + home folder
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{
"username": "bob",
"password": "BobPassword1!"
}
HTTP 200
[Captures]
bob_token: jsonpath "$.access_token"
GET {{base_url}}/api/folders
Authorization: Bearer {{bob_token}}
HTTP 200
[Captures]
bob_home_id: jsonpath "$[0].id"
[Asserts]
jsonpath "$" count == 1
jsonpath "$[0].parent_id" == null
# ═════════════════════════════════════════════════════════════
# IDOR tests — every request below uses bob's token
# ═════════════════════════════════════════════════════════════
# ─────────────────────────────────────────────────────────────
# Step 7 – Bob attempts to create a folder inside admin's home
# Expected: 404 (NotFound, not 403, to avoid leaking
# the existence of admin's folder).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/folders
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{
"name": "bob-attack-1",
"parent_id": "{{admin_home_id}}"
}
HTTP 404
[Asserts]
jsonpath "$.error_type" == "Not Found"
# ─────────────────────────────────────────────────────────────
# Step 8 – Bob attempts to create a folder inside admin's
# private folder. Same expectation as Step 7.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/folders
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{
"name": "bob-attack-2",
"parent_id": "{{admin_private_id}}"
}
HTTP 404
[Asserts]
jsonpath "$.error_type" == "Not Found"
# ─────────────────────────────────────────────────────────────
# Step 9 – Bob omits parent_id (null). The REST handler
# auto-resolves null to the caller's home folder
# (folder_handler.rs:55-77), so the request succeeds
# and the folder lands in bob's home — NOT at the
# database root. The service-level validation_error
# ("Root folder creation is reserved for registration")
# is defense-in-depth for callers that bypass this
# handler convenience.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/folders
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{
"name": "bob-auto-resolved",
"parent_id": null
}
HTTP 201
[Asserts]
jsonpath "$.name" == "bob-auto-resolved"
jsonpath "$.parent_id" == {{bob_home_id}}
# ─────────────────────────────────────────────────────────────
# Step 10 – Positive control: bob CAN create a folder inside
# his own home.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/folders
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{
"name": "bob-own-folder",
"parent_id": "{{bob_home_id}}"
}
HTTP 201
[Captures]
bob_folder_id: jsonpath "$.id"
[Asserts]
jsonpath "$.name" == "bob-own-folder"
jsonpath "$.parent_id" == {{bob_home_id}}
# ─────────────────────────────────────────────────────────────
# Step 11 – Bob uploads a file into his own home (for the
# file-move tests below).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/upload
Authorization: Bearer {{bob_token}}
[MultipartFormData]
folder_id: {{bob_home_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 201
[Captures]
bob_file_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# Step 12 – Bob attempts to move his own file into admin's
# private folder. He owns the file but not the target
# → verify_target_folder_owner rejects with 404.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/files/{{bob_file_id}}/move
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{
"folder_id": "{{admin_private_id}}"
}
HTTP 404
[Asserts]
jsonpath "$.error_type" == "Not Found"
# ─────────────────────────────────────────────────────────────
# Step 13 – Bob moves his file to folder_id: null (his root
# namespace). storage.files.folder_id IS NULL is a
# legitimate state — verify_target_folder_owner
# short-circuits to Ok(()) when target is None.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/files/{{bob_file_id}}/move
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{
"folder_id": null
}
HTTP 200
[Asserts]
jsonpath "$.id" == {{bob_file_id}}
jsonpath "$.folder_id" == null
# ─────────────────────────────────────────────────────────────
# Step 14 – Bob attempts to access admin's file directly.
# verify_owner on the file (not the folder) catches
# this — IDOR on file reads, also 404.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/files/{{admin_file_id}}
Authorization: Bearer {{bob_token}}
HTTP 404
[Asserts]
jsonpath "$.error_type" == "Not Found"
# ─────────────────────────────────────────────────────────────
# Step 15 – Admin's private folder still exists & is untouched.
# Bob's attacks must not have polluted admin's tree.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders/{{admin_home_id}}/contents
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$[*].id" contains {{admin_private_id}}
jsonpath "$[*].name" not contains "bob-attack-1"
jsonpath "$[*].name" not contains "bob-attack-2"
# ═════════════════════════════════════════════════════════════
# WebDAV MKCOL — namespace isolation
# ═════════════════════════════════════════════════════════════
# WebDAV requests are isolated per-user by `resolve_webdav_path`
# (webdav_handler.rs:189). If the requested path doesn't begin
# with the caller's home folder name ("My Folder - <username>"),
# the handler silently prefixes the caller's home folder path
# onto the front. Effect: any WebDAV path a client sends is
# always resolved INSIDE the caller's own tree, regardless of
# what they wrote.
#
# These tests assert the isolation works (regression guard) and
# that the service-level verify_owner still acts as
# defense-in-depth for the legitimate path.
# ─────────────────────────────────────────────────────────────
# Step 16 – Bob crafts a path that looks like it targets admin's
# home. The WebDAV handler rewrites the path to live
# under bob's home, so the request succeeds (201) but
# the new folders land in BOB's tree — never admin's.
# ─────────────────────────────────────────────────────────────
MKCOL {{base_url}}/webdav/My%20Folder%20-%20admin/bob-webdav-attack
Authorization: Bearer {{bob_token}}
HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 17 – Positive control: bob MKCOL inside his own home.
# ─────────────────────────────────────────────────────────────
MKCOL {{base_url}}/webdav/My%20Folder%20-%20bob/bob-webdav-own
Authorization: Bearer {{bob_token}}
HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 18 – Bob's home now contains:
# - "bob-webdav-own" (from Step 17, normal MKCOL)
# - "My Folder - admin" (from Step 16 — the prefix
# rewrite turned admin's home name into a literal
# sub-folder name inside bob's tree).
# This proves the path prefix re-rooted the attack
# into bob's own namespace.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders/{{bob_home_id}}/contents
Authorization: Bearer {{bob_token}}
HTTP 200
[Asserts]
jsonpath "$[*].name" contains "bob-webdav-own"
jsonpath "$[*].name" contains "My Folder - admin"
# ─────────────────────────────────────────────────────────────
# Step 19 – Admin's tree is unchanged by bob's WebDAV traffic.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders/{{admin_home_id}}/contents
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$[*].name" not contains "bob-webdav-attack"
jsonpath "$[*].name" not contains "bob-webdav-own"
+3 -1
View File
@@ -96,7 +96,9 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$API_DIR/recent.hurl" \
"$API_DIR/batch_folder_copy.hurl" \
"$API_DIR/dedup_blob_cleanup.hurl" \
"$API_DIR/contacts.hurl"
"$API_DIR/contacts.hurl" \
"$API_DIR/permissions.hurl" \
"$API_DIR/grants.hurl"
#bash "$API_DIR/dedup_bulk_upload.sh"
+31
View File
@@ -64,6 +64,37 @@ assert_local_blob_existsy "$FIXTURE" "$STORAGE_PATH" || fail "probe blob not fou
assert_preview_existsy "$FIXTURE" "$STORAGE_PATH" || fail "probe thumbnail not found on disk"
log "Probe blob and thumbnail confirmed present on disk."
# ── 1c. Delete every non-admin user created by earlier Hurl tests ─────────────
#
# Tests like permissions.hurl and grants.hurl create user accounts (bob,
# dave, eve, adam, frank, …) that own their own folders/files. The probe
# cleanup below only sees admin-owned roots, so those other users' files
# would leak as orphan blobs on disk. Deleting the users cascades through
# the schema (storage.folders/storage.files via ON DELETE CASCADE), which
# fires the file-delete trigger and decrements blob ref_counts. The
# subsequent trash-empty triggers garbage_collect() to remove the
# now-orphaned blob files from disk.
# /api/admin/users returns { users: [...], total, limit, offset }
USERS_JSON=$(curl -sf -H "$AUTH" "$base_url/api/admin/users?limit=500")
ADMIN_USER_ID=$(echo "$USERS_JSON" \
| jq -r --arg u "$username" '.users[] | select(.username == $u) | .id')
[[ -z "$ADMIN_USER_ID" || "$ADMIN_USER_ID" == "null" ]] && fail "could not resolve admin user id"
OTHER_USER_IDS=$(echo "$USERS_JSON" \
| jq -r --arg admin_id "$ADMIN_USER_ID" '.users[] | select(.id != $admin_id) | .id')
OTHER_USER_COUNT=0
while IFS= read -r uid; do
[[ -z "$uid" ]] && continue
OTHER_USER_COUNT=$((OTHER_USER_COUNT + 1))
curl -sf -X DELETE -H "$AUTH" "$base_url/api/admin/users/$uid" >/dev/null \
|| fail "failed to delete user $uid"
done <<< "$OTHER_USER_IDS"
log "Deleted $OTHER_USER_COUNT non-admin user(s) created by tests."
# ── 2. Move all live files and folders to trash ───────────────────────────────
#
# For each root folder, list its direct children and soft-delete them.
+1
View File
@@ -17,6 +17,7 @@ OXICLOUD_WOPI_ENABLED=false
OXICLOUD_OIDC_ENABLED=false
RUST_LOG=warn
#RUST_LOG=debug
#RUST_LOG=info
# grow up limits for tests
OXICLOUD_RATE_LIMIT_REFRESH_MAX=120
+8 -2
View File
@@ -1,6 +1,9 @@
#!/bin/bash
source test.env
if [ -z "$base_url" ]
then
source test.env
fi
err() {
echo "$*" >&2
@@ -32,7 +35,10 @@ oxicloud_setup() {
# returns TOKEN variable
oxicloud_login() {
oxicloud_setup
if [[ ( $# -eq 0 ) || ( "$1" != "no-create" ) ]]
then
oxicloud_setup
fi
LOGIN_DATA='{"username":"'$username'","password":"'$password'"}'