Merge pull request #383 from EdouardVanbelle/permissions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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
@@ -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
@@ -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)>;
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¶ms);
|
||||
|
||||
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(),
|
||||
}
|
||||
|
||||
@@ -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(¤t_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) {}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)))?;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'"}'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user