diff --git a/CLAUDE.md b/CLAUDE.md index a5a38694..e3b5e70c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/migrations/20260520000000_rebac_access_grants.sql b/migrations/20260520000000_rebac_access_grants.sql new file mode 100644 index 00000000..c0b18721 --- /dev/null +++ b/migrations/20260520000000_rebac_access_grants.sql @@ -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' + ); diff --git a/src/application/dtos/grant_dto.rs b/src/application/dtos/grant_dto.rs new file mode 100644 index 00000000..d45f01ae --- /dev/null +++ b/src/application/dtos/grant_dto.rs @@ -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 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 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 for Resource { + fn from(dto: ResourceDto) -> Self { + match dto.kind { + ResourceTypeDto::Folder => Resource::Folder(dto.id), + ResourceTypeDto::File => Resource::File(dto.id), + } + } +} + +impl From 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 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 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>, + #[serde(default)] + pub role: Option, +} + +/// `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, +} + +impl From 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, + } + } +} diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index 6efa5558..52cb3135 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -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; diff --git a/src/application/ports/authorization_ports.rs b/src/application/ports/authorization_ports.rs new file mode 100644 index 00000000..2ded9878 --- /dev/null +++ b/src/application/ports/authorization_ports.rs @@ -0,0 +1,87 @@ +//! 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; + + /// 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? { + 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, + ) -> Result, 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, 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, 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; + + /// 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; + + /// 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; +} diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index cccc6c8e..8988aad0 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -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; diff --git a/src/application/services/batch_operations_test.rs b/src/application/services/batch_operations_test.rs index b0039e82..a01e145c 100644 --- a/src/application/services/batch_operations_test.rs +++ b/src/application/services/batch_operations_test.rs @@ -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, diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 98c1aad4..f234e0f7 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -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,41 +26,32 @@ use uuid::Uuid; /// touches ref_count directly. pub struct FileManagementService { file_repository: Arc, - file_read: Option>, - folder_repo: Option>, trash_service: Option>, content_cache: Option>, + authz: Arc, /// Hooks fired after a file is permanently deleted. file_deleted_hooks: Vec>, } impl FileManagementService { - /// Creates a new FileManagementService. - pub fn new(file_repository: Arc) -> 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, trash_service: Option>, - file_read: Option>, - folder_repo: Option>, + _file_read: Option>, + _folder_repo: Option>, content_cache: Option>, + authz: Arc, ) -> Self { Self { file_repository, - file_read, - folder_repo, trash_service, content_cache, + authz, file_deleted_hooks: Vec::new(), } } @@ -68,40 +62,35 @@ impl FileManagementService { 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", - )) - } + /// Engine check for a file resource. Parses the id into a `Uuid` and + /// requires the specified permission. + async fn require_file_perm( + &self, + file_id: &str, + perm: Permission, + caller_id: Uuid, + ) -> Result<(), DomainError> { + 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 } - /// Verifies that the target folder is owned by the caller. - /// - /// `None` means the target is the user's root namespace - /// (`storage.files.folder_id IS NULL`) — implicitly owned by the caller, so - /// the check is skipped. Fails closed if `folder_repo` was not injected. - async fn verify_target_folder_owner( + /// 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 { - // TODO: File creation to root is currently allowed, check is this policy is relevant return Ok(()); }; - let Some(folder_repo) = &self.folder_repo else { - return Err(DomainError::internal_error( - "FileManagement", - "Folder ownership verification unavailable", - )); - }; - folder_repo.verify_owner(target, caller_id).await + 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 { @@ -242,10 +231,10 @@ impl FileManagementUseCase for FileManagementService { caller_id: Uuid, folder_id: Option, ) -> Result { - // 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.as_deref(), caller_id) + // 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 } @@ -256,8 +245,10 @@ impl FileManagementUseCase for FileManagementService { caller_id: Uuid, target_folder_id: Option, ) -> Result { - self.verify_owner(file_id, caller_id).await?; - self.verify_target_folder_owner(target_folder_id.as_deref(), caller_id) + // 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 } @@ -268,12 +259,14 @@ impl FileManagementUseCase for FileManagementService { caller_id: Uuid, new_name: &str, ) -> Result { - self.verify_owner(file_id, caller_id).await?; + 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.verify_owner(id, caller_id).await?; + self.require_file_perm(id, Permission::Delete, caller_id) + .await?; self.delete_file(id).await } @@ -288,7 +281,8 @@ impl FileManagementUseCase for FileManagementService { id: &str, caller_id: Uuid, ) -> Result { - self.verify_owner(id, caller_id).await?; + 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); @@ -328,11 +322,10 @@ impl FileManagementUseCase for FileManagementService { target_parent_id: Option, dest_name: Option, ) -> Result { - // Source ownership: source_folder_id is required (not optional), but reuse the - // wrapper which also enforces the fail-closed semantics if folder_repo is absent. - self.verify_target_folder_owner(Some(source_folder_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.verify_target_folder_owner(target_parent_id.as_deref(), caller_id) + 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 diff --git a/src/application/services/file_retrieval_service.rs b/src/application/services/file_retrieval_service.rs index 2587b9af..037049a6 100644 --- a/src/application/services/file_retrieval_service.rs +++ b/src/application/services/file_retrieval_service.rs @@ -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,31 +32,55 @@ pub struct FileRetrievalService { file_read: Arc, content_cache: Option>, transcode: Option>, + authz: Option>, } 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) -> 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, content_cache: Arc, transcode: Arc, + authz: Arc, ) -> Self { Self { file_read, content_cache: Some(content_cache), transcode: Some(transcode), + authz: Some(authz), } } + /// 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 + } + // ── private helpers ────────────────────────────────────────── /// Try to transcode image content to WebP and return transcoded variant. @@ -203,12 +230,17 @@ impl FileRetrievalUseCase for FileRetrievalService { } async fn get_file_owned(&self, id: &str, caller_id: Uuid) -> Result { - 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?; Ok(FileDto::from(file)) } async fn get_file_by_path(&self, path: &str) -> Result { // 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)); } @@ -248,7 +280,7 @@ impl FileRetrievalUseCase for FileRetrievalService { id: &str, caller_id: Uuid, ) -> Result> + 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 } @@ -272,7 +304,8 @@ impl FileRetrievalUseCase for FileRetrievalService { 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 @@ -307,8 +340,7 @@ impl FileRetrievalUseCase for FileRetrievalService { start: u64, end: Option, ) -> Result> + 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 } diff --git a/src/application/services/file_use_case_factory.rs b/src/application/services/file_use_case_factory.rs index fe092361..83c259ab 100644 --- a/src/application/services/file_use_case_factory.rs +++ b/src/application/services/file_use_case_factory.rs @@ -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, file_write_repository: Arc, + authz: Arc, } impl AppFileUseCaseFactory { @@ -18,10 +20,12 @@ impl AppFileUseCaseFactory { pub fn new( file_read_repository: Arc, file_write_repository: Arc, + authz: Arc, ) -> 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 { - 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(), )) } } diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 7c7ee192..0a3fc2df 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -1,23 +1,39 @@ use crate::application::dtos::folder_dto::{ CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, }; +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, + authz: Arc, } impl FolderService { /// Creates a new folder service - pub fn new(folder_storage: Arc) -> Self { - Self { folder_storage } + pub fn new(folder_storage: Arc, authz: Arc) -> 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 { + Uuid::parse_str(id) + .map(Resource::Folder) + .map_err(|_| DomainError::not_found("Folder", id)) } /// Creates a stub implementation for testing and middleware @@ -159,8 +175,13 @@ impl FolderUseCase for FolderService { "Root folder creation is reserved for registration", )); }; - self.folder_storage - .verify_owner(parent_id, caller_id) + let parent_resource = Self::folder_resource(parent_id)?; + self.authz + .require( + Subject::User(caller_id), + Permission::Create, + parent_resource, + ) .await?; let folder = self @@ -207,23 +228,21 @@ impl FolderUseCase for FolderService { Ok(FolderDto::from(folder)) } - /// Gets a folder by its ID, enforcing that `caller_id` is the owner. + /// 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 { - 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) + 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 @@ -395,14 +414,13 @@ impl FolderUseCase for FolderService { Ok(response) } - /// Renames a folder after verifying ownership. + /// 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 { - // Input validation if let Err(reason) = validate_storage_name(&dto.name) { return Err(DomainError::validation_error(format!( "Invalid folder name '{}': {reason}", @@ -410,20 +428,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) @@ -438,29 +450,25 @@ impl FolderUseCase for FolderService { Ok(FolderDto::from(folder)) } - /// Moves a folder to a new parent after verifying ownership. + /// 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 { - // 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, @@ -468,27 +476,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 @@ -504,22 +502,18 @@ impl FolderUseCase for FolderService { Ok(FolderDto::from(folder)) } - /// Deletes a folder after verifying ownership. + /// 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> { - // Verify the folder exists and belongs to the caller - let folder = self.folder_storage.get_folder(id).await?; + 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", diff --git a/src/common/di.rs b/src/common/di.rs index d0f5a5fe..ffabe91c 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -350,9 +350,13 @@ impl AppServiceFactory { repos: &RepositoryServices, trash_service: Option>, db_pool: &Arc, + authz: &Arc, ) -> 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 +378,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,6 +389,7 @@ 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()), ); @@ -391,6 +397,7 @@ impl AppServiceFactory { 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())); @@ -605,9 +612,22 @@ impl AppServiceFactory { // 3. Trash service (needed before application services) let trash_service = self.create_trash_service(&repos, &core).await; - // 4. Application services (with trash already wired) - let mut apps = - self.create_application_services(&core, &repos, trash_service.clone(), &pool); + // 3b. Authorization engine — must exist before application services + // because services hold an Arc for ReBAC checks. + let authorization = build_authorization_engine( + pool.clone(), + repos.folder_repository.clone(), + repos.file_read_repository.clone(), + ); + + // 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 +797,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 @@ -1092,6 +1113,38 @@ pub struct AppState { Option>, pub webdav_lock_store: Arc, + /// 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` (with + /// `async_trait` boxing). + pub authorization: Arc, } // 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, + folder_repo: Arc< + crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository, + >, + file_repo: Arc< + crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository, + >, +) -> Arc { + 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)) +} diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs new file mode 100644 index 00000000..98036d81 --- /dev/null +++ b/src/domain/services/authorization.rs @@ -0,0 +1,205 @@ +//! 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 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 { + match subject_type { + "user" => Some(Subject::User(id)), + "group" => Some(Subject::Group(id)), + "token" => Some(Subject::Token(id)), + "external" => Some(Subject::External(id)), + _ => None, + } + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// Resource — what the permission is on +// ════════════════════════════════════════════════════════════════════════════ + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Resource { + Folder(Uuid), + File(Uuid), +} + +impl Resource { + pub fn type_str(&self) -> &'static str { + match self { + Resource::Folder(_) => "folder", + Resource::File(_) => "file", + } + } + + pub fn id(&self) -> Uuid { + match self { + Resource::Folder(id) | Resource::File(id) => *id, + } + } + + pub fn from_parts(resource_type: &str, id: Uuid) -> Option { + match resource_type { + "folder" => Some(Resource::Folder(id)), + "file" => Some(Resource::File(id)), + _ => None, + } + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// 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 { + 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, + } + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// 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, +} + +#[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()); + } +} diff --git a/src/domain/services/mod.rs b/src/domain/services/mod.rs index 83d4df8f..93be8d78 100644 --- a/src/domain/services/mod.rs +++ b/src/domain/services/mod.rs @@ -1,3 +1,4 @@ +pub mod authorization; pub mod i18n_service; pub mod path_service; diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 82f4edb1..b84d346f 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -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 { + 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 { diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 1952d231..5d625379 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -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; diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs new file mode 100644 index 00000000..6711b110 --- /dev/null +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -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, + folder_repo: Arc, + file_repo: Arc, +} + +impl PgAclEngine { + pub fn new( + pool: Arc, + folder_repo: Arc, + file_repo: Arc, + ) -> 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::::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 { + 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 { + let exists: Option = 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 { + let exists: Option = 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, 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, + ), + ) -> Result { + 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 { + // 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, + ) -> Result, 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, + ), + >( + 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, DomainError> { + let rows = sqlx::query_as::< + _, + ( + Uuid, + String, + Uuid, + String, + Uuid, + String, + Uuid, + chrono::DateTime, + ), + >( + 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, DomainError> { + let rows = sqlx::query_as::< + _, + ( + Uuid, + String, + Uuid, + String, + Uuid, + String, + Uuid, + chrono::DateTime, + ), + >( + 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 { + // 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, + ), + >( + 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 { + 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 { + 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) + } +} diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs new file mode 100644 index 00000000..b769fa7c --- /dev/null +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -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), + (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>, + auth_user: AuthUser, + Json(dto): Json, +) -> impl IntoResponse { + let caller_id = auth_user.id; + + // Validate: exactly one of permissions/role + let permissions: Vec = 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 = 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>, + auth_user: AuthUser, + Path(id): Path, +) -> 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, 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), + (status = 404, description = "Resource not found or caller lacks Share"), + ), + tag = "grants" +)] +pub async fn set_role( + State(authz): State>, + auth_user: AuthUser, + Json(dto): Json, +) -> 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 = + 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 = current + .iter() + .filter(|g| g.subject == subject) + .map(|g| g.permission) + .collect(); + + // Diff and apply. + let to_add: Vec = target_perms.difference(¤t_perms).copied().collect(); + let to_remove: Vec = 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 = 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, +} + +#[utoipa::path( + get, + path = "/api/grants/incoming", + params(IncomingQuery), + responses( + (status = 200, description = "Direct grants targeting the caller", body = Vec), + ), + tag = "grants" +)] +pub async fn list_incoming( + State(authz): State>, + auth_user: AuthUser, + Query(q): Query, +) -> 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 = 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), + ), + tag = "grants" +)] +pub async fn list_outgoing( + State(authz): State>, + auth_user: AuthUser, +) -> impl IntoResponse { + let caller_id = auth_user.id; + match authz.list_outgoing_grants(caller_id).await { + Ok(grants) => { + let dtos: Vec = 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), + (status = 404, description = "Resource not found or caller lacks Share"), + ), + tag = "grants" +)] +pub async fn list_on_resource( + State(authz): State>, + auth_user: AuthUser, + Query(q): Query, +) -> 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 = 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) {} diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index 39a0ecfd..caa75884 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -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; diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index faa58e20..872ec605 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -165,6 +165,7 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { 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) -> Router> { 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) -> Router> { .nest("/batch", batch_router) .nest("/search", search_router) .nest("/shares", share_router) + .nest("/grants", grants_router) .nest("/favorites", favorites_router) .nest("/recent", recent_router); diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl new file mode 100644 index 00000000..d5378479 --- /dev/null +++ b/tests/api/grants.hurl @@ -0,0 +1,303 @@ +# ============================================================= +# OxiCloud — ReBAC grant management (POST/DELETE/GET /api/grants) +# ============================================================= +# Exercises cross-user grants, cascading, roles, revoke, lifecycle +# cleanup. Uses ONLY endpoints that route through the +# AuthorizationEngine — handler-layer inline checks (e.g. +# GET /api/folders/{id}) are scheduled for cleanup separately. +# +# Runs AFTER permissions.hurl (bob already exists). Self-contained +# resources (unique names) so it doesn't depend on prior state. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Login as admin (Alice), capture token + home folder. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +alice_token: jsonpath "$.access_token" + +GET {{base_url}}/api/folders +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Captures] +alice_home_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Create two test users specific to this file (dave + eve). +# Avoids cross-file dependencies on bob from permissions.hurl +# and gives us their user_id directly from the create response. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "username": "dave", "password": "DavePassword1!", "email": "dave@example.com", "role": "user" } + +HTTP 201 +[Captures] +dave_user_id: jsonpath "$.id" + +POST {{base_url}}/api/admin/users +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "username": "eve", "password": "EvePassword1!", "email": "eve@example.com", "role": "user" } + +HTTP 201 +[Captures] +eve_user_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Login dave and eve. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "dave", "password": "DavePassword1!" } + +HTTP 200 +[Captures] +dave_token: jsonpath "$.access_token" + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "eve", "password": "EvePassword1!" } + +HTTP 200 +[Captures] +eve_token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Alice creates a folder "grant-shared" + a child "grant-child". +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "name": "grant-shared", "parent_id": "{{alice_home_id}}" } + +HTTP 201 +[Captures] +shared_folder_id: jsonpath "$.id" + +POST {{base_url}}/api/folders +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "name": "grant-child", "parent_id": "{{shared_folder_id}}" } + +HTTP 201 +[Captures] +child_folder_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Without any grant, bob cannot rename Alice's folder. +# PUT /api/folders/{id}/rename goes through the engine → +# 404 (anti-enumeration). +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename +Authorization: Bearer {{dave_token}} +Content-Type: application/json +{ "name": "bob-tried" } + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 6 — Alice grants Bob the Viewer role. Server expands → [read]. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{dave_user_id}}" }, + "resource": { "type": "folder", "id": "{{shared_folder_id}}" }, + "role": "viewer" +} + +HTTP 201 +[Asserts] +jsonpath "$" count == 1 +jsonpath "$[0].permission" == "read" + + +# ───────────────────────────────────────────────────────────── +# Step 7 — Viewer cannot rename (no update grant). +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename +Authorization: Bearer {{dave_token}} +Content-Type: application/json +{ "name": "bob-tried-again" } + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 8 — Bob's incoming grants list contains the new grant. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/grants/incoming +Authorization: Bearer {{dave_token}} + +HTTP 200 +[Asserts] +jsonpath "$[?(@.resource.id=='{{shared_folder_id}}')].permission" == "read" + + +# ───────────────────────────────────────────────────────────── +# Step 9 — Promote Bob to Manager (adds comment, create, update, share). +# PUT /api/grants/role reconciles the row set in one call. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/grants/role +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{dave_user_id}}" }, + "resource": { "type": "folder", "id": "{{shared_folder_id}}" }, + "role": "manager" +} + +HTTP 200 +[Asserts] +jsonpath "$" count == 5 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Bob can now rename (Manager includes update). +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename +Authorization: Bearer {{dave_token}} +Content-Type: application/json +{ "name": "renamed-by-bob-as-manager" } + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 11 — Cascading: Bob can also rename the CHILD folder, because +# his Update grant on the parent cascades via ltree to the +# child resource — even though no direct grant on the child. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/folders/{{child_folder_id}}/rename +Authorization: Bearer {{dave_token}} +Content-Type: application/json +{ "name": "renamed-child-via-cascade" } + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 12 — Bob re-shares to Carol (he has Share via Manager). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{dave_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{eve_user_id}}" }, + "resource": { "type": "folder", "id": "{{shared_folder_id}}" }, + "role": "viewer" +} + +HTTP 201 +[Captures] +eve_grant_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 13 — Carol can see the grant in her incoming list. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/grants/incoming +Authorization: Bearer {{eve_token}} + +HTTP 200 +[Asserts] +jsonpath "$[?(@.resource.id=='{{shared_folder_id}}')].permission" == "read" + + +# ───────────────────────────────────────────────────────────── +# Step 14 — Bob's outgoing grants list contains the grant to Carol. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/grants/outgoing +Authorization: Bearer {{dave_token}} + +HTTP 200 +[Asserts] +jsonpath "$[?(@.id=='{{eve_grant_id}}')].id" == "{{eve_grant_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 15 — Demote Bob to Viewer; he loses update/share/etc. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/grants/role +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{dave_user_id}}" }, + "resource": { "type": "folder", "id": "{{shared_folder_id}}" }, + "role": "viewer" +} + +HTTP 200 +[Asserts] +jsonpath "$" count == 1 +jsonpath "$[0].permission" == "read" + + +# ───────────────────────────────────────────────────────────── +# Step 16 — Demoted Bob can no longer rename. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename +Authorization: Bearer {{dave_token}} +Content-Type: application/json +{ "name": "bob-tried-after-demote" } + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 17 — Lifecycle: Alice deletes the folder. The DB trigger +# trg_cleanup_grants_folder removes both bob's and carol's +# grants automatically (also for the cascade-deleted child). +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/folders/{{child_folder_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 + +DELETE {{base_url}}/api/folders/{{shared_folder_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 + +DELETE {{base_url}}/api/trash/empty +Authorization: Bearer {{alice_token}} + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 18 — After permanent delete, Bob's incoming list no longer +# contains the deleted folder's grant. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/grants/incoming +Authorization: Bearer {{dave_token}} + +HTTP 200 +[Asserts] +jsonpath "$" count == 0 + + +# ───────────────────────────────────────────────────────────── +# Step 19 — Same for Carol. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/grants/incoming +Authorization: Bearer {{eve_token}} + +HTTP 200 +[Asserts] +jsonpath "$" count == 0 diff --git a/tests/api/run.sh b/tests/api/run.sh index 48e9f438..3284c903 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -97,7 +97,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/batch_folder_copy.hurl" \ "$API_DIR/dedup_blob_cleanup.hurl" \ "$API_DIR/contacts.hurl" \ - "$API_DIR/permissions.hurl" + "$API_DIR/permissions.hurl" \ + "$API_DIR/grants.hurl" #bash "$API_DIR/dedup_bulk_upload.sh"