feat(rebac): first pass

This commit is contained in:
Edouard Vanbelle
2026-05-20 22:56:00 +02:00
parent 2c53f99089
commit cba9be8c21
22 changed files with 2058 additions and 153 deletions
+6
View File
@@ -119,6 +119,12 @@ Never duplicate logic across handlers or services. If the same behaviour is need
- Reusable infrastructure behaviour → method on the relevant service struct
- Shared port behaviour → default method on the trait
### Authorization (AuthZ)
**AuthZ is enforced exclusively in the application service layer, never in handlers.** All permission checks go through `AuthorizationEngine` (port: `application/ports/authorization_ports.rs`) via service methods named with the `_with_perms` suffix. HTTP handlers (REST, WebDAV, NextCloud, CalDAV, CardDAV) authenticate the caller and pass `caller_id` into the service — they MUST NOT perform their own ownership/permission checks. The authentication middleware extracts the caller; the service decides if the action is allowed.
This rule prevents drift between layers and ensures every code path goes through the same policy. New service methods that touch a user-scoped resource must take `caller_id: Uuid` and call `authz.require(...)` before any read or mutation.
# Frontend part
## Code conventions
@@ -0,0 +1,161 @@
-- ════════════════════════════════════════════════════════════════════════════
-- ReBAC: access_grants table + lifecycle cleanup triggers + data migration
-- ════════════════════════════════════════════════════════════════════════════
-- PR 1 of the ReBAC rollout. Schema and data only — no code changes yet.
--
-- This migration:
-- 1. Creates storage.access_grants (the single grant table for ReBAC)
-- 2. Installs AFTER DELETE triggers so lifecycle cleanup is enforced at the
-- DB level even if a future code path bypasses the service layer
-- 3. Migrates existing storage.shares permission flags into access_grants
-- rows with subject_type='token'
--
-- The storage.shares.permissions_* columns are NOT dropped here. They stay
-- until PR 5 (share_service is updated to read from access_grants instead).
-- See /Users/ed/.claude/plans/compiled-shimmying-bonbon.md → "Rollout sequencing".
-- ── 1. The grant table ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS storage.access_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Subject (who has the permission)
-- 'user' → auth.users.id
-- 'group' → future: group membership
-- 'token' → refers to storage.shares.id (anonymous link)
-- 'external' → future: refers to auth.external_subjects.id
-- (Open Cloud Mesh / federated OIDC)
subject_type TEXT NOT NULL
CHECK (subject_type IN ('user', 'group', 'token', 'external')),
subject_id UUID NOT NULL,
-- Resource (what the permission is on)
resource_type TEXT NOT NULL
CHECK (resource_type IN ('folder', 'file')),
resource_id UUID NOT NULL,
-- Permission (what action is allowed)
permission TEXT NOT NULL
CHECK (permission IN ('read', 'create', 'share', 'comment', 'delete', 'update')),
-- Audit
granted_by UUID NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (subject_type, subject_id, resource_type, resource_id, permission)
);
CREATE INDEX IF NOT EXISTS idx_grants_subject
ON storage.access_grants (subject_type, subject_id);
CREATE INDEX IF NOT EXISTS idx_grants_resource
ON storage.access_grants (resource_type, resource_id);
COMMENT ON TABLE storage.access_grants IS
'ReBAC grant table — subject × resource × permission. Owner is implicit '
'via storage.folders.user_id / storage.files.user_id (no rows here for owners).';
-- ── 2. Lifecycle cleanup triggers (defense-in-depth) ────────────────────────
-- These fire AFTER DELETE on the resource/subject tables so stale grants can
-- never outlive their target. The application layer also calls explicit
-- engine.revoke_all_for_* on the canonical paths.
CREATE OR REPLACE FUNCTION storage.cleanup_grants_on_resource_delete()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM storage.access_grants
WHERE resource_type = TG_ARGV[0]
AND resource_id = OLD.id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_cleanup_grants_folder ON storage.folders;
CREATE TRIGGER trg_cleanup_grants_folder
AFTER DELETE ON storage.folders
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_grants_on_resource_delete('folder');
DROP TRIGGER IF EXISTS trg_cleanup_grants_file ON storage.files;
CREATE TRIGGER trg_cleanup_grants_file
AFTER DELETE ON storage.files
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_grants_on_resource_delete('file');
CREATE OR REPLACE FUNCTION storage.cleanup_grants_on_subject_delete()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM storage.access_grants
WHERE subject_type = TG_ARGV[0]
AND subject_id = OLD.id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_cleanup_grants_user ON auth.users;
CREATE TRIGGER trg_cleanup_grants_user
AFTER DELETE ON auth.users
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_grants_on_subject_delete('user');
DROP TRIGGER IF EXISTS trg_cleanup_grants_token ON storage.shares;
CREATE TRIGGER trg_cleanup_grants_token
AFTER DELETE ON storage.shares
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_grants_on_subject_delete('token');
-- ── 3. Data migration from storage.shares ───────────────────────────────────
-- Each existing share row becomes one or more access_grants rows with
-- subject_type='token', subject_id=shares.id.
--
-- The old model's permission flags map to the new model as:
-- permissions_read → ['read']
-- permissions_write → ['read', 'create', 'update', 'delete']
-- (write implies full mutation rights)
-- permissions_reshare → ['share']
--
-- WHERE NOT EXISTS guards make this idempotent — re-running the migration
-- won't create duplicates.
INSERT INTO storage.access_grants
(subject_type, subject_id, resource_type, resource_id, permission, granted_by)
SELECT 'token', s.id, s.item_type, s.item_id::uuid, 'read', s.created_by
FROM storage.shares s
WHERE s.permissions_read
AND NOT EXISTS (
SELECT 1 FROM storage.access_grants g
WHERE g.subject_type = 'token'
AND g.subject_id = s.id
AND g.resource_id = s.item_id::uuid
AND g.permission = 'read'
);
INSERT INTO storage.access_grants
(subject_type, subject_id, resource_type, resource_id, permission, granted_by)
SELECT 'token', s.id, s.item_type, s.item_id::uuid, p.perm, s.created_by
FROM storage.shares s
CROSS JOIN (VALUES ('read'), ('create'), ('update'), ('delete')) AS p(perm)
WHERE s.permissions_write
AND NOT EXISTS (
SELECT 1 FROM storage.access_grants g
WHERE g.subject_type = 'token'
AND g.subject_id = s.id
AND g.resource_id = s.item_id::uuid
AND g.permission = p.perm
);
INSERT INTO storage.access_grants
(subject_type, subject_id, resource_type, resource_id, permission, granted_by)
SELECT 'token', s.id, s.item_type, s.item_id::uuid, 'share', s.created_by
FROM storage.shares s
WHERE s.permissions_reshare
AND NOT EXISTS (
SELECT 1 FROM storage.access_grants g
WHERE g.subject_type = 'token'
AND g.subject_id = s.id
AND g.resource_id = s.item_id::uuid
AND g.permission = 'share'
);
+222
View File
@@ -0,0 +1,222 @@
//! DTOs for the ReBAC `/api/grants` REST endpoints.
//!
//! The wire shapes are intentionally separate from the domain types
//! (`Subject`, `Resource`, `Permission`, `Grant`) so that domain stays
//! storage-agnostic and DTOs can evolve with the HTTP contract.
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
// ════════════════════════════════════════════════════════════════════════════
// Subject / Resource / Permission DTOs
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum SubjectTypeDto {
User,
Group,
Token,
External,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SubjectDto {
#[serde(rename = "type")]
pub kind: SubjectTypeDto,
pub id: Uuid,
}
impl From<SubjectDto> for Subject {
fn from(dto: SubjectDto) -> Self {
match dto.kind {
SubjectTypeDto::User => Subject::User(dto.id),
SubjectTypeDto::Group => Subject::Group(dto.id),
SubjectTypeDto::Token => Subject::Token(dto.id),
SubjectTypeDto::External => Subject::External(dto.id),
}
}
}
impl From<Subject> for SubjectDto {
fn from(s: Subject) -> Self {
let (kind, id) = match s {
Subject::User(id) => (SubjectTypeDto::User, id),
Subject::Group(id) => (SubjectTypeDto::Group, id),
Subject::Token(id) => (SubjectTypeDto::Token, id),
Subject::External(id) => (SubjectTypeDto::External, id),
};
SubjectDto { kind, id }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum ResourceTypeDto {
Folder,
File,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ResourceDto {
#[serde(rename = "type")]
pub kind: ResourceTypeDto,
pub id: Uuid,
}
impl From<ResourceDto> for Resource {
fn from(dto: ResourceDto) -> Self {
match dto.kind {
ResourceTypeDto::Folder => Resource::Folder(dto.id),
ResourceTypeDto::File => Resource::File(dto.id),
}
}
}
impl From<Resource> for ResourceDto {
fn from(r: Resource) -> Self {
let (kind, id) = match r {
Resource::Folder(id) => (ResourceTypeDto::Folder, id),
Resource::File(id) => (ResourceTypeDto::File, id),
};
ResourceDto { kind, id }
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PermissionDto {
Read,
Create,
Share,
Comment,
Delete,
Update,
}
impl From<PermissionDto> for Permission {
fn from(p: PermissionDto) -> Self {
match p {
PermissionDto::Read => Permission::Read,
PermissionDto::Create => Permission::Create,
PermissionDto::Share => Permission::Share,
PermissionDto::Comment => Permission::Comment,
PermissionDto::Delete => Permission::Delete,
PermissionDto::Update => Permission::Update,
}
}
}
impl From<Permission> for PermissionDto {
fn from(p: Permission) -> Self {
match p {
Permission::Read => PermissionDto::Read,
Permission::Create => PermissionDto::Create,
Permission::Share => PermissionDto::Share,
Permission::Comment => PermissionDto::Comment,
Permission::Delete => PermissionDto::Delete,
Permission::Update => PermissionDto::Update,
}
}
}
// ════════════════════════════════════════════════════════════════════════════
// Roles (DTO-layer sugar)
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Viewer,
Commenter,
Editor,
Manager,
Admin,
}
impl Role {
/// Expands a role into its constituent raw permissions. Storage and
/// engine know nothing about roles — the server normalizes here before
/// writing rows.
pub fn expand(self) -> &'static [Permission] {
match self {
Role::Viewer => &[Permission::Read],
Role::Commenter => &[Permission::Read, Permission::Comment],
Role::Editor => &[
Permission::Read,
Permission::Comment,
Permission::Create,
Permission::Update,
],
Role::Manager => &[
Permission::Read,
Permission::Comment,
Permission::Create,
Permission::Update,
Permission::Share,
],
Role::Admin => &[
Permission::Read,
Permission::Comment,
Permission::Create,
Permission::Update,
Permission::Share,
Permission::Delete,
],
}
}
}
// ════════════════════════════════════════════════════════════════════════════
// Request DTOs
// ════════════════════════════════════════════════════════════════════════════
/// `POST /api/grants` — accepts either `permissions` (explicit) or `role`.
/// Server-side validation requires exactly one of the two to be present.
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateGrantDto {
pub subject: SubjectDto,
pub resource: ResourceDto,
#[serde(default)]
pub permissions: Option<Vec<PermissionDto>>,
#[serde(default)]
pub role: Option<Role>,
}
/// `PUT /api/grants/role` — reconcile a subject's role on a resource.
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateRoleDto {
pub subject: SubjectDto,
pub resource: ResourceDto,
pub role: Role,
}
// ════════════════════════════════════════════════════════════════════════════
// Response DTOs
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct GrantDto {
pub id: Uuid,
pub subject: SubjectDto,
pub resource: ResourceDto,
pub permission: PermissionDto,
pub granted_by: Uuid,
pub granted_at: chrono::DateTime<chrono::Utc>,
}
impl From<Grant> for GrantDto {
fn from(g: Grant) -> Self {
Self {
id: g.id,
subject: g.subject.into(),
resource: g.resource.into(),
permission: g.permission.into(),
granted_by: g.granted_by,
granted_at: g.granted_at,
}
}
}
+1
View File
@@ -8,6 +8,7 @@ pub mod favorites_dto;
pub mod file_dto;
pub mod folder_dto;
pub mod folder_listing_dto;
pub mod grant_dto;
pub mod i18n_dto;
pub mod pagination;
pub mod playlist_dto;
@@ -0,0 +1,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<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? {
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
/// 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>;
}
+1
View File
@@ -1,4 +1,5 @@
pub mod auth_ports;
pub mod authorization_ports;
pub mod blob_lifecycle;
pub mod blob_storage_ports;
pub mod cache_ports;
@@ -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,
@@ -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<FileBlobWriteRepository>,
file_read: Option<Arc<FileBlobReadRepository>>,
folder_repo: Option<Arc<FolderDbRepository>>,
trash_service: Option<Arc<TrashService>>,
content_cache: Option<Arc<FileContentCache>>,
authz: Arc<PgAclEngine>,
/// Hooks fired after a file is permanently deleted.
file_deleted_hooks: Vec<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,
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<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.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<String>,
) -> Result<FileDto, DomainError> {
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<FileDto, DomainError> {
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<bool, DomainError> {
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<String>,
dest_name: Option<String>,
) -> Result<CopyFolderTreeResult, DomainError> {
// 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
@@ -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<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),
}
}
/// 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<FileDto, 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?;
Ok(FileDto::from(file))
}
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));
}
@@ -248,7 +280,7 @@ impl FileRetrievalUseCase for FileRetrievalService {
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
}
@@ -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<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
}
@@ -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(),
))
}
}
+73 -79
View File
@@ -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<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
@@ -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<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)
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<FolderDto, DomainError> {
// 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<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,
@@ -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",
+57 -4
View File
@@ -350,9 +350,13 @@ impl AppServiceFactory {
repos: &RepositoryServices,
trash_service: Option<Arc<TrashService>>,
db_pool: &Arc<PgPool>,
authz: &Arc<crate::infrastructure::services::pg_acl_engine::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 +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<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 + 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<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))
}
+205
View File
@@ -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<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,
}
}
}
// ════════════════════════════════════════════════════════════════════════════
// 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<Self> {
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<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,
}
}
}
// ════════════════════════════════════════════════════════════════════════════
// Grant — a row in storage.access_grants
// ════════════════════════════════════════════════════════════════════════════
#[derive(Clone, Debug)]
pub struct Grant {
pub id: Uuid,
pub subject: Subject,
pub resource: Resource,
pub permission: Permission,
pub granted_by: Uuid,
pub granted_at: chrono::DateTime<chrono::Utc>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subject_roundtrip() {
let id = Uuid::new_v4();
let cases = [
Subject::User(id),
Subject::Group(id),
Subject::Token(id),
Subject::External(id),
];
for s in cases {
let back = Subject::from_parts(s.type_str(), s.id()).unwrap();
assert_eq!(s, back);
}
assert!(Subject::from_parts("unknown", id).is_none());
}
#[test]
fn resource_roundtrip() {
let id = Uuid::new_v4();
for r in [Resource::Folder(id), Resource::File(id)] {
let back = Resource::from_parts(r.type_str(), r.id()).unwrap();
assert_eq!(r, back);
}
assert!(Resource::from_parts("calendar", id).is_none());
}
#[test]
fn permission_roundtrip() {
for p in Permission::ALL {
assert_eq!(Permission::parse(p.as_str()), Some(p));
}
assert!(Permission::parse("administrate").is_none());
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod authorization;
pub mod i18n_service;
pub mod path_service;
@@ -80,6 +80,20 @@ impl FileBlobReadRepository {
}
}
/// Returns the user_id (owner) for a given file ID.
/// Mirrors `FolderDbRepository::get_folder_user_id`.
/// Used by the AuthorizationEngine for owner short-circuit.
pub async fn get_file_user_id(&self, file_id: &str) -> Result<uuid::Uuid, DomainError> {
sqlx::query_scalar::<_, uuid::Uuid>(
"SELECT user_id FROM storage.files WHERE id = $1::uuid AND NOT is_trashed",
)
.bind(file_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("user_id lookup: {e}")))?
.ok_or_else(|| DomainError::not_found("File", file_id))
}
/// Creates a stub instance for testing — never hits PG.
#[cfg(test)]
pub fn new_stub() -> Self {
+1
View File
@@ -19,6 +19,7 @@ pub mod oidc_service;
pub mod password_hasher;
pub mod path_resolver_service;
pub mod path_service;
pub mod pg_acl_engine;
pub mod retry_blob_backend;
pub mod s3_blob_backend;
pub mod share_unlock_cookie;
@@ -0,0 +1,436 @@
//! PostgreSQL-backed implementation of `AuthorizationEngine`.
//!
//! Stores grants in `storage.access_grants` (see migration
//! `20260520000000_rebac_access_grants.sql`). Cascading is resolved at check
//! time via PostgreSQL `ltree` `@>` (ancestor-of) on `storage.folders.lpath`,
//! using the existing GiST index for O(log N) traversal.
//!
//! Owner is implicit — `storage.folders.user_id` / `storage.files.user_id`
//! are checked first via dedicated helpers; if the caller is the owner, no
//! SQL against `access_grants` happens.
//!
//! ## Lifecycle cleanup
//!
//! In v1, cleanup of grant rows when a resource or subject is permanently
//! deleted is enforced by **DB triggers** (`trg_cleanup_grants_*` in the
//! migration). The application layer does not call `revoke_all_for_*`
//! explicitly today — the triggers are the canonical path because they
//! also catch bulk SQL maintenance, admin scripts, and any code path that
//! bypasses the service layer.
//!
//! The `revoke_all_for_resource` / `revoke_all_for_subject` methods exist
//! on the trait for future use cases:
//! - **Caching** (planned) — a `CachedAuthorizationEngine` decorator needs
//! to see the invalidation event at the engine boundary, not just at the
//! SQL level. When caching lands, services will start calling these
//! methods explicitly before/around delete operations.
//! - **Alternate engines** (OpenFGA, future) — engines that don't share a
//! DB transaction with the resource table need an explicit signal to
//! delete their tuples.
use std::sync::Arc;
use uuid::Uuid;
use sqlx::PgPool;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::common::errors::DomainError;
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
pub struct PgAclEngine {
pool: Arc<PgPool>,
folder_repo: Arc<FolderDbRepository>,
file_repo: Arc<FileBlobReadRepository>,
}
impl PgAclEngine {
pub fn new(
pool: Arc<PgPool>,
folder_repo: Arc<FolderDbRepository>,
file_repo: Arc<FileBlobReadRepository>,
) -> Self {
Self {
pool,
folder_repo,
file_repo,
}
}
/// Creates a stub instance for tests that need to construct services
/// without a real PostgreSQL pool. Connecting to the lazy pool will
/// fail at runtime — only safe in tests that exercise types, not actual
/// authz queries.
#[cfg(test)]
pub fn new_stub() -> Self {
let pool = sqlx::pool::PoolOptions::<sqlx::Postgres>::new()
.max_connections(1)
.connect_lazy("postgres://invalid:5432/none")
.unwrap();
Self {
pool: Arc::new(pool),
folder_repo: Arc::new(FolderDbRepository::new_stub()),
file_repo: Arc::new(FileBlobReadRepository::new_stub()),
}
}
/// Returns the owner UUID for any resource type.
async fn owner_of(&self, resource: Resource) -> Result<Uuid, DomainError> {
match resource {
Resource::Folder(id) => self.folder_repo.get_folder_user_id(&id.to_string()).await,
Resource::File(id) => self.file_repo.get_file_user_id(&id.to_string()).await,
}
}
/// Cascading check for folders: is there a grant on any ancestor folder
/// (including the target itself) in this subject + permission?
/// Uses GiST index on `storage.folders.lpath`.
async fn folder_cascade_grant_exists(
&self,
subject: Subject,
permission: Permission,
folder_id: Uuid,
) -> Result<bool, DomainError> {
let exists: Option<i32> = sqlx::query_scalar(
r#"
SELECT 1
FROM storage.access_grants g
JOIN storage.folders gf ON gf.id = g.resource_id
WHERE g.subject_type = $1
AND g.subject_id = $2
AND g.permission = $3
AND g.resource_type = 'folder'
AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = $4)
LIMIT 1
"#,
)
.bind(subject.type_str())
.bind(subject.id())
.bind(permission.as_str())
.bind(folder_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("folder cascade: {e}")))?;
Ok(exists.is_some())
}
/// Cascading check for files: either a direct file grant OR a grant on
/// any ancestor folder of the file's containing folder.
async fn file_cascade_grant_exists(
&self,
subject: Subject,
permission: Permission,
file_id: Uuid,
) -> Result<bool, DomainError> {
let exists: Option<i32> = sqlx::query_scalar(
r#"
SELECT 1
FROM (
-- direct file grant
SELECT 1
FROM storage.access_grants
WHERE subject_type = $1 AND subject_id = $2 AND permission = $3
AND resource_type = 'file' AND resource_id = $4
UNION ALL
-- cascading from any ancestor folder of the file's containing folder
SELECT 1
FROM storage.access_grants g
JOIN storage.folders gf ON gf.id = g.resource_id
JOIN storage.files target_f ON target_f.id = $4
WHERE g.subject_type = $1
AND g.subject_id = $2
AND g.permission = $3
AND g.resource_type = 'folder'
AND target_f.folder_id IS NOT NULL
AND gf.lpath @> (SELECT lpath FROM storage.folders
WHERE id = target_f.folder_id)
) any_match
LIMIT 1
"#,
)
.bind(subject.type_str())
.bind(subject.id())
.bind(permission.as_str())
.bind(file_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("file cascade: {e}")))?;
Ok(exists.is_some())
}
/// Look up a single grant by id. Returns `(resource, granted_by)` so
/// the REST `DELETE /api/grants/{id}` handler can decide authorization
/// without a second round-trip. Returns `Ok(None)` if no such grant.
pub async fn find_grant_by_id(
&self,
grant_id: Uuid,
) -> Result<Option<(Resource, Uuid)>, DomainError> {
let row: Option<(String, Uuid, Uuid)> = sqlx::query_as(
"SELECT resource_type, resource_id, granted_by FROM storage.access_grants WHERE id = $1",
)
.bind(grant_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("find_grant_by_id: {e}")))?;
let Some((rt, rid, granter)) = row else {
return Ok(None);
};
let res = Resource::from_parts(&rt, rid)
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?;
Ok(Some((res, granter)))
}
/// Decode a (id, subject_type, subject_id, resource_type, resource_id,
/// permission, granted_by, granted_at) row into a `Grant`.
fn row_to_grant(
row: (
Uuid,
String,
Uuid,
String,
Uuid,
String,
Uuid,
chrono::DateTime<chrono::Utc>,
),
) -> Result<Grant, DomainError> {
let subject = Subject::from_parts(&row.1, row.2)
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown subject_type"))?;
let resource = Resource::from_parts(&row.3, row.4)
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?;
let permission = Permission::parse(&row.5)
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown permission"))?;
Ok(Grant {
id: row.0,
subject,
resource,
permission,
granted_by: row.6,
granted_at: row.7,
})
}
}
impl AuthorizationEngine for PgAclEngine {
async fn check(
&self,
subject: Subject,
permission: Permission,
resource: Resource,
) -> Result<bool, DomainError> {
// Owner short-circuit (only for User subjects — groups/tokens/external
// are never owners of resources).
if let Subject::User(uid) = subject {
match self.owner_of(resource).await {
Ok(owner) if owner == uid => return Ok(true),
Ok(_) => { /* not owner — fall through to grants */ }
Err(e) if e.kind == crate::common::errors::ErrorKind::NotFound => {
// Resource doesn't exist — no permission. Return false
// rather than propagating NotFound; the caller (`require`)
// converts a false back to NotFound on its own.
return Ok(false);
}
Err(e) => return Err(e),
}
}
// Cascading grant check.
match resource {
Resource::Folder(id) => {
self.folder_cascade_grant_exists(subject, permission, id)
.await
}
Resource::File(id) => {
self.file_cascade_grant_exists(subject, permission, id)
.await
}
}
}
async fn list_incoming_grants(
&self,
subject: Subject,
permission_filter: Option<Permission>,
) -> Result<Vec<Grant>, DomainError> {
let perm_str = permission_filter.map(|p| p.as_str().to_string());
let rows = sqlx::query_as::<
_,
(
Uuid,
String,
Uuid,
String,
Uuid,
String,
Uuid,
chrono::DateTime<chrono::Utc>,
),
>(
r#"
SELECT id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at
FROM storage.access_grants
WHERE subject_type = $1
AND subject_id = $2
AND ($3::text IS NULL OR permission = $3)
ORDER BY granted_at DESC
"#,
)
.bind(subject.type_str())
.bind(subject.id())
.bind(perm_str)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("list incoming: {e}")))?;
rows.into_iter().map(Self::row_to_grant).collect()
}
async fn list_grants_on_resource(&self, resource: Resource) -> Result<Vec<Grant>, DomainError> {
let rows = sqlx::query_as::<
_,
(
Uuid,
String,
Uuid,
String,
Uuid,
String,
Uuid,
chrono::DateTime<chrono::Utc>,
),
>(
r#"
SELECT id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at
FROM storage.access_grants
WHERE resource_type = $1
AND resource_id = $2
ORDER BY granted_at DESC
"#,
)
.bind(resource.type_str())
.bind(resource.id())
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("list on resource: {e}")))?;
rows.into_iter().map(Self::row_to_grant).collect()
}
async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result<Vec<Grant>, DomainError> {
let rows = sqlx::query_as::<
_,
(
Uuid,
String,
Uuid,
String,
Uuid,
String,
Uuid,
chrono::DateTime<chrono::Utc>,
),
>(
r#"
SELECT id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at
FROM storage.access_grants
WHERE granted_by = $1
ORDER BY granted_at DESC
"#,
)
.bind(granted_by)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("list outgoing: {e}")))?;
rows.into_iter().map(Self::row_to_grant).collect()
}
async fn grant(
&self,
granted_by: Uuid,
subject: Subject,
permission: Permission,
resource: Resource,
) -> Result<Grant, DomainError> {
// Idempotent: ON CONFLICT DO UPDATE so we always return the row
// (whether newly inserted or pre-existing). The "update" is a no-op
// (granted_by/granted_at preserved from the existing row).
let row = sqlx::query_as::<
_,
(
Uuid,
String,
Uuid,
String,
Uuid,
String,
Uuid,
chrono::DateTime<chrono::Utc>,
),
>(
r#"
INSERT INTO storage.access_grants
(subject_type, subject_id, resource_type, resource_id, permission, granted_by)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (subject_type, subject_id, resource_type, resource_id, permission)
DO UPDATE SET subject_type = EXCLUDED.subject_type
RETURNING id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at
"#,
)
.bind(subject.type_str())
.bind(subject.id())
.bind(resource.type_str())
.bind(resource.id())
.bind(permission.as_str())
.bind(granted_by)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("insert grant: {e}")))?;
Self::row_to_grant(row)
}
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> {
sqlx::query("DELETE FROM storage.access_grants WHERE id = $1")
.bind(grant_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke: {e}")))?;
Ok(())
}
async fn revoke_all_for_resource(&self, resource: Resource) -> Result<usize, DomainError> {
let result = sqlx::query(
"DELETE FROM storage.access_grants WHERE resource_type = $1 AND resource_id = $2",
)
.bind(resource.type_str())
.bind(resource.id())
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for resource: {e}")))?;
Ok(result.rows_affected() as usize)
}
async fn revoke_all_for_subject(&self, subject: Subject) -> Result<usize, DomainError> {
let result = sqlx::query(
"DELETE FROM storage.access_grants WHERE subject_type = $1 AND subject_id = $2",
)
.bind(subject.type_str())
.bind(subject.id())
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for subject: {e}")))?;
Ok(result.rows_affected() as usize)
}
}
@@ -0,0 +1,360 @@
//! REST handlers for the ReBAC grant management endpoints.
//!
//! All endpoints under `/api/grants`. The authenticated caller is taken from
//! the `AuthUser` extractor. Authorization for sharing operations is enforced
//! via `authz.require(caller, Share, resource)` — handlers never embed their
//! own checks (see CLAUDE.md § Authorization).
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use serde::Deserialize;
use std::sync::Arc;
use tracing::{error, info};
use utoipa::IntoParams;
use uuid::Uuid;
use crate::application::dtos::grant_dto::{
CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, SubjectDto,
UpdateRoleDto,
};
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::common::errors::DomainError;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
// ════════════════════════════════════════════════════════════════════════════
// POST /api/grants
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
post,
path = "/api/grants",
request_body = CreateGrantDto,
responses(
(status = 201, description = "Grant(s) created", body = Vec<GrantDto>),
(status = 400, description = "Invalid input (both/neither of permissions+role provided)"),
(status = 404, description = "Resource not found OR caller lacks Share permission"),
),
tag = "grants"
)]
pub async fn create_grant(
State(authz): State<Arc<PgAclEngine>>,
auth_user: AuthUser,
Json(dto): Json<CreateGrantDto>,
) -> impl IntoResponse {
let caller_id = auth_user.id;
// Validate: exactly one of permissions/role
let permissions: Vec<Permission> = match (dto.permissions, dto.role) {
(Some(perms), None) if !perms.is_empty() => perms.into_iter().map(Into::into).collect(),
(None, Some(role)) => role.expand().to_vec(),
(Some(_), Some(_)) => {
return AppError::new(
StatusCode::BAD_REQUEST,
"Provide either 'permissions' or 'role', not both",
"InvalidInput",
)
.into_response();
}
_ => {
return AppError::new(
StatusCode::BAD_REQUEST,
"Either 'permissions' (non-empty) or 'role' is required",
"InvalidInput",
)
.into_response();
}
};
let subject: Subject = dto.subject.into();
let resource: Resource = dto.resource.into();
// Caller must have Share on the resource (owners pass via short-circuit).
if let Err(e) = authz
.require(Subject::User(caller_id), Permission::Share, resource)
.await
{
return AppError::from(e).into_response();
}
let mut results: Vec<GrantDto> = Vec::with_capacity(permissions.len());
for perm in permissions {
match authz.grant(caller_id, subject, perm, resource).await {
Ok(grant) => results.push(grant.into()),
Err(err) => {
error!("grant insert failed for {perm:?}: {err}");
return AppError::from(err).into_response();
}
}
}
info!(
"Created {} grant(s) for subject={:?} on resource={:?} by user {}",
results.len(),
subject,
resource,
caller_id
);
(StatusCode::CREATED, Json(results)).into_response()
}
// ════════════════════════════════════════════════════════════════════════════
// DELETE /api/grants/{id}
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
delete,
path = "/api/grants/{id}",
params(("id" = String, Path, description = "Grant UUID")),
responses(
(status = 204, description = "Grant revoked (or did not exist)"),
(status = 404, description = "Caller lacks Share permission on the underlying resource"),
),
tag = "grants"
)]
pub async fn revoke_grant(
State(authz): State<Arc<PgAclEngine>>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> impl IntoResponse {
let caller_id = auth_user.id;
let grant_id = match Uuid::parse_str(&id) {
Ok(u) => u,
Err(_) => return AppError::not_found(format!("Grant {id} not found")).into_response(),
};
// Look up the grant to find the underlying resource (and granter).
let on_resource = match find_grant_resource(&authz, grant_id).await {
Ok(Some((res, granter))) => (res, granter),
Ok(None) => return StatusCode::NO_CONTENT.into_response(), // idempotent
Err(e) => return AppError::from(e).into_response(),
};
// Caller is authorized if they are the granter OR have Share on the resource.
if on_resource.1 != caller_id
&& let Err(e) = authz
.require(Subject::User(caller_id), Permission::Share, on_resource.0)
.await
{
return AppError::from(e).into_response();
}
if let Err(e) = authz.revoke(grant_id).await {
return AppError::from(e).into_response();
}
info!("Revoked grant {grant_id} (caller {caller_id})");
StatusCode::NO_CONTENT.into_response()
}
/// Look up a grant by id and return (resource, granted_by) so the caller-auth
/// check in revoke_grant can determine if the caller is the granter or needs
/// the Share permission on the resource. Returns `Ok(None)` if no such grant.
async fn find_grant_resource(
authz: &PgAclEngine,
grant_id: Uuid,
) -> Result<Option<(Resource, Uuid)>, DomainError> {
authz.find_grant_by_id(grant_id).await
}
// ════════════════════════════════════════════════════════════════════════════
// PUT /api/grants/role
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
put,
path = "/api/grants/role",
request_body = UpdateRoleDto,
responses(
(status = 200, description = "Role applied; returns the new full grant set", body = Vec<GrantDto>),
(status = 404, description = "Resource not found or caller lacks Share"),
),
tag = "grants"
)]
pub async fn set_role(
State(authz): State<Arc<PgAclEngine>>,
auth_user: AuthUser,
Json(dto): Json<UpdateRoleDto>,
) -> impl IntoResponse {
let caller_id = auth_user.id;
let subject: Subject = dto.subject.into();
let resource: Resource = dto.resource.into();
let target_perms: std::collections::HashSet<Permission> =
dto.role.expand().iter().copied().collect();
// Caller must have Share on the resource.
if let Err(e) = authz
.require(Subject::User(caller_id), Permission::Share, resource)
.await
{
return AppError::from(e).into_response();
}
// Fetch current grants on the resource for this subject.
let current = match authz.list_grants_on_resource(resource).await {
Ok(g) => g,
Err(e) => return AppError::from(e).into_response(),
};
let current_perms: std::collections::HashSet<Permission> = current
.iter()
.filter(|g| g.subject == subject)
.map(|g| g.permission)
.collect();
// Diff and apply.
let to_add: Vec<Permission> = target_perms.difference(&current_perms).copied().collect();
let to_remove: Vec<Permission> = current_perms.difference(&target_perms).copied().collect();
for perm in &to_remove {
if let Some(g) = current
.iter()
.find(|g| g.subject == subject && g.permission == *perm)
&& let Err(e) = authz.revoke(g.id).await
{
return AppError::from(e).into_response();
}
}
for perm in &to_add {
if let Err(e) = authz.grant(caller_id, subject, *perm, resource).await {
return AppError::from(e).into_response();
}
}
// Return the new full set.
let after = match authz.list_grants_on_resource(resource).await {
Ok(g) => g,
Err(e) => return AppError::from(e).into_response(),
};
let mine: Vec<GrantDto> = after
.into_iter()
.filter(|g| g.subject == subject)
.map(Into::into)
.collect();
info!(
"Role applied: caller={} subject={:?} resource={:?} added={:?} removed={:?}",
caller_id, subject, resource, to_add, to_remove
);
(StatusCode::OK, Json(mine)).into_response()
}
// ════════════════════════════════════════════════════════════════════════════
// GET /api/grants/incoming
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Deserialize, IntoParams)]
pub struct IncomingQuery {
#[serde(default)]
pub permission: Option<PermissionDto>,
}
#[utoipa::path(
get,
path = "/api/grants/incoming",
params(IncomingQuery),
responses(
(status = 200, description = "Direct grants targeting the caller", body = Vec<GrantDto>),
),
tag = "grants"
)]
pub async fn list_incoming(
State(authz): State<Arc<PgAclEngine>>,
auth_user: AuthUser,
Query(q): Query<IncomingQuery>,
) -> impl IntoResponse {
let caller_id = auth_user.id;
match authz
.list_incoming_grants(Subject::User(caller_id), q.permission.map(Into::into))
.await
{
Ok(grants) => {
let dtos: Vec<GrantDto> = grants.into_iter().map(Into::into).collect();
(StatusCode::OK, Json(dtos)).into_response()
}
Err(e) => AppError::from(e).into_response(),
}
}
// ════════════════════════════════════════════════════════════════════════════
// GET /api/grants/outgoing
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
get,
path = "/api/grants/outgoing",
responses(
(status = 200, description = "Grants the caller has created", body = Vec<GrantDto>),
),
tag = "grants"
)]
pub async fn list_outgoing(
State(authz): State<Arc<PgAclEngine>>,
auth_user: AuthUser,
) -> impl IntoResponse {
let caller_id = auth_user.id;
match authz.list_outgoing_grants(caller_id).await {
Ok(grants) => {
let dtos: Vec<GrantDto> = grants.into_iter().map(Into::into).collect();
(StatusCode::OK, Json(dtos)).into_response()
}
Err(e) => AppError::from(e).into_response(),
}
}
// ════════════════════════════════════════════════════════════════════════════
// GET /api/grants?resource_type=...&resource_id=...
// (list grants on a specific resource — requires Share on it)
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Deserialize, IntoParams)]
pub struct OnResourceQuery {
pub resource_type: ResourceTypeDto,
pub resource_id: Uuid,
}
#[utoipa::path(
get,
path = "/api/grants",
params(OnResourceQuery),
responses(
(status = 200, description = "Grants on the specified resource", body = Vec<GrantDto>),
(status = 404, description = "Resource not found or caller lacks Share"),
),
tag = "grants"
)]
pub async fn list_on_resource(
State(authz): State<Arc<PgAclEngine>>,
auth_user: AuthUser,
Query(q): Query<OnResourceQuery>,
) -> impl IntoResponse {
let caller_id = auth_user.id;
let resource: Resource = ResourceDto {
kind: q.resource_type,
id: q.resource_id,
}
.into();
if let Err(e) = authz
.require(Subject::User(caller_id), Permission::Share, resource)
.await
{
return AppError::from(e).into_response();
}
match authz.list_grants_on_resource(resource).await {
Ok(grants) => {
let dtos: Vec<GrantDto> = grants.into_iter().map(Into::into).collect();
(StatusCode::OK, Json(dtos)).into_response()
}
Err(e) => AppError::from(e).into_response(),
}
}
// Silence unused-import warnings for SubjectDto when only certain endpoints
// touch it directly.
#[allow(dead_code)]
fn _ensure_subject_dto_compiles(_: SubjectDto) {}
+1
View File
@@ -11,6 +11,7 @@ pub mod device_auth_handler;
pub mod favorites_handler;
pub mod file_handler;
pub mod folder_handler;
pub mod grant_handler;
pub mod i18n_handler;
pub mod music_handler;
pub mod photos_handler;
+15
View File
@@ -165,6 +165,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
let share_service = app_state.share_service.clone();
let favorites_service = app_state.favorites_service.clone();
let recent_service = app_state.recent_service.clone();
let authorization = app_state.authorization.clone();
// Initialize the batch operations service
let mut batch_service_builder = BatchOperationService::default(
@@ -301,6 +302,19 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
Router::new()
};
// Create routes for ReBAC grants (/api/grants) — single state: the authz engine.
let grants_router = {
use crate::interfaces::api::handlers::grant_handler;
Router::new()
.route("/", post(grant_handler::create_grant))
.route("/", get(grant_handler::list_on_resource))
.route("/{id}", delete(grant_handler::revoke_grant))
.route("/role", put(grant_handler::set_role))
.route("/incoming", get(grant_handler::list_incoming))
.route("/outgoing", get(grant_handler::list_outgoing))
.with_state(authorization.clone())
};
// Create a router without the i18n routes
// Create routes for favorites if the service is available
let favorites_router = if let Some(favorites_service) = favorites_service.clone() {
@@ -378,6 +392,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
.nest("/batch", batch_router)
.nest("/search", search_router)
.nest("/shares", share_router)
.nest("/grants", grants_router)
.nest("/favorites", favorites_router)
.nest("/recent", recent_router);
+303
View File
@@ -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
+2 -1
View File
@@ -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"