feat(rebac): first pass
This commit is contained in:
@@ -119,6 +119,12 @@ Never duplicate logic across handlers or services. If the same behaviour is need
|
||||
- Reusable infrastructure behaviour → method on the relevant service struct
|
||||
- Shared port behaviour → default method on the trait
|
||||
|
||||
### Authorization (AuthZ)
|
||||
|
||||
**AuthZ is enforced exclusively in the application service layer, never in handlers.** All permission checks go through `AuthorizationEngine` (port: `application/ports/authorization_ports.rs`) via service methods named with the `_with_perms` suffix. HTTP handlers (REST, WebDAV, NextCloud, CalDAV, CardDAV) authenticate the caller and pass `caller_id` into the service — they MUST NOT perform their own ownership/permission checks. The authentication middleware extracts the caller; the service decides if the action is allowed.
|
||||
|
||||
This rule prevents drift between layers and ensures every code path goes through the same policy. New service methods that touch a user-scoped resource must take `caller_id: Uuid` and call `authz.require(...)` before any read or mutation.
|
||||
|
||||
# Frontend part
|
||||
|
||||
## Code conventions
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- ReBAC: access_grants table + lifecycle cleanup triggers + data migration
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- PR 1 of the ReBAC rollout. Schema and data only — no code changes yet.
|
||||
--
|
||||
-- This migration:
|
||||
-- 1. Creates storage.access_grants (the single grant table for ReBAC)
|
||||
-- 2. Installs AFTER DELETE triggers so lifecycle cleanup is enforced at the
|
||||
-- DB level even if a future code path bypasses the service layer
|
||||
-- 3. Migrates existing storage.shares permission flags into access_grants
|
||||
-- rows with subject_type='token'
|
||||
--
|
||||
-- The storage.shares.permissions_* columns are NOT dropped here. They stay
|
||||
-- until PR 5 (share_service is updated to read from access_grants instead).
|
||||
-- See /Users/ed/.claude/plans/compiled-shimmying-bonbon.md → "Rollout sequencing".
|
||||
|
||||
|
||||
-- ── 1. The grant table ──────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS storage.access_grants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- Subject (who has the permission)
|
||||
-- 'user' → auth.users.id
|
||||
-- 'group' → future: group membership
|
||||
-- 'token' → refers to storage.shares.id (anonymous link)
|
||||
-- 'external' → future: refers to auth.external_subjects.id
|
||||
-- (Open Cloud Mesh / federated OIDC)
|
||||
subject_type TEXT NOT NULL
|
||||
CHECK (subject_type IN ('user', 'group', 'token', 'external')),
|
||||
subject_id UUID NOT NULL,
|
||||
|
||||
-- Resource (what the permission is on)
|
||||
resource_type TEXT NOT NULL
|
||||
CHECK (resource_type IN ('folder', 'file')),
|
||||
resource_id UUID NOT NULL,
|
||||
|
||||
-- Permission (what action is allowed)
|
||||
permission TEXT NOT NULL
|
||||
CHECK (permission IN ('read', 'create', 'share', 'comment', 'delete', 'update')),
|
||||
|
||||
-- Audit
|
||||
granted_by UUID NOT NULL,
|
||||
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (subject_type, subject_id, resource_type, resource_id, permission)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_grants_subject
|
||||
ON storage.access_grants (subject_type, subject_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_grants_resource
|
||||
ON storage.access_grants (resource_type, resource_id);
|
||||
|
||||
COMMENT ON TABLE storage.access_grants IS
|
||||
'ReBAC grant table — subject × resource × permission. Owner is implicit '
|
||||
'via storage.folders.user_id / storage.files.user_id (no rows here for owners).';
|
||||
|
||||
|
||||
-- ── 2. Lifecycle cleanup triggers (defense-in-depth) ────────────────────────
|
||||
-- These fire AFTER DELETE on the resource/subject tables so stale grants can
|
||||
-- never outlive their target. The application layer also calls explicit
|
||||
-- engine.revoke_all_for_* on the canonical paths.
|
||||
|
||||
CREATE OR REPLACE FUNCTION storage.cleanup_grants_on_resource_delete()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM storage.access_grants
|
||||
WHERE resource_type = TG_ARGV[0]
|
||||
AND resource_id = OLD.id;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_grants_folder ON storage.folders;
|
||||
CREATE TRIGGER trg_cleanup_grants_folder
|
||||
AFTER DELETE ON storage.folders
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION storage.cleanup_grants_on_resource_delete('folder');
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_grants_file ON storage.files;
|
||||
CREATE TRIGGER trg_cleanup_grants_file
|
||||
AFTER DELETE ON storage.files
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION storage.cleanup_grants_on_resource_delete('file');
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION storage.cleanup_grants_on_subject_delete()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM storage.access_grants
|
||||
WHERE subject_type = TG_ARGV[0]
|
||||
AND subject_id = OLD.id;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_grants_user ON auth.users;
|
||||
CREATE TRIGGER trg_cleanup_grants_user
|
||||
AFTER DELETE ON auth.users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION storage.cleanup_grants_on_subject_delete('user');
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_grants_token ON storage.shares;
|
||||
CREATE TRIGGER trg_cleanup_grants_token
|
||||
AFTER DELETE ON storage.shares
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION storage.cleanup_grants_on_subject_delete('token');
|
||||
|
||||
|
||||
-- ── 3. Data migration from storage.shares ───────────────────────────────────
|
||||
-- Each existing share row becomes one or more access_grants rows with
|
||||
-- subject_type='token', subject_id=shares.id.
|
||||
--
|
||||
-- The old model's permission flags map to the new model as:
|
||||
-- permissions_read → ['read']
|
||||
-- permissions_write → ['read', 'create', 'update', 'delete']
|
||||
-- (write implies full mutation rights)
|
||||
-- permissions_reshare → ['share']
|
||||
--
|
||||
-- WHERE NOT EXISTS guards make this idempotent — re-running the migration
|
||||
-- won't create duplicates.
|
||||
|
||||
INSERT INTO storage.access_grants
|
||||
(subject_type, subject_id, resource_type, resource_id, permission, granted_by)
|
||||
SELECT 'token', s.id, s.item_type, s.item_id::uuid, 'read', s.created_by
|
||||
FROM storage.shares s
|
||||
WHERE s.permissions_read
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM storage.access_grants g
|
||||
WHERE g.subject_type = 'token'
|
||||
AND g.subject_id = s.id
|
||||
AND g.resource_id = s.item_id::uuid
|
||||
AND g.permission = 'read'
|
||||
);
|
||||
|
||||
INSERT INTO storage.access_grants
|
||||
(subject_type, subject_id, resource_type, resource_id, permission, granted_by)
|
||||
SELECT 'token', s.id, s.item_type, s.item_id::uuid, p.perm, s.created_by
|
||||
FROM storage.shares s
|
||||
CROSS JOIN (VALUES ('read'), ('create'), ('update'), ('delete')) AS p(perm)
|
||||
WHERE s.permissions_write
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM storage.access_grants g
|
||||
WHERE g.subject_type = 'token'
|
||||
AND g.subject_id = s.id
|
||||
AND g.resource_id = s.item_id::uuid
|
||||
AND g.permission = p.perm
|
||||
);
|
||||
|
||||
INSERT INTO storage.access_grants
|
||||
(subject_type, subject_id, resource_type, resource_id, permission, granted_by)
|
||||
SELECT 'token', s.id, s.item_type, s.item_id::uuid, 'share', s.created_by
|
||||
FROM storage.shares s
|
||||
WHERE s.permissions_reshare
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM storage.access_grants g
|
||||
WHERE g.subject_type = 'token'
|
||||
AND g.subject_id = s.id
|
||||
AND g.resource_id = s.item_id::uuid
|
||||
AND g.permission = 'share'
|
||||
);
|
||||
@@ -0,0 +1,222 @@
|
||||
//! DTOs for the ReBAC `/api/grants` REST endpoints.
|
||||
//!
|
||||
//! The wire shapes are intentionally separate from the domain types
|
||||
//! (`Subject`, `Resource`, `Permission`, `Grant`) so that domain stays
|
||||
//! storage-agnostic and DTOs can evolve with the HTTP contract.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Subject / Resource / Permission DTOs
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SubjectTypeDto {
|
||||
User,
|
||||
Group,
|
||||
Token,
|
||||
External,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SubjectDto {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: SubjectTypeDto,
|
||||
pub id: Uuid,
|
||||
}
|
||||
|
||||
impl From<SubjectDto> for Subject {
|
||||
fn from(dto: SubjectDto) -> Self {
|
||||
match dto.kind {
|
||||
SubjectTypeDto::User => Subject::User(dto.id),
|
||||
SubjectTypeDto::Group => Subject::Group(dto.id),
|
||||
SubjectTypeDto::Token => Subject::Token(dto.id),
|
||||
SubjectTypeDto::External => Subject::External(dto.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Subject> for SubjectDto {
|
||||
fn from(s: Subject) -> Self {
|
||||
let (kind, id) = match s {
|
||||
Subject::User(id) => (SubjectTypeDto::User, id),
|
||||
Subject::Group(id) => (SubjectTypeDto::Group, id),
|
||||
Subject::Token(id) => (SubjectTypeDto::Token, id),
|
||||
Subject::External(id) => (SubjectTypeDto::External, id),
|
||||
};
|
||||
SubjectDto { kind, id }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ResourceTypeDto {
|
||||
Folder,
|
||||
File,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ResourceDto {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: ResourceTypeDto,
|
||||
pub id: Uuid,
|
||||
}
|
||||
|
||||
impl From<ResourceDto> for Resource {
|
||||
fn from(dto: ResourceDto) -> Self {
|
||||
match dto.kind {
|
||||
ResourceTypeDto::Folder => Resource::Folder(dto.id),
|
||||
ResourceTypeDto::File => Resource::File(dto.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Resource> for ResourceDto {
|
||||
fn from(r: Resource) -> Self {
|
||||
let (kind, id) = match r {
|
||||
Resource::Folder(id) => (ResourceTypeDto::Folder, id),
|
||||
Resource::File(id) => (ResourceTypeDto::File, id),
|
||||
};
|
||||
ResourceDto { kind, id }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PermissionDto {
|
||||
Read,
|
||||
Create,
|
||||
Share,
|
||||
Comment,
|
||||
Delete,
|
||||
Update,
|
||||
}
|
||||
|
||||
impl From<PermissionDto> for Permission {
|
||||
fn from(p: PermissionDto) -> Self {
|
||||
match p {
|
||||
PermissionDto::Read => Permission::Read,
|
||||
PermissionDto::Create => Permission::Create,
|
||||
PermissionDto::Share => Permission::Share,
|
||||
PermissionDto::Comment => Permission::Comment,
|
||||
PermissionDto::Delete => Permission::Delete,
|
||||
PermissionDto::Update => Permission::Update,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Permission> for PermissionDto {
|
||||
fn from(p: Permission) -> Self {
|
||||
match p {
|
||||
Permission::Read => PermissionDto::Read,
|
||||
Permission::Create => PermissionDto::Create,
|
||||
Permission::Share => PermissionDto::Share,
|
||||
Permission::Comment => PermissionDto::Comment,
|
||||
Permission::Delete => PermissionDto::Delete,
|
||||
Permission::Update => PermissionDto::Update,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Roles (DTO-layer sugar)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
Viewer,
|
||||
Commenter,
|
||||
Editor,
|
||||
Manager,
|
||||
Admin,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
/// Expands a role into its constituent raw permissions. Storage and
|
||||
/// engine know nothing about roles — the server normalizes here before
|
||||
/// writing rows.
|
||||
pub fn expand(self) -> &'static [Permission] {
|
||||
match self {
|
||||
Role::Viewer => &[Permission::Read],
|
||||
Role::Commenter => &[Permission::Read, Permission::Comment],
|
||||
Role::Editor => &[
|
||||
Permission::Read,
|
||||
Permission::Comment,
|
||||
Permission::Create,
|
||||
Permission::Update,
|
||||
],
|
||||
Role::Manager => &[
|
||||
Permission::Read,
|
||||
Permission::Comment,
|
||||
Permission::Create,
|
||||
Permission::Update,
|
||||
Permission::Share,
|
||||
],
|
||||
Role::Admin => &[
|
||||
Permission::Read,
|
||||
Permission::Comment,
|
||||
Permission::Create,
|
||||
Permission::Update,
|
||||
Permission::Share,
|
||||
Permission::Delete,
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Request DTOs
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// `POST /api/grants` — accepts either `permissions` (explicit) or `role`.
|
||||
/// Server-side validation requires exactly one of the two to be present.
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateGrantDto {
|
||||
pub subject: SubjectDto,
|
||||
pub resource: ResourceDto,
|
||||
#[serde(default)]
|
||||
pub permissions: Option<Vec<PermissionDto>>,
|
||||
#[serde(default)]
|
||||
pub role: Option<Role>,
|
||||
}
|
||||
|
||||
/// `PUT /api/grants/role` — reconcile a subject's role on a resource.
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateRoleDto {
|
||||
pub subject: SubjectDto,
|
||||
pub resource: ResourceDto,
|
||||
pub role: Role,
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Response DTOs
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct GrantDto {
|
||||
pub id: Uuid,
|
||||
pub subject: SubjectDto,
|
||||
pub resource: ResourceDto,
|
||||
pub permission: PermissionDto,
|
||||
pub granted_by: Uuid,
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<Grant> for GrantDto {
|
||||
fn from(g: Grant) -> Self {
|
||||
Self {
|
||||
id: g.id,
|
||||
subject: g.subject.into(),
|
||||
resource: g.resource.into(),
|
||||
permission: g.permission.into(),
|
||||
granted_by: g.granted_by,
|
||||
granted_at: g.granted_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub mod favorites_dto;
|
||||
pub mod file_dto;
|
||||
pub mod folder_dto;
|
||||
pub mod folder_listing_dto;
|
||||
pub mod grant_dto;
|
||||
pub mod i18n_dto;
|
||||
pub mod pagination;
|
||||
pub mod playlist_dto;
|
||||
|
||||
@@ -0,0 +1,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,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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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(¤t_perms).copied().collect();
|
||||
let to_remove: Vec<Permission> = current_perms.difference(&target_perms).copied().collect();
|
||||
|
||||
for perm in &to_remove {
|
||||
if let Some(g) = current
|
||||
.iter()
|
||||
.find(|g| g.subject == subject && g.permission == *perm)
|
||||
&& let Err(e) = authz.revoke(g.id).await
|
||||
{
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
}
|
||||
for perm in &to_add {
|
||||
if let Err(e) = authz.grant(caller_id, subject, *perm, resource).await {
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Return the new full set.
|
||||
let after = match authz.list_grants_on_resource(resource).await {
|
||||
Ok(g) => g,
|
||||
Err(e) => return AppError::from(e).into_response(),
|
||||
};
|
||||
let mine: Vec<GrantDto> = after
|
||||
.into_iter()
|
||||
.filter(|g| g.subject == subject)
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
|
||||
info!(
|
||||
"Role applied: caller={} subject={:?} resource={:?} added={:?} removed={:?}",
|
||||
caller_id, subject, resource, to_add, to_remove
|
||||
);
|
||||
(StatusCode::OK, Json(mine)).into_response()
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/grants/incoming
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct IncomingQuery {
|
||||
#[serde(default)]
|
||||
pub permission: Option<PermissionDto>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/grants/incoming",
|
||||
params(IncomingQuery),
|
||||
responses(
|
||||
(status = 200, description = "Direct grants targeting the caller", body = Vec<GrantDto>),
|
||||
),
|
||||
tag = "grants"
|
||||
)]
|
||||
pub async fn list_incoming(
|
||||
State(authz): State<Arc<PgAclEngine>>,
|
||||
auth_user: AuthUser,
|
||||
Query(q): Query<IncomingQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let caller_id = auth_user.id;
|
||||
match authz
|
||||
.list_incoming_grants(Subject::User(caller_id), q.permission.map(Into::into))
|
||||
.await
|
||||
{
|
||||
Ok(grants) => {
|
||||
let dtos: Vec<GrantDto> = grants.into_iter().map(Into::into).collect();
|
||||
(StatusCode::OK, Json(dtos)).into_response()
|
||||
}
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/grants/outgoing
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/grants/outgoing",
|
||||
responses(
|
||||
(status = 200, description = "Grants the caller has created", body = Vec<GrantDto>),
|
||||
),
|
||||
tag = "grants"
|
||||
)]
|
||||
pub async fn list_outgoing(
|
||||
State(authz): State<Arc<PgAclEngine>>,
|
||||
auth_user: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
let caller_id = auth_user.id;
|
||||
match authz.list_outgoing_grants(caller_id).await {
|
||||
Ok(grants) => {
|
||||
let dtos: Vec<GrantDto> = grants.into_iter().map(Into::into).collect();
|
||||
(StatusCode::OK, Json(dtos)).into_response()
|
||||
}
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/grants?resource_type=...&resource_id=...
|
||||
// (list grants on a specific resource — requires Share on it)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct OnResourceQuery {
|
||||
pub resource_type: ResourceTypeDto,
|
||||
pub resource_id: Uuid,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/grants",
|
||||
params(OnResourceQuery),
|
||||
responses(
|
||||
(status = 200, description = "Grants on the specified resource", body = Vec<GrantDto>),
|
||||
(status = 404, description = "Resource not found or caller lacks Share"),
|
||||
),
|
||||
tag = "grants"
|
||||
)]
|
||||
pub async fn list_on_resource(
|
||||
State(authz): State<Arc<PgAclEngine>>,
|
||||
auth_user: AuthUser,
|
||||
Query(q): Query<OnResourceQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let caller_id = auth_user.id;
|
||||
let resource: Resource = ResourceDto {
|
||||
kind: q.resource_type,
|
||||
id: q.resource_id,
|
||||
}
|
||||
.into();
|
||||
|
||||
if let Err(e) = authz
|
||||
.require(Subject::User(caller_id), Permission::Share, resource)
|
||||
.await
|
||||
{
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
|
||||
match authz.list_grants_on_resource(resource).await {
|
||||
Ok(grants) => {
|
||||
let dtos: Vec<GrantDto> = grants.into_iter().map(Into::into).collect();
|
||||
(StatusCode::OK, Json(dtos)).into_response()
|
||||
}
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// Silence unused-import warnings for SubjectDto when only certain endpoints
|
||||
// touch it directly.
|
||||
#[allow(dead_code)]
|
||||
fn _ensure_subject_dto_compiles(_: SubjectDto) {}
|
||||
@@ -11,6 +11,7 @@ pub mod device_auth_handler;
|
||||
pub mod favorites_handler;
|
||||
pub mod file_handler;
|
||||
pub mod folder_handler;
|
||||
pub mod grant_handler;
|
||||
pub mod i18n_handler;
|
||||
pub mod music_handler;
|
||||
pub mod photos_handler;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user