diff --git a/migrations/20260906000000_role_grants_calendar_address_book.sql b/migrations/20260906000000_role_grants_calendar_address_book.sql new file mode 100644 index 00000000..d3307338 --- /dev/null +++ b/migrations/20260906000000_role_grants_calendar_address_book.sql @@ -0,0 +1,68 @@ +-- ───────────────────────────────────────────────────────────────────────── +-- Round 3 — admit 'calendar' and 'address_book' into +-- `storage.role_grants.resource_type`. +-- +-- Companion to the domain unblock in +-- `src/domain/services/authorization.rs` (Round 3 Phase 1). The +-- `Resource::Calendar(Uuid)` and `Resource::AddressBook(Uuid)` +-- variants can't be inserted into `role_grants` until the CHECK +-- constraint on `resource_type` permits their string discriminators. +-- +-- CalDAV and CardDAV surfaces have historically enforced access via +-- dedicated per-domain share tables (`caldav.calendar_shares`, +-- `carddav.address_book_shares`) and bespoke `check_calendar_access` +-- / `check_address_book_access` helpers. Round 3 folds both into the +-- unified ReBAC engine so: +-- +-- * A single ACL source of truth (`storage.role_grants`) covers +-- every OxiCloud resource type — files, folders, drives, +-- calendars, address books. +-- * Group subjects become a free feature on calendar/book shares +-- (falls out of `role_grants.subject_type='group'`). +-- * The `authz.require` audit line ("👮🏻‍♂️ perms: ⛔ …") fires on +-- denial with no per-domain retrofit. +-- +-- Migration of existing rows from `caldav.calendar_shares` and +-- `carddav.address_book_shares` into `role_grants` happens in the +-- next migration (Phase 2). The legacy tables stay in place through +-- this PR for rollback safety; they get dropped one release later. + +-- `resource_type` is a TEXT column with a CHECK constraint (not a PG +-- enum), so extending it is a DROP / ADD pair — no `ALTER TYPE` / +-- non-transactional migration issues. + +ALTER TABLE storage.role_grants + DROP CONSTRAINT IF EXISTS role_grants_resource_type_check; + +ALTER TABLE storage.role_grants + ADD CONSTRAINT role_grants_resource_type_check + CHECK (resource_type IN ('folder', 'file', 'drive', 'calendar', 'address_book')); + +-- Post-flight: introspect the live constraint definition and prove +-- both new values appear. Cheap read-only check with no INSERT. +DO $BODY$ +DECLARE + defn TEXT; +BEGIN + SELECT pg_get_constraintdef(c.oid) INTO defn + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE n.nspname = 'storage' + AND t.relname = 'role_grants' + AND c.conname = 'role_grants_resource_type_check'; + + IF defn IS NULL THEN + RAISE EXCEPTION + 'role_grants_resource_type_check not found on storage.role_grants'; + END IF; + IF position('calendar' IN defn) = 0 THEN + RAISE EXCEPTION + 'CHECK constraint does not admit ''calendar'': %', defn; + END IF; + IF position('address_book' IN defn) = 0 THEN + RAISE EXCEPTION + 'CHECK constraint does not admit ''address_book'': %', defn; + END IF; +END; +$BODY$; diff --git a/migrations/20260906000001_backfill_calendar_address_book_role_grants.sql b/migrations/20260906000001_backfill_calendar_address_book_role_grants.sql new file mode 100644 index 00000000..b70f3aae --- /dev/null +++ b/migrations/20260906000001_backfill_calendar_address_book_role_grants.sql @@ -0,0 +1,145 @@ +-- ───────────────────────────────────────────────────────────────────────── +-- Round 3 Phase 2 — backfill role_grants from the legacy per-domain +-- share tables. +-- +-- Companion to `20260906000000_role_grants_calendar_address_book.sql` +-- (Phase 1: CHECK constraint extension). This migration seeds the +-- unified `storage.role_grants` table with: +-- +-- 1. Owner grants for every existing calendar and address book — +-- replaces the implicit "owner via `caldav.calendars.owner_id`" +-- short-circuit that the bespoke `check_calendar_access` +-- helper used. +-- 2. Non-owner grants translated from `caldav.calendar_shares` and +-- `carddav.address_book_shares` — the existing "shared with me" +-- relationships continue working after Phase 3's service +-- rewrite starts reading grants from `role_grants` only. +-- +-- The legacy share tables stay in place through this PR for +-- rollback safety. They get dropped in a follow-up migration one +-- release later, once the new engine path bakes. +-- +-- Idempotent: every INSERT uses `ON CONFLICT DO NOTHING` on the +-- `(subject_type, subject_id, resource_type, resource_id)` unique +-- key so a re-run (or a duplicate row in the legacy table where +-- someone shared with themselves) is a no-op. + +-- ── 1. Owner grants for calendars ─────────────────────────────────────── +-- +-- One row per calendar in `caldav.calendars`. `granted_by = owner_id` +-- is the self-seeded creation event — the calendar's owner brought +-- themselves into existence as its owner, matching the pattern used +-- by the drive lifecycle hook for personal drives. +INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, role, granted_by) +SELECT 'user', c.owner_id, 'calendar', c.id, 'owner'::storage.grant_role, c.owner_id + FROM caldav.calendars c +ON CONFLICT (subject_type, subject_id, resource_type, resource_id) + DO NOTHING; + +-- ── 2. Owner grants for address books ─────────────────────────────────── +INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, role, granted_by) +SELECT 'user', a.owner_id, 'address_book', a.id, 'owner'::storage.grant_role, a.owner_id + FROM carddav.address_books a +ON CONFLICT (subject_type, subject_id, resource_type, resource_id) + DO NOTHING; + +-- ── 3. Non-owner grants from calendar_shares ──────────────────────────── +-- +-- `caldav.calendar_shares.access_level` is a VARCHAR(10) with values +-- `'read'`, `'write'`, or `'owner'`. Map: +-- - `'read'` → `viewer` (bundle: Read only) +-- - `'write'` → `editor` (bundle: Read + Update) +-- - `'owner'` → `owner` (bundle: everything, including Share/Manage) +-- Anything else (defensive) falls through to `viewer` — losing +-- permission is safer than silently gaining permission if a stray +-- value slipped past the pre-D0 CHECK. +-- +-- `granted_by` = calendar owner, since the legacy share table didn't +-- track the granter. Best available signal — the owner is the only +-- principal who could have created the share via the legacy code path. +INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, role, granted_by) +SELECT + 'user', + s.user_id, + 'calendar', + s.calendar_id, + (CASE s.access_level + WHEN 'write' THEN 'editor' + WHEN 'owner' THEN 'owner' + ELSE 'viewer' + END)::storage.grant_role, + c.owner_id + FROM caldav.calendar_shares s + JOIN caldav.calendars c ON c.id = s.calendar_id + WHERE s.user_id <> c.owner_id -- skip self-shares (owner grant already covers them) +ON CONFLICT (subject_type, subject_id, resource_type, resource_id) + DO NOTHING; + +-- ── 4. Non-owner grants from address_book_shares ──────────────────────── +-- +-- `carddav.address_book_shares.can_write` is a BOOLEAN. Map: +-- - `false` → `viewer` +-- - `true` → `editor` +INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, role, granted_by) +SELECT + 'user', + s.user_id, + 'address_book', + s.address_book_id, + (CASE WHEN s.can_write THEN 'editor' ELSE 'viewer' END)::storage.grant_role, + a.owner_id + FROM carddav.address_book_shares s + JOIN carddav.address_books a ON a.id = s.address_book_id + WHERE s.user_id <> a.owner_id +ON CONFLICT (subject_type, subject_id, resource_type, resource_id) + DO NOTHING; + +-- ── 5. Post-flight sanity ─────────────────────────────────────────────── +-- +-- Every calendar / address book must now have an owner role_grant. +-- If any row is missing one, the Phase 3 service rewrite would +-- lock owners out of their own resources — refuse to leave the +-- migration in that state. +DO $BODY$ +DECLARE + missing_cal_owners BIGINT; + missing_ab_owners BIGINT; +BEGIN + SELECT COUNT(*) INTO missing_cal_owners + FROM caldav.calendars c + WHERE NOT EXISTS ( + SELECT 1 FROM storage.role_grants g + WHERE g.subject_type = 'user' + AND g.subject_id = c.owner_id + AND g.resource_type = 'calendar' + AND g.resource_id = c.id + AND g.role = 'owner'::storage.grant_role + ); + + SELECT COUNT(*) INTO missing_ab_owners + FROM carddav.address_books a + WHERE NOT EXISTS ( + SELECT 1 FROM storage.role_grants g + WHERE g.subject_type = 'user' + AND g.subject_id = a.owner_id + AND g.resource_type = 'address_book' + AND g.resource_id = a.id + AND g.role = 'owner'::storage.grant_role + ); + + IF missing_cal_owners > 0 THEN + RAISE EXCEPTION + 'Round 3 backfill left % calendars without an Owner role_grant', + missing_cal_owners; + END IF; + IF missing_ab_owners > 0 THEN + RAISE EXCEPTION + 'Round 3 backfill left % address books without an Owner role_grant', + missing_ab_owners; + END IF; +END; +$BODY$; diff --git a/migrations/20260906000002_drop_legacy_share_tables.sql b/migrations/20260906000002_drop_legacy_share_tables.sql new file mode 100644 index 00000000..4cf8b9ca --- /dev/null +++ b/migrations/20260906000002_drop_legacy_share_tables.sql @@ -0,0 +1,38 @@ +-- Drop the pre-Round-3 per-domain share tables. Every reader/writer +-- was retired in the Rust cleanup landing alongside this migration: +-- +-- * `CalendarUseCase::{list_shared_calendars, share_calendar, +-- remove_calendar_sharing, get_calendar_shares}` — gone +-- * `AddressBookUseCase::{share_address_book, unshare_address_book, +-- get_address_book_shares}` — gone +-- * `CalendarRepository` / `AddressBookRepository` share methods — gone +-- * SQL bodies in `calendar_pg_repository.rs` / +-- `address_book_pg_repository.rs` that touched these tables — gone +-- +-- Data lives on in `storage.role_grants` (backfilled by +-- `20260906000001_backfill_calendar_address_book_role_grants.sql`). +-- The one-release rollback window between the backfill and this drop +-- was left implicit — no external process reads either table today. + +DROP TABLE IF EXISTS caldav.calendar_shares; +DROP TABLE IF EXISTS carddav.address_book_shares; + +-- Post-flight introspection: refuse to complete if either table is +-- still present. Guards against a name-collision resurrection by an +-- older seed file or hand-rolled restore step. +DO $$ +DECLARE + stray_count INT; +BEGIN + SELECT COUNT(*) INTO stray_count + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE (n.nspname = 'caldav' AND c.relname = 'calendar_shares') + OR (n.nspname = 'carddav' AND c.relname = 'address_book_shares'); + + IF stray_count > 0 THEN + RAISE EXCEPTION + 'Migration 20260906000002 finished with % legacy share table(s) still present', + stray_count; + END IF; +END $$; diff --git a/src/application/dtos/address_book_dto.rs b/src/application/dtos/address_book_dto.rs index 7523f655..853a1b00 100644 --- a/src/application/dtos/address_book_dto.rs +++ b/src/application/dtos/address_book_dto.rs @@ -61,16 +61,3 @@ pub struct UpdateAddressBookDto { pub is_public: Option, pub user_id: String, // Current user making the update } - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ShareAddressBookDto { - pub address_book_id: String, - pub user_id: String, - pub can_write: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UnshareAddressBookDto { - pub address_book_id: String, - pub user_id: String, -} diff --git a/src/application/dtos/grant_dto.rs b/src/application/dtos/grant_dto.rs index f1e7cf93..c1941fa5 100644 --- a/src/application/dtos/grant_dto.rs +++ b/src/application/dtos/grant_dto.rs @@ -55,11 +55,13 @@ impl From for SubjectDto { } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "snake_case")] pub enum ResourceTypeDto { Folder, File, Drive, + Calendar, + AddressBook, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -75,6 +77,8 @@ impl From for Resource { ResourceTypeDto::Folder => Resource::Folder(dto.id), ResourceTypeDto::File => Resource::File(dto.id), ResourceTypeDto::Drive => Resource::Drive(dto.id), + ResourceTypeDto::Calendar => Resource::Calendar(dto.id), + ResourceTypeDto::AddressBook => Resource::AddressBook(dto.id), } } } @@ -85,6 +89,8 @@ impl From for ResourceDto { Resource::Folder(id) => (ResourceTypeDto::Folder, id), Resource::File(id) => (ResourceTypeDto::File, id), Resource::Drive(id) => (ResourceTypeDto::Drive, id), + Resource::Calendar(id) => (ResourceTypeDto::Calendar, id), + Resource::AddressBook(id) => (ResourceTypeDto::AddressBook, id), }; ResourceDto { kind, id } } diff --git a/src/application/ports/authorization_ports.rs b/src/application/ports/authorization_ports.rs index 3c4e456e..6b10a143 100644 --- a/src/application/ports/authorization_ports.rs +++ b/src/application/ports/authorization_ports.rs @@ -62,6 +62,8 @@ pub trait AuthorizationEngine: Send + Sync + 'static { Resource::Folder(id) => ("Folder", id), Resource::File(id) => ("File", id), Resource::Drive(id) => ("Drive", id), + Resource::Calendar(id) => ("Calendar", id), + Resource::AddressBook(id) => ("AddressBook", id), }; // Audit-worthy: denials are the interesting signal. Routed // through the `audit` tracing target so log aggregators can diff --git a/src/application/ports/calendar_ports.rs b/src/application/ports/calendar_ports.rs index 28728a88..332da7ec 100644 --- a/src/application/ports/calendar_ports.rs +++ b/src/application/ports/calendar_ports.rs @@ -25,38 +25,11 @@ pub trait CalendarStoragePort: Send + Sync + 'static { &self, owner_id: Uuid, ) -> Result, DomainError>; - async fn list_calendars_shared_with_user( - &self, - user_id: Uuid, - ) -> Result, DomainError>; async fn list_public_calendars( &self, limit: i64, offset: i64, ) -> Result, DomainError>; - async fn check_calendar_access( - &self, - calendar_id: &str, - user_id: Uuid, - ) -> Result; - - // Calendar sharing - async fn share_calendar( - &self, - calendar_id: &str, - user_id: Uuid, - access_level: &str, - ) -> Result<(), DomainError>; - async fn remove_calendar_sharing( - &self, - calendar_id: &str, - user_id: Uuid, - ) -> Result<(), DomainError>; - async fn get_calendar_shares( - &self, - calendar_id: &str, - ) -> Result, DomainError>; - // Calendar properties async fn set_calendar_property( &self, @@ -146,33 +119,12 @@ pub trait CalendarUseCase: Send + Sync + 'static { user_id: Uuid, ) -> Result; async fn list_my_calendars(&self, user_id: Uuid) -> Result, DomainError>; - async fn list_shared_calendars(&self, user_id: Uuid) -> Result, DomainError>; async fn list_public_calendars( &self, limit: Option, offset: Option, ) -> Result, DomainError>; - // Calendar sharing - async fn share_calendar( - &self, - calendar_id: &str, - target_user_id: Uuid, - access_level: &str, - caller_user_id: Uuid, - ) -> Result<(), DomainError>; - async fn remove_calendar_sharing( - &self, - calendar_id: &str, - target_user_id: Uuid, - caller_user_id: Uuid, - ) -> Result<(), DomainError>; - async fn get_calendar_shares( - &self, - calendar_id: &str, - user_id: Uuid, - ) -> Result, DomainError>; - // Event operations async fn create_event( &self, diff --git a/src/application/ports/carddav_ports.rs b/src/application/ports/carddav_ports.rs index cb842c14..3dd420a8 100644 --- a/src/application/ports/carddav_ports.rs +++ b/src/application/ports/carddav_ports.rs @@ -1,16 +1,105 @@ use crate::application::dtos::address_book_dto::{ - AddressBookDto, CreateAddressBookDto, ShareAddressBookDto, UnshareAddressBookDto, - UpdateAddressBookDto, + AddressBookDto, CreateAddressBookDto, UpdateAddressBookDto, }; use crate::application::dtos::contact_dto::{ ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, CreateContactVCardDto, GroupMembershipDto, UpdateContactDto, UpdateContactGroupDto, }; use crate::common::errors::DomainError; +use crate::domain::entities::contact::{AddressBook, Contact, ContactGroup}; use uuid::Uuid; pub type CardDavRepositoryError = DomainError; +/// Low-level storage port for CardDAV resources. Post-Round-3 the +/// port covers ONLY raw storage operations — everything that used +/// to be routed through it for sharing (`share_address_book`, +/// `unshare_address_book`, `get_address_book_shares`) or +/// scope-listing (`get_address_books_by_owner`, +/// `get_shared_address_books`) is gone. Access decisions live in +/// `AuthorizationEngine`; sharing state lives in +/// `storage.role_grants`. The service layer (`ContactService`) gates +/// each call, then reaches through this port for storage. +/// +/// Symmetric with `CalendarStoragePort`. Implemented by +/// `ContactStorageAdapter` against Postgres today; a future backend +/// (external CardDAV, LDAP directory, in-memory test mock) would +/// implement the same trait and swap in via DI. +pub trait ContactStoragePort: Send + Sync + 'static { + // ── Address books ──────────────────────────────────────────── + async fn create_address_book( + &self, + address_book: AddressBook, + ) -> Result; + async fn update_address_book( + &self, + address_book: AddressBook, + ) -> Result; + async fn delete_address_book(&self, id: &Uuid) -> Result<(), DomainError>; + async fn get_address_book_by_id(&self, id: &Uuid) -> Result, DomainError>; + async fn get_public_address_books(&self) -> Result, DomainError>; + + // ── Contacts ───────────────────────────────────────────────── + async fn create_contact(&self, contact: Contact) -> Result; + async fn update_contact(&self, contact: Contact) -> Result; + async fn delete_contact(&self, id: &Uuid) -> Result<(), DomainError>; + async fn get_contact_by_id(&self, id: &Uuid) -> Result, DomainError>; + /// Indexed single-row lookup by vCard UID within a specific book. + async fn get_contact_by_uid( + &self, + address_book_id: &Uuid, + uid: &str, + ) -> Result, DomainError>; + /// Indexed batch lookup by vCard UID within a specific book. + async fn get_contacts_by_uids( + &self, + address_book_id: &Uuid, + uids: &[String], + ) -> Result, DomainError>; + async fn get_contacts_by_address_book( + &self, + address_book_id: &Uuid, + ) -> Result, DomainError>; + async fn get_contacts_by_address_book_paginated( + &self, + address_book_id: &Uuid, + limit: i64, + offset: i64, + ) -> Result, DomainError>; + async fn search_contacts( + &self, + address_book_id: &Uuid, + query: &str, + ) -> Result, DomainError>; + + // ── Contact groups ─────────────────────────────────────────── + async fn create_group(&self, group: ContactGroup) -> Result; + async fn update_group(&self, group: ContactGroup) -> Result; + async fn delete_group(&self, id: &Uuid) -> Result<(), DomainError>; + async fn get_group_by_id(&self, id: &Uuid) -> Result, DomainError>; + async fn get_groups_by_address_book( + &self, + address_book_id: &Uuid, + ) -> Result, DomainError>; + + // ── Group membership ───────────────────────────────────────── + async fn add_contact_to_group( + &self, + group_id: &Uuid, + contact_id: &Uuid, + ) -> Result<(), DomainError>; + async fn remove_contact_from_group( + &self, + group_id: &Uuid, + contact_id: &Uuid, + ) -> Result<(), DomainError>; + async fn get_contacts_in_group(&self, group_id: &Uuid) -> Result, DomainError>; + async fn get_groups_for_contact( + &self, + contact_id: &Uuid, + ) -> Result, DomainError>; +} + pub trait AddressBookUseCase: Send + Sync + 'static { // Address Book operations async fn create_address_book( @@ -37,23 +126,6 @@ pub trait AddressBookUseCase: Send + Sync + 'static { user_id: Uuid, ) -> Result, DomainError>; async fn list_public_address_books(&self) -> Result, DomainError>; - - // Address Book sharing - async fn share_address_book( - &self, - dto: ShareAddressBookDto, - user_id: Uuid, - ) -> Result<(), DomainError>; - async fn unshare_address_book( - &self, - dto: UnshareAddressBookDto, - user_id: Uuid, - ) -> Result<(), DomainError>; - async fn get_address_book_shares( - &self, - address_book_id: &str, - user_id: Uuid, - ) -> Result, DomainError>; } pub trait ContactUseCase: Send + Sync + 'static { diff --git a/src/application/ports/storage_ports.rs b/src/application/ports/storage_ports.rs index 5e4bf4e8..1157f649 100644 --- a/src/application/ports/storage_ports.rs +++ b/src/application/ports/storage_ports.rs @@ -1,6 +1,5 @@ use bytes::Bytes; use futures::Stream; -use serde_json::Value; use std::path::PathBuf; use std::pin::Pin; use uuid::Uuid; @@ -446,9 +445,3 @@ pub trait StorageUsagePort: Send + Sync + 'static { additional_bytes: u64, ) -> Result<(), DomainError>; } - -/// Generic storage service interface for calendar and contact services -pub trait StorageUseCase: Send + Sync + 'static { - /// Handle a request with the specified action and parameters - async fn handle_request(&self, action: &str, params: Value) -> Result; -} diff --git a/src/application/services/calendar_service.rs b/src/application/services/calendar_service.rs index 762a38d9..fcd7cab0 100644 --- a/src/application/services/calendar_service.rs +++ b/src/application/services/calendar_service.rs @@ -1,4 +1,5 @@ use chrono::{DateTime, Utc}; +use std::collections::HashSet; use std::sync::Arc; use uuid::Uuid; @@ -6,17 +7,80 @@ use crate::application::dtos::calendar_dto::{ CalendarDto, CalendarEventDto, CreateCalendarDto, CreateEventDto, CreateEventICalDto, UpdateCalendarDto, UpdateEventDto, }; +use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::calendar_ports::{CalendarStoragePort, CalendarUseCase}; use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::services::authorization::{Permission, Resource, Role, Subject}; use crate::infrastructure::adapters::calendar_storage_adapter::CalendarStorageAdapter; +use crate::infrastructure::services::pg_acl_engine::PgAclEngine; +/// Calendar service — the CalDAV / REST entry point for every calendar +/// or event operation. Every method routes through `AuthorizationEngine`; +/// the pre-Round-3 `check_calendar_access` bespoke helper is gone. +/// +/// Ownership + sharing live entirely in `storage.role_grants` +/// (`resource_type='calendar'`). `caldav.calendars.owner_id` stays for +/// provenance and legacy queries but is no longer consulted for access +/// decisions. pub struct CalendarService { calendar_storage: Arc, + /// ReBAC engine — every user-facing method calls `authz.require` + /// with the appropriate `Permission`. `create_calendar` also + /// uses it to seed an Owner grant for the caller so the common + /// "owning my own calendar" case takes a single indexed + /// role_grants lookup. + authz: Arc, } impl CalendarService { - pub fn new(calendar_storage: Arc) -> Self { - Self { calendar_storage } + pub fn new(calendar_storage: Arc, authz: Arc) -> Self { + Self { + calendar_storage, + authz, + } + } + + /// Parse `calendar_id` and enforce `permission` on `Resource::Calendar(uuid)`. + /// On denial `authz.require` returns `NotFound` (anti-enum — same + /// shape as "no such calendar") and emits the `authz.denied` audit + /// line. Returns the parsed UUID on success so the caller doesn't + /// have to parse it a second time. + async fn require_calendar_perm( + &self, + calendar_id: &str, + caller_id: Uuid, + permission: Permission, + ) -> Result { + let uuid = Uuid::parse_str(calendar_id) + .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid ID"))?; + self.authz + .require( + Subject::User(caller_id), + permission, + Resource::Calendar(uuid), + ) + .await?; + Ok(uuid) + } + + /// Check `permission` on a calendar without throwing. Used by the + /// read paths that also allow a public-calendar bypass — they need + /// a bool, not a `Result<(), NotFound>`. + async fn has_calendar_perm( + &self, + calendar_id: &str, + caller_id: Uuid, + permission: Permission, + ) -> Result { + let uuid = Uuid::parse_str(calendar_id) + .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid ID"))?; + self.authz + .check( + Subject::User(caller_id), + permission, + Resource::Calendar(uuid), + ) + .await } } @@ -26,9 +90,30 @@ impl CalendarUseCase for CalendarService { calendar: CreateCalendarDto, user_id: Uuid, ) -> Result { - self.calendar_storage + // No pre-write gate: creating a calendar is a personal act + // (like creating a folder in your own drive). Storage stamps + // `owner_id = user_id`; we then seed an Owner role_grant so + // the engine's cache warms on first-read. + let created = self + .calendar_storage .create_calendar(calendar, user_id) - .await + .await?; + let calendar_uuid = Uuid::parse_str(&created.id).map_err(|_| { + DomainError::internal_error("Calendar", "storage returned invalid calendar id") + })?; + // `set_role` is idempotent on the `(subject, resource)` unique + // key — a re-run (rare — only if storage retried) is a no-op. + // `granted_by = user_id` is the self-seeded creation event. + self.authz + .set_role( + user_id, + Subject::User(user_id), + Role::Owner, + Resource::Calendar(calendar_uuid), + None, + ) + .await?; + Ok(created) } async fn update_calendar( @@ -37,35 +122,28 @@ impl CalendarUseCase for CalendarService { update: UpdateCalendarDto, user_id: Uuid, ) -> Result { - let has_access = self - .calendar_storage - .check_calendar_access(calendar_id, user_id) + self.require_calendar_perm(calendar_id, user_id, Permission::Update) .await?; - if !has_access { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "You don't have permission to update this calendar", - )); - } self.calendar_storage .update_calendar(calendar_id, update) .await } async fn delete_calendar(&self, calendar_id: &str, user_id: Uuid) -> Result<(), DomainError> { - let has_access = self - .calendar_storage - .check_calendar_access(calendar_id, user_id) + let uuid = self + .require_calendar_perm(calendar_id, user_id, Permission::Delete) .await?; - if !has_access { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "You don't have permission to delete this calendar", - )); - } - self.calendar_storage.delete_calendar(calendar_id).await + self.calendar_storage.delete_calendar(calendar_id).await?; + // Wipe every grant on this calendar so a re-used UUID (impossible + // today but cheap to defend against) doesn't inherit stale ACLs. + // The storage DELETE won't cascade to `storage.role_grants` — the + // legacy `caldav.calendar_shares` had an FK, `role_grants` + // doesn't (it's cross-schema). + let _ = self + .authz + .revoke_all_for_resource(Resource::Calendar(uuid)) + .await; + Ok(()) } async fn get_calendar( @@ -74,28 +152,52 @@ impl CalendarUseCase for CalendarService { user_id: Uuid, ) -> Result { let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - let has_access = self - .calendar_storage - .check_calendar_access(calendar_id, user_id) - .await?; - if !has_access && !calendar.is_public { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "You don't have permission to view this calendar", - )); + // Public-calendar bypass: anonymous-ish read. `check` returns + // bool (no throw); combine with the public flag before + // deciding. + let allowed = calendar.is_public + || self + .has_calendar_perm(calendar_id, user_id, Permission::Read) + .await?; + if !allowed { + return Err(DomainError::not_found("Calendar", calendar_id)); } Ok(calendar) } async fn list_my_calendars(&self, user_id: Uuid) -> Result, DomainError> { - self.calendar_storage.list_calendars_by_owner(user_id).await - } + // Post-Round-3 semantics: every calendar the caller has any + // grant on — owned + shared, one union. The pre-Round-3 + // `list_calendars_by_owner` returned owner-only; shared + // calendars never surfaced through this method. See + // `docs/plan/caldav-carddav-migration-to-authz.md`. + let grants = self + .authz + .list_incoming_grants(Subject::User(user_id)) + .await?; - async fn list_shared_calendars(&self, user_id: Uuid) -> Result, DomainError> { - self.calendar_storage - .list_calendars_shared_with_user(user_id) - .await + // Deduplicate — a user can hold multiple grants on the same + // calendar (direct + group-inherited). We only need one DTO + // per resource. + let calendar_ids: HashSet = grants + .into_iter() + .filter_map(|g| match g.resource { + Resource::Calendar(id) => Some(id), + _ => None, + }) + .collect(); + + // Hydrate DTOs. `get_calendar` misses on trashed / deleted + // calendars — those are dropped from the listing rather than + // erroring, so a lifecycle-race doesn't turn a PROPFIND into + // a 5xx. + let mut out = Vec::with_capacity(calendar_ids.len()); + for id in calendar_ids { + if let Ok(dto) = self.calendar_storage.get_calendar(&id.to_string()).await { + out.push(dto); + } + } + Ok(out) } async fn list_public_calendars( @@ -103,6 +205,8 @@ impl CalendarUseCase for CalendarService { limit: Option, offset: Option, ) -> Result, DomainError> { + // No caller gate: public listing by definition. Storage + // filters on `is_public = true`. let limit = limit.unwrap_or(100); let offset = offset.unwrap_or(0); self.calendar_storage @@ -110,90 +214,13 @@ impl CalendarUseCase for CalendarService { .await } - async fn share_calendar( - &self, - calendar_id: &str, - target_user_id: Uuid, - access_level: &str, - caller_user_id: Uuid, - ) -> Result<(), DomainError> { - let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - if calendar.owner_id != caller_user_id.to_string() { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "Only the calendar owner can change sharing settings", - )); - } - match access_level { - "read" | "write" | "owner" => {} - _ => { - return Err(DomainError::new( - ErrorKind::InvalidInput, - "Calendar", - format!( - "Invalid access level: {}. Valid values are: read, write, owner", - access_level - ), - )); - } - } - self.calendar_storage - .share_calendar(calendar_id, target_user_id, access_level) - .await - } - - async fn remove_calendar_sharing( - &self, - calendar_id: &str, - target_user_id: Uuid, - caller_user_id: Uuid, - ) -> Result<(), DomainError> { - let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - if calendar.owner_id != caller_user_id.to_string() { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "Only the calendar owner can change sharing settings", - )); - } - self.calendar_storage - .remove_calendar_sharing(calendar_id, target_user_id) - .await - } - - async fn get_calendar_shares( - &self, - calendar_id: &str, - user_id: Uuid, - ) -> Result, DomainError> { - let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - if calendar.owner_id != user_id.to_string() { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "Only the calendar owner can view sharing settings", - )); - } - self.calendar_storage.get_calendar_shares(calendar_id).await - } - async fn create_event( &self, event: CreateEventDto, user_id: Uuid, ) -> Result { - let has_access = self - .calendar_storage - .check_calendar_access(&event.calendar_id, user_id) + self.require_calendar_perm(&event.calendar_id, user_id, Permission::Create) .await?; - if !has_access { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "You don't have permission to add events to this calendar", - )); - } self.calendar_storage.create_event(event).await } @@ -202,17 +229,8 @@ impl CalendarUseCase for CalendarService { event: CreateEventICalDto, user_id: Uuid, ) -> Result { - let has_access = self - .calendar_storage - .check_calendar_access(&event.calendar_id, user_id) + self.require_calendar_perm(&event.calendar_id, user_id, Permission::Create) .await?; - if !has_access { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "You don't have permission to add events to this calendar", - )); - } self.calendar_storage.create_event_from_ical(event).await } @@ -223,33 +241,15 @@ impl CalendarUseCase for CalendarService { user_id: Uuid, ) -> Result { let event = self.calendar_storage.get_event(event_id).await?; - let has_access = self - .calendar_storage - .check_calendar_access(&event.calendar_id, user_id) + self.require_calendar_perm(&event.calendar_id, user_id, Permission::Update) .await?; - if !has_access { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "You don't have permission to update events in this calendar", - )); - } self.calendar_storage.update_event(event_id, update).await } async fn delete_event(&self, event_id: &str, user_id: Uuid) -> Result<(), DomainError> { let event = self.calendar_storage.get_event(event_id).await?; - let has_access = self - .calendar_storage - .check_calendar_access(&event.calendar_id, user_id) + self.require_calendar_perm(&event.calendar_id, user_id, Permission::Delete) .await?; - if !has_access { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "You don't have permission to delete events in this calendar", - )); - } self.calendar_storage.delete_event(event_id).await } @@ -259,20 +259,17 @@ impl CalendarUseCase for CalendarService { user_id: Uuid, ) -> Result { let event = self.calendar_storage.get_event(event_id).await?; - let has_access = self - .calendar_storage - .check_calendar_access(&event.calendar_id, user_id) - .await?; let calendar = self .calendar_storage .get_calendar(&event.calendar_id) .await?; - if !has_access && !calendar.is_public { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "You don't have permission to view events in this calendar", - )); + // Same public-calendar bypass as `get_calendar`. + let allowed = calendar.is_public + || self + .has_calendar_perm(&event.calendar_id, user_id, Permission::Read) + .await?; + if !allowed { + return Err(DomainError::not_found("Event", event_id)); } Ok(event) } @@ -283,17 +280,13 @@ impl CalendarUseCase for CalendarService { ical_uid: &str, user_id: Uuid, ) -> Result, DomainError> { - let has_access = self - .calendar_storage - .check_calendar_access(calendar_id, user_id) - .await?; let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - if !has_access && !calendar.is_public { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "You don't have permission to view events in this calendar", - )); + let allowed = calendar.is_public + || self + .has_calendar_perm(calendar_id, user_id, Permission::Read) + .await?; + if !allowed { + return Err(DomainError::not_found("Calendar", calendar_id)); } self.calendar_storage .find_event_by_ical_uid(calendar_id, ical_uid) @@ -306,17 +299,13 @@ impl CalendarUseCase for CalendarService { ical_uids: &[String], user_id: Uuid, ) -> Result, DomainError> { - let has_access = self - .calendar_storage - .check_calendar_access(calendar_id, user_id) - .await?; let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - if !has_access && !calendar.is_public { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "You don't have permission to view events in this calendar", - )); + let allowed = calendar.is_public + || self + .has_calendar_perm(calendar_id, user_id, Permission::Read) + .await?; + if !allowed { + return Err(DomainError::not_found("Calendar", calendar_id)); } if ical_uids.is_empty() { return Ok(Vec::new()); @@ -333,17 +322,13 @@ impl CalendarUseCase for CalendarService { offset: Option, user_id: Uuid, ) -> Result, DomainError> { - let has_access = self - .calendar_storage - .check_calendar_access(calendar_id, user_id) - .await?; let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - if !has_access && !calendar.is_public { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "You don't have permission to view events in this calendar", - )); + let allowed = calendar.is_public + || self + .has_calendar_perm(calendar_id, user_id, Permission::Read) + .await?; + if !allowed { + return Err(DomainError::not_found("Calendar", calendar_id)); } if limit.is_some() || offset.is_some() { let limit = limit.unwrap_or(100); @@ -365,17 +350,13 @@ impl CalendarUseCase for CalendarService { end: DateTime, user_id: Uuid, ) -> Result, DomainError> { - let has_access = self - .calendar_storage - .check_calendar_access(calendar_id, user_id) - .await?; let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - if !has_access && !calendar.is_public { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "You don't have permission to view events in this calendar", - )); + let allowed = calendar.is_public + || self + .has_calendar_perm(calendar_id, user_id, Permission::Read) + .await?; + if !allowed { + return Err(DomainError::not_found("Calendar", calendar_id)); } self.calendar_storage .get_events_in_time_range(calendar_id, &start, &end) diff --git a/src/application/services/contact_service.rs b/src/application/services/contact_service.rs index b87642eb..45470c4f 100644 --- a/src/application/services/contact_service.rs +++ b/src/application/services/contact_service.rs @@ -3,109 +3,104 @@ use std::sync::Arc; use uuid::Uuid; use crate::application::dtos::address_book_dto::{ - AddressBookDto, CreateAddressBookDto, ShareAddressBookDto, UnshareAddressBookDto, - UpdateAddressBookDto, + AddressBookDto, CreateAddressBookDto, UpdateAddressBookDto, }; use crate::application::dtos::contact_dto::{ ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, CreateContactVCardDto, GroupMembershipDto, UpdateContactDto, UpdateContactGroupDto, }; -use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase}; -use crate::application::ports::storage_ports::StorageUseCase; +use crate::application::ports::authorization_ports::AuthorizationEngine; +use crate::application::ports::carddav_ports::{ + AddressBookUseCase, ContactStoragePort, ContactUseCase, +}; use crate::common::errors::DomainError; use crate::domain::entities::contact::{Address, AddressBook, Contact, ContactGroup, Email, Phone}; -use crate::domain::repositories::address_book_repository::AddressBookRepository; -use crate::domain::repositories::contact_repository::{ContactGroupRepository, ContactRepository}; -use crate::infrastructure::repositories::pg::AddressBookPgRepository; -use crate::infrastructure::repositories::pg::ContactGroupPgRepository; -use crate::infrastructure::repositories::pg::ContactPgRepository; +use crate::domain::services::authorization::{Permission, Resource, Role, Subject}; +use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter; +use crate::infrastructure::services::pg_acl_engine::PgAclEngine; +/// Contact service — the CardDAV / REST entry point for every +/// address-book or contact operation. Every method routes through +/// `AuthorizationEngine`; the pre-Round-3 `check_address_book_access` +/// / `check_address_book_write_access` bespoke helpers are gone. +/// +/// Ownership + sharing live entirely in `storage.role_grants` +/// (`resource_type='address_book'`). `carddav.address_books.owner_id` +/// stays for provenance and legacy queries but is no longer consulted +/// for access decisions. pub struct ContactService { - address_book_repository: Arc, - contact_repository: Arc, - contact_group_repository: Arc, + /// Storage port — bundles the three CardDAV PG repositories + /// (address_book, contact, contact_group) behind + /// `ContactStoragePort`. Symmetric with `CalendarService`'s + /// hold on `CalendarStorageAdapter`. + contact_storage: Arc, + /// ReBAC engine — every user-facing method calls `authz.require` + /// with the appropriate `Permission`. `create_address_book` also + /// uses it to seed an Owner grant for the caller so the common + /// "owning my own address book" case takes a single indexed + /// role_grants lookup. + authz: Arc, } impl ContactService { - pub fn new( - address_book_repository: Arc, - contact_repository: Arc, - contact_group_repository: Arc, - ) -> Self { + pub fn new(contact_storage: Arc, authz: Arc) -> Self { Self { - address_book_repository, - contact_repository, - contact_group_repository, + contact_storage, + authz, } } - // Helper methods - async fn check_address_book_access( + /// Enforce `permission` on `Resource::AddressBook(uuid)` and + /// return the hydrated entity. Denial routes through + /// `authz.require` → `NotFound` (anti-enum, same shape as "no + /// such address book") + `authz.denied` audit line. Used by + /// every method that needs both the entity AND the authz gate. + async fn require_address_book_perm( &self, address_book_id: &Uuid, - user_id: &Uuid, + caller_id: &Uuid, + permission: Permission, ) -> Result { - let address_book = self - .address_book_repository + self.authz + .require( + Subject::User(*caller_id), + permission, + Resource::AddressBook(*address_book_id), + ) + .await?; + self.contact_storage .get_address_book_by_id(address_book_id) .await? - .ok_or_else(|| DomainError::not_found("Address book", "not found"))?; - - // Check if user is owner - if address_book.owner_id() == user_id.to_string() { - return Ok(address_book); - } - - // Check if address book is shared with user - let shares = self - .address_book_repository - .get_address_book_shares(address_book_id) - .await?; - if shares.iter().any(|(id, _)| id == &user_id.to_string()) { - return Ok(address_book); - } - - // Check if address book is public - if address_book.is_public() { - return Ok(address_book); - } - - Err(DomainError::unauthorized( - "You don't have access to this address book", - )) + .ok_or_else(|| DomainError::not_found("Address book", "not found")) } - async fn check_address_book_write_access( + /// Read gate with the public-address-book bypass: any + /// authenticated OxiCloud user can Read a book marked + /// `is_public = true`, matching the pre-Round-3 behaviour and + /// the calendar `is_public` semantics. Write paths never use + /// this bypass — they go through `require_address_book_perm` + /// with `Update` / `Delete` / `Create` directly. + async fn require_address_book_read_or_public( &self, address_book_id: &Uuid, - user_id: &Uuid, + caller_id: &Uuid, ) -> Result { - let address_book = self - .address_book_repository + let book = self + .contact_storage .get_address_book_by_id(address_book_id) .await? .ok_or_else(|| DomainError::not_found("Address book", "not found"))?; - - // Check if user is owner - if address_book.owner_id() == user_id.to_string() { - return Ok(address_book); + if book.is_public() { + return Ok(book); } - - // Check if address book is shared with user with write access - let shares = self - .address_book_repository - .get_address_book_shares(address_book_id) + self.authz + .require( + Subject::User(*caller_id), + Permission::Read, + Resource::AddressBook(*address_book_id), + ) .await?; - if shares - .iter() - .any(|(id, can_write)| id == &user_id.to_string() && *can_write) - { - return Ok(address_book); - } - - Err(DomainError::unauthorized( - "You don't have write access to this address book", - )) + Ok(book) } fn parse_vcard(&self, vcard_data: &str) -> Result { @@ -271,6 +266,11 @@ impl AddressBookUseCase for ContactService { &self, dto: CreateAddressBookDto, ) -> Result { + // Legacy DTO carries the caller as `owner_id`. Parse it once + // so the Owner-grant seed below can use the typed UUID; failed + // parse maps to InvalidInput. + let owner_id = Uuid::parse_str(&dto.owner_id) + .map_err(|_| DomainError::validation_error("Invalid owner ID format"))?; let address_book = AddressBook::new( dto.name, dto.owner_id, @@ -280,9 +280,21 @@ impl AddressBookUseCase for ContactService { ); let created_address_book = self - .address_book_repository + .contact_storage .create_address_book(address_book) .await?; + // Seed the Owner role_grant so the engine's cache warms on + // the caller's first read. `set_role` is idempotent on the + // unique key — a re-run is a no-op. + self.authz + .set_role( + owner_id, + Subject::User(owner_id), + Role::Owner, + Resource::AddressBook(*created_address_book.id()), + None, + ) + .await?; Ok(AddressBookDto::from(created_address_book)) } @@ -294,13 +306,15 @@ impl AddressBookUseCase for ContactService { let id = Uuid::parse_str(address_book_id) .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; - // Check if user has write access to the address book + // AuthZ: caller must have Update on the address book. + // `update.user_id` in the DTO is the caller's own id — this + // is legacy from the pre-Round-3 CardDAV flow. Post-Round-3 + // the caller is authoritative from the JWT extractor at the + // handler; keeping the DTO field for wire compat. + let caller_id = Uuid::parse_str(&update.user_id) + .map_err(|_| DomainError::validation_error("Invalid user ID format"))?; let address_book = self - .check_address_book_write_access( - &id, - &Uuid::parse_str(&update.user_id) - .map_err(|_| DomainError::validation_error("Invalid user ID format"))?, - ) + .require_address_book_perm(&id, &caller_id, Permission::Update) .await?; // Apply updates @@ -322,7 +336,7 @@ impl AddressBookUseCase for ContactService { ); let result = self - .address_book_repository + .contact_storage .update_address_book(updated_address_book) .await?; Ok(AddressBookDto::from(result)) @@ -336,22 +350,22 @@ impl AddressBookUseCase for ContactService { let id = Uuid::parse_str(address_book_id) .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; - // Verify that the user is the owner of the address book - let address_book = self - .address_book_repository - .get_address_book_by_id(&id) - .await? - .ok_or_else(|| DomainError::not_found("Address book", "not found"))?; - - if address_book.owner_id() != user_id.to_string() { - return Err(DomainError::unauthorized( - "Only the owner can delete an address book", - )); - } - - self.address_book_repository - .delete_address_book(&id) + // AuthZ: caller must have Delete on the address book. Only + // Owner grants include Delete in their bundle today, matching + // the pre-Round-3 owner-only rule; if `Contributor` ever grows + // a Delete bundle it inherits the ability here for free. + self.require_address_book_perm(&id, &user_id, Permission::Delete) .await?; + + self.contact_storage.delete_address_book(&id).await?; + // Wipe every grant on this book so a re-used UUID doesn't + // inherit stale ACLs. Storage DELETE won't cascade to + // `storage.role_grants` — the legacy `carddav.address_book_shares` + // had an FK, `role_grants` doesn't (cross-schema). + let _ = self + .authz + .revoke_all_for_resource(Resource::AddressBook(id)) + .await; Ok(()) } @@ -363,7 +377,9 @@ impl AddressBookUseCase for ContactService { let id = Uuid::parse_str(address_book_id) .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; - let address_book = self.check_address_book_access(&id, &user_id).await?; + let address_book = self + .require_address_book_read_or_public(&id, &user_id) + .await?; Ok(AddressBookDto::from(address_book)) } @@ -371,156 +387,61 @@ impl AddressBookUseCase for ContactService { &self, user_id: Uuid, ) -> Result, DomainError> { - // Get address books owned by the user - let owned_address_books = self - .address_book_repository - .get_address_books_by_owner(user_id) + // Post-Round-3: every address book the caller has any grant on + // (owned + shared) comes from a single role_grants lookup. + // Public address books stay a separate query — they don't + // require a per-user grant, so a listing that ONLY filters on + // grants would miss them. + // + // Duplicate suppression: a book that's public AND directly + // granted to the caller shows up once. The HashMap keyed on + // `book.id` handles this cheaply. + let grants = self + .authz + .list_incoming_grants(Subject::User(user_id)) .await?; + let book_ids: std::collections::HashSet = grants + .into_iter() + .filter_map(|g| match g.resource { + Resource::AddressBook(id) => Some(id), + _ => None, + }) + .collect(); - // Get address books shared with the user - let shared_address_books = self - .address_book_repository - .get_shared_address_books(user_id) - .await?; - - // Get public address books - let public_address_books = self - .address_book_repository - .get_public_address_books() - .await?; - - // Combine all address books, avoiding duplicates let mut address_book_map = std::collections::HashMap::new(); - for address_book in owned_address_books { - address_book_map.insert(*address_book.id(), address_book); - } - - for address_book in shared_address_books { - address_book_map.insert(*address_book.id(), address_book); - } - - for address_book in public_address_books { - if address_book.owner_id() != user_id.to_string() - && !address_book_map.contains_key(address_book.id()) - { - address_book_map.insert(*address_book.id(), address_book); + for id in book_ids { + // Missing rows (deleted / trashed race) drop out silently + // — matches the calendar-listing carve-out. + if let Ok(Some(book)) = self.contact_storage.get_address_book_by_id(&id).await { + address_book_map.insert(*book.id(), book); } } - let address_books: Vec = address_book_map - .values() - .cloned() - .map(AddressBookDto::from) - .collect(); + // Public address books surface for every authenticated caller + // — same "internal-Read-for-everyone" semantics as + // `is_public` on calendars. + let public_address_books = self.contact_storage.get_public_address_books().await?; + for book in public_address_books { + if !address_book_map.contains_key(book.id()) { + address_book_map.insert(*book.id(), book); + } + } - Ok(address_books) + Ok(address_book_map + .into_values() + .map(AddressBookDto::from) + .collect()) } async fn list_public_address_books(&self) -> Result, DomainError> { - let address_books = self - .address_book_repository - .get_public_address_books() - .await?; + let address_books = self.contact_storage.get_public_address_books().await?; let dtos: Vec = address_books .into_iter() .map(AddressBookDto::from) .collect(); Ok(dtos) } - - async fn share_address_book( - &self, - dto: ShareAddressBookDto, - user_id: Uuid, - ) -> Result<(), DomainError> { - let id = Uuid::parse_str(&dto.address_book_id) - .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; - - // Verify that the user is the owner of the address book - let address_book = self - .address_book_repository - .get_address_book_by_id(&id) - .await? - .ok_or_else(|| DomainError::not_found("Address book", "not found"))?; - - if address_book.owner_id() != user_id.to_string() { - return Err(DomainError::unauthorized( - "Only the owner can share an address book", - )); - } - - // Don't allow sharing with yourself - if dto.user_id == user_id.to_string() { - return Err(DomainError::validation_error( - "Cannot share an address book with yourself", - )); - } - - let target_user_id = Uuid::parse_str(&dto.user_id) - .map_err(|_| DomainError::validation_error("Invalid target user ID format"))?; - self.address_book_repository - .share_address_book(&id, target_user_id, dto.can_write) - .await?; - Ok(()) - } - - async fn unshare_address_book( - &self, - dto: UnshareAddressBookDto, - user_id: Uuid, - ) -> Result<(), DomainError> { - let id = Uuid::parse_str(&dto.address_book_id) - .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; - - // Verify that the user is the owner of the address book - let address_book = self - .address_book_repository - .get_address_book_by_id(&id) - .await? - .ok_or_else(|| DomainError::not_found("Address book", "not found"))?; - - if address_book.owner_id() != user_id.to_string() { - return Err(DomainError::unauthorized( - "Only the owner can unshare an address book", - )); - } - - let target_user_id = Uuid::parse_str(&dto.user_id) - .map_err(|_| DomainError::validation_error("Invalid target user ID format"))?; - self.address_book_repository - .unshare_address_book(&id, target_user_id) - .await?; - Ok(()) - } - - async fn get_address_book_shares( - &self, - address_book_id: &str, - user_id: Uuid, - ) -> Result, DomainError> { - let id = Uuid::parse_str(address_book_id) - .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; - - // Verify that the user is the owner of the address book - let address_book = self - .address_book_repository - .get_address_book_by_id(&id) - .await? - .ok_or_else(|| DomainError::not_found("Address book", "not found"))?; - - if address_book.owner_id() != user_id.to_string() { - return Err(DomainError::unauthorized( - "Only the owner can view address book shares", - )); - } - - let shares = self - .address_book_repository - .get_address_book_shares(&id) - .await?; - Ok(shares) - } } impl ContactUseCase for ContactService { @@ -529,12 +450,10 @@ impl ContactUseCase for ContactService { .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; // Check if user has write access to the address book - self.check_address_book_write_access( - &address_book_id, - &Uuid::parse_str(&dto.user_id) - .map_err(|_| DomainError::validation_error("Invalid user ID format"))?, - ) - .await?; + let caller_id = Uuid::parse_str(&dto.user_id) + .map_err(|_| DomainError::validation_error("Invalid user ID format"))?; + self.require_address_book_perm(&address_book_id, &caller_id, Permission::Update) + .await?; // Convert DTOs to domain entities let email: Vec = dto @@ -596,7 +515,7 @@ impl ContactUseCase for ContactService { // Create the contact let created_contact = self - .contact_repository + .contact_storage .create_contact(contact_with_vcard) .await?; Ok(ContactDto::from(created_contact)) @@ -610,12 +529,10 @@ impl ContactUseCase for ContactService { .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; // Check if user has write access to the address book - self.check_address_book_write_access( - &address_book_id, - &Uuid::parse_str(&dto.user_id) - .map_err(|_| DomainError::validation_error("Invalid user ID format"))?, - ) - .await?; + let caller_id = Uuid::parse_str(&dto.user_id) + .map_err(|_| DomainError::validation_error("Invalid user ID format"))?; + self.require_address_book_perm(&address_book_id, &caller_id, Permission::Update) + .await?; // Parse vCard data let mut contact = self.parse_vcard(&dto.vcard)?; @@ -629,7 +546,7 @@ impl ContactUseCase for ContactService { contact.set_updated_at(now); // Create the contact - let created_contact = self.contact_repository.create_contact(contact).await?; + let created_contact = self.contact_storage.create_contact(contact).await?; Ok(ContactDto::from(created_contact)) } @@ -643,7 +560,7 @@ impl ContactUseCase for ContactService { // Get the current contact let contact = self - .contact_repository + .contact_storage .get_contact_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Contact", "not found"))?; @@ -651,8 +568,12 @@ impl ContactUseCase for ContactService { // Check if user has write access to the address book let update_user_id = Uuid::parse_str(&update.user_id) .map_err(|_| DomainError::validation_error("Invalid user ID format"))?; - self.check_address_book_write_access(contact.address_book_id(), &update_user_id) - .await?; + self.require_address_book_perm( + contact.address_book_id(), + &update_user_id, + Permission::Update, + ) + .await?; // Destructure contact into owned parts for updates let parts = contact.into_parts(); @@ -732,7 +653,7 @@ impl ContactUseCase for ContactService { // Update the contact let result = self - .contact_repository + .contact_storage .update_contact(contact_with_vcard) .await?; Ok(ContactDto::from(result)) @@ -744,17 +665,17 @@ impl ContactUseCase for ContactService { // Get the current contact let contact = self - .contact_repository + .contact_storage .get_contact_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Contact", "not found"))?; // Check if user has write access to the address book - self.check_address_book_write_access(contact.address_book_id(), &user_id) + self.require_address_book_perm(contact.address_book_id(), &user_id, Permission::Update) .await?; // Delete the contact - self.contact_repository.delete_contact(&id).await?; + self.contact_storage.delete_contact(&id).await?; Ok(()) } @@ -768,13 +689,13 @@ impl ContactUseCase for ContactService { // Get the contact let contact = self - .contact_repository + .contact_storage .get_contact_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Contact", "not found"))?; // Check if user has access to the address book - self.check_address_book_access(contact.address_book_id(), &user_id) + self.require_address_book_read_or_public(contact.address_book_id(), &user_id) .await?; Ok(ContactDto::from(contact)) @@ -790,9 +711,10 @@ impl ContactUseCase for ContactService { .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; // Check if user has access to the address book - self.check_address_book_access(&id, &user_id).await?; + self.require_address_book_read_or_public(&id, &user_id) + .await?; - let contact = self.contact_repository.get_contact_by_uid(&id, uid).await?; + let contact = self.contact_storage.get_contact_by_uid(&id, uid).await?; Ok(contact.map(ContactDto::from)) } @@ -806,16 +728,14 @@ impl ContactUseCase for ContactService { .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; // Check if user has access to the address book - self.check_address_book_access(&id, &user_id).await?; + self.require_address_book_read_or_public(&id, &user_id) + .await?; if uids.is_empty() { return Ok(Vec::new()); } - let contacts = self - .contact_repository - .get_contacts_by_uids(&id, uids) - .await?; + let contacts = self.contact_storage.get_contacts_by_uids(&id, uids).await?; Ok(contacts.into_iter().map(ContactDto::from).collect()) } @@ -830,17 +750,18 @@ impl ContactUseCase for ContactService { .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; // Check if user has access to the address book - self.check_address_book_access(&id, &user_id).await?; + self.require_address_book_read_or_public(&id, &user_id) + .await?; // Get contacts let contacts = if limit.is_some() || offset.is_some() { let limit = limit.unwrap_or(100); let offset = offset.unwrap_or(0); - self.contact_repository + self.contact_storage .get_contacts_by_address_book_paginated(&id, limit, offset) .await? } else { - self.contact_repository + self.contact_storage .get_contacts_by_address_book(&id) .await? }; @@ -859,10 +780,11 @@ impl ContactUseCase for ContactService { .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; // Check if user has access to the address book - self.check_address_book_access(&id, &user_id).await?; + self.require_address_book_read_or_public(&id, &user_id) + .await?; // Search contacts - let contacts = self.contact_repository.search_contacts(&id, query).await?; + let contacts = self.contact_storage.search_contacts(&id, query).await?; let dtos = contacts.into_iter().map(ContactDto::from).collect(); Ok(dtos) @@ -876,16 +798,14 @@ impl ContactUseCase for ContactService { .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; // Check if user has write access to the address book - self.check_address_book_write_access( - &address_book_id, - &Uuid::parse_str(&dto.user_id) - .map_err(|_| DomainError::validation_error("Invalid user ID format"))?, - ) - .await?; + let caller_id = Uuid::parse_str(&dto.user_id) + .map_err(|_| DomainError::validation_error("Invalid user ID format"))?; + self.require_address_book_perm(&address_book_id, &caller_id, Permission::Update) + .await?; let group = ContactGroup::new(address_book_id, dto.name); - let created_group = self.contact_group_repository.create_group(group).await?; + let created_group = self.contact_storage.create_group(group).await?; Ok(ContactGroupDto::from(created_group)) } @@ -899,18 +819,16 @@ impl ContactUseCase for ContactService { // Get the current group let group = self - .contact_group_repository + .contact_storage .get_group_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Contact group", "not found"))?; // Check if user has write access to the address book - self.check_address_book_write_access( - group.address_book_id(), - &Uuid::parse_str(&update.user_id) - .map_err(|_| DomainError::validation_error("Invalid user ID format"))?, - ) - .await?; + let caller_id = Uuid::parse_str(&update.user_id) + .map_err(|_| DomainError::validation_error("Invalid user ID format"))?; + self.require_address_book_perm(group.address_book_id(), &caller_id, Permission::Update) + .await?; // Update the group let updated_group = ContactGroup::from_raw( @@ -921,10 +839,7 @@ impl ContactUseCase for ContactService { Utc::now(), ); - let result = self - .contact_group_repository - .update_group(updated_group) - .await?; + let result = self.contact_storage.update_group(updated_group).await?; Ok(ContactGroupDto::from(result)) } @@ -934,17 +849,17 @@ impl ContactUseCase for ContactService { // Get the current group let group = self - .contact_group_repository + .contact_storage .get_group_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Contact group", "not found"))?; // Check if user has write access to the address book - self.check_address_book_write_access(group.address_book_id(), &user_id) + self.require_address_book_perm(group.address_book_id(), &user_id, Permission::Update) .await?; // Delete the group - self.contact_group_repository.delete_group(&id).await?; + self.contact_storage.delete_group(&id).await?; Ok(()) } @@ -958,20 +873,17 @@ impl ContactUseCase for ContactService { // Get the group let group = self - .contact_group_repository + .contact_storage .get_group_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Contact group", "not found"))?; // Check if user has access to the address book - self.check_address_book_access(group.address_book_id(), &user_id) + self.require_address_book_read_or_public(group.address_book_id(), &user_id) .await?; // Get the number of contacts in the group - let contacts = self - .contact_group_repository - .get_contacts_in_group(&id) - .await?; + let contacts = self.contact_storage.get_contacts_in_group(&id).await?; let mut dto = ContactGroupDto::from(group); dto.members_count = Some(contacts.len() as i32); @@ -988,13 +900,11 @@ impl ContactUseCase for ContactService { .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; // Check if user has access to the address book - self.check_address_book_access(&id, &user_id).await?; + self.require_address_book_read_or_public(&id, &user_id) + .await?; // Get groups - let groups = self - .contact_group_repository - .get_groups_by_address_book(&id) - .await?; + let groups = self.contact_storage.get_groups_by_address_book(&id).await?; let dtos = groups.into_iter().map(ContactGroupDto::from).collect(); Ok(dtos) @@ -1013,17 +923,17 @@ impl ContactUseCase for ContactService { // Get the group let group = self - .contact_group_repository + .contact_storage .get_group_by_id(&group_id) .await? .ok_or_else(|| DomainError::not_found("Contact group", "not found"))?; // Check if user has write access to the address book - self.check_address_book_write_access(group.address_book_id(), &user_id) + self.require_address_book_perm(group.address_book_id(), &user_id, Permission::Update) .await?; // Add contact to group - self.contact_group_repository + self.contact_storage .add_contact_to_group(&group_id, &contact_id) .await?; Ok(()) @@ -1042,17 +952,17 @@ impl ContactUseCase for ContactService { // Get the group let group = self - .contact_group_repository + .contact_storage .get_group_by_id(&group_id) .await? .ok_or_else(|| DomainError::not_found("Contact group", "not found"))?; // Check if user has write access to the address book - self.check_address_book_write_access(group.address_book_id(), &user_id) + self.require_address_book_perm(group.address_book_id(), &user_id, Permission::Update) .await?; // Remove contact from group - self.contact_group_repository + self.contact_storage .remove_contact_from_group(&group_id, &contact_id) .await?; Ok(()) @@ -1068,20 +978,17 @@ impl ContactUseCase for ContactService { // Get the group let group = self - .contact_group_repository + .contact_storage .get_group_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Contact group", "not found"))?; // Check if user has access to the address book - self.check_address_book_access(group.address_book_id(), &user_id) + self.require_address_book_read_or_public(group.address_book_id(), &user_id) .await?; // Get contacts in group - let contacts = self - .contact_group_repository - .get_contacts_in_group(&id) - .await?; + let contacts = self.contact_storage.get_contacts_in_group(&id).await?; let dtos = contacts.into_iter().map(ContactDto::from).collect(); Ok(dtos) @@ -1097,20 +1004,17 @@ impl ContactUseCase for ContactService { // Get the contact let contact = self - .contact_repository + .contact_storage .get_contact_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Contact", "not found"))?; // Check if user has access to the address book - self.check_address_book_access(contact.address_book_id(), &user_id) + self.require_address_book_read_or_public(contact.address_book_id(), &user_id) .await?; // Get groups for contact - let groups = self - .contact_group_repository - .get_groups_for_contact(&id) - .await?; + let groups = self.contact_storage.get_groups_for_contact(&id).await?; let dtos = groups.into_iter().map(ContactGroupDto::from).collect(); Ok(dtos) @@ -1126,13 +1030,13 @@ impl ContactUseCase for ContactService { // Get the contact let contact = self - .contact_repository + .contact_storage .get_contact_by_id(&id) .await? .ok_or_else(|| DomainError::not_found("Contact", "not found"))?; // Check if user has access to the address book - self.check_address_book_access(contact.address_book_id(), &user_id) + self.require_address_book_read_or_public(contact.address_book_id(), &user_id) .await?; // Return the vCard data @@ -1148,11 +1052,12 @@ impl ContactUseCase for ContactService { .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; // Check if user has access to the address book - self.check_address_book_access(&id, &user_id).await?; + self.require_address_book_read_or_public(&id, &user_id) + .await?; // Get all contacts in the address book let contacts = self - .contact_repository + .contact_storage .get_contacts_by_address_book(&id) .await?; @@ -1165,385 +1070,3 @@ impl ContactUseCase for ContactService { Ok(vcards) } } - -impl StorageUseCase for ContactService { - async fn handle_request( - &self, - action: &str, - params: serde_json::Value, - ) -> Result { - match action { - // Address Book operations - "create_address_book" => { - let dto: CreateAddressBookDto = - serde_json::from_value(params.clone()).map_err(|e| { - DomainError::validation_error(format!("Invalid parameters: {}", e)) - })?; - - let result = self.create_address_book(dto).await?; - Ok(serde_json::to_value(result).unwrap()) - } - "update_address_book" => { - let address_book_id = params["address_book_id"].as_str().ok_or_else(|| { - DomainError::validation_error("Missing address_book_id parameter") - })?; - - let update: UpdateAddressBookDto = - serde_json::from_value(params.clone()).map_err(|e| { - DomainError::validation_error(format!("Invalid parameters: {}", e)) - })?; - - let result = self.update_address_book(address_book_id, update).await?; - Ok(serde_json::to_value(result).unwrap()) - } - "delete_address_book" => { - let address_book_id = params["address_book_id"].as_str().ok_or_else(|| { - DomainError::validation_error("Missing address_book_id parameter") - })?; - - let user_id = params["user_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - let user_id = Uuid::parse_str(user_id) - .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - - self.delete_address_book(address_book_id, user_id).await?; - Ok(serde_json::Value::Null) - } - "get_address_book" => { - let address_book_id = params["address_book_id"].as_str().ok_or_else(|| { - DomainError::validation_error("Missing address_book_id parameter") - })?; - - let user_id = params["user_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - let user_id = Uuid::parse_str(user_id) - .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - - let result = self.get_address_book(address_book_id, user_id).await?; - Ok(serde_json::to_value(result).unwrap()) - } - "list_user_address_books" => { - let user_id = params["user_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - let user_id = Uuid::parse_str(user_id) - .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - - let result = self.list_user_address_books(user_id).await?; - Ok(serde_json::to_value(result).unwrap()) - } - "list_public_address_books" => { - let result = self.list_public_address_books().await?; - Ok(serde_json::to_value(result).unwrap()) - } - "share_address_book" => { - let dto: ShareAddressBookDto = - serde_json::from_value(params.clone()).map_err(|e| { - DomainError::validation_error(format!("Invalid parameters: {}", e)) - })?; - - let user_id = params["user_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - let user_id = Uuid::parse_str(user_id) - .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - - self.share_address_book(dto, user_id).await?; - Ok(serde_json::Value::Null) - } - "unshare_address_book" => { - let dto: UnshareAddressBookDto = - serde_json::from_value(params.clone()).map_err(|e| { - DomainError::validation_error(format!("Invalid parameters: {}", e)) - })?; - - let user_id = params["user_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - let user_id = Uuid::parse_str(user_id) - .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - - self.unshare_address_book(dto, user_id).await?; - Ok(serde_json::Value::Null) - } - "get_address_book_shares" => { - let address_book_id = params["address_book_id"].as_str().ok_or_else(|| { - DomainError::validation_error("Missing address_book_id parameter") - })?; - - let user_id = params["user_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - let user_id = Uuid::parse_str(user_id) - .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - - let result = self - .get_address_book_shares(address_book_id, user_id) - .await?; - Ok(serde_json::to_value(result).unwrap()) - } - - // Contact operations - "create_contact" => { - let dto: CreateContactDto = - serde_json::from_value(params.clone()).map_err(|e| { - DomainError::validation_error(format!("Invalid parameters: {}", e)) - })?; - - let result = self.create_contact(dto).await?; - Ok(serde_json::to_value(result).unwrap()) - } - "create_contact_from_vcard" => { - let dto: CreateContactVCardDto = - serde_json::from_value(params.clone()).map_err(|e| { - DomainError::validation_error(format!("Invalid parameters: {}", e)) - })?; - - let result = self.create_contact_from_vcard(dto).await?; - Ok(serde_json::to_value(result).unwrap()) - } - "update_contact" => { - let contact_id = params["contact_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing contact_id parameter"))?; - - let update: UpdateContactDto = - serde_json::from_value(params.clone()).map_err(|e| { - DomainError::validation_error(format!("Invalid parameters: {}", e)) - })?; - - let result = self.update_contact(contact_id, update).await?; - Ok(serde_json::to_value(result).unwrap()) - } - "delete_contact" => { - let contact_id = params["contact_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing contact_id parameter"))?; - - let user_id = params["user_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - let user_id = Uuid::parse_str(user_id) - .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - - self.delete_contact(contact_id, user_id).await?; - Ok(serde_json::Value::Null) - } - "get_contact" => { - let contact_id = params["contact_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing contact_id parameter"))?; - - let user_id = params["user_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - let user_id = Uuid::parse_str(user_id) - .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - - let result = self.get_contact(contact_id, user_id).await?; - Ok(serde_json::to_value(result).unwrap()) - } - "list_contacts" => { - let address_book_id = params["address_book_id"].as_str().ok_or_else(|| { - DomainError::validation_error("Missing address_book_id parameter") - })?; - - let user_id = params["user_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - let user_id = Uuid::parse_str(user_id) - .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - - let result = self - .list_contacts(address_book_id, None, None, user_id) - .await?; - Ok(serde_json::to_value(result).unwrap()) - } - "search_contacts" => { - let address_book_id = params["address_book_id"].as_str().ok_or_else(|| { - DomainError::validation_error("Missing address_book_id parameter") - })?; - - let query = params["query"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing query parameter"))?; - - let user_id = params["user_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - let user_id = Uuid::parse_str(user_id) - .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - - let result = self - .search_contacts(address_book_id, query, user_id) - .await?; - Ok(serde_json::to_value(result).unwrap()) - } - - // Group operations - "create_group" => { - let dto: CreateContactGroupDto = - serde_json::from_value(params.clone()).map_err(|e| { - DomainError::validation_error(format!("Invalid parameters: {}", e)) - })?; - - let result = self.create_group(dto).await?; - Ok(serde_json::to_value(result).unwrap()) - } - "update_group" => { - let group_id = params["group_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing group_id parameter"))?; - - let update: UpdateContactGroupDto = serde_json::from_value(params.clone()) - .map_err(|e| { - DomainError::validation_error(format!("Invalid parameters: {}", e)) - })?; - - let result = self.update_group(group_id, update).await?; - Ok(serde_json::to_value(result).unwrap()) - } - "delete_group" => { - let group_id = params["group_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing group_id parameter"))?; - - let user_id = params["user_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - let user_id = Uuid::parse_str(user_id) - .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - - self.delete_group(group_id, user_id).await?; - Ok(serde_json::Value::Null) - } - "get_group" => { - let group_id = params["group_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing group_id parameter"))?; - - let user_id = params["user_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - let user_id = Uuid::parse_str(user_id) - .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - - let result = self.get_group(group_id, user_id).await?; - Ok(serde_json::to_value(result).unwrap()) - } - "list_groups" => { - let address_book_id = params["address_book_id"].as_str().ok_or_else(|| { - DomainError::validation_error("Missing address_book_id parameter") - })?; - - let user_id = params["user_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - let user_id = Uuid::parse_str(user_id) - .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - - let result = self.list_groups(address_book_id, user_id).await?; - Ok(serde_json::to_value(result).unwrap()) - } - - // Group membership operations - "add_contact_to_group" => { - let dto: GroupMembershipDto = - serde_json::from_value(params.clone()).map_err(|e| { - DomainError::validation_error(format!("Invalid parameters: {}", e)) - })?; - - let user_id = params["user_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - let user_id = Uuid::parse_str(user_id) - .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - - self.add_contact_to_group(dto, user_id).await?; - Ok(serde_json::Value::Null) - } - "remove_contact_from_group" => { - let dto: GroupMembershipDto = - serde_json::from_value(params.clone()).map_err(|e| { - DomainError::validation_error(format!("Invalid parameters: {}", e)) - })?; - - let user_id = params["user_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - let user_id = Uuid::parse_str(user_id) - .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - - self.remove_contact_from_group(dto, user_id).await?; - Ok(serde_json::Value::Null) - } - "list_contacts_in_group" => { - let group_id = params["group_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing group_id parameter"))?; - - let user_id = params["user_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - let user_id = Uuid::parse_str(user_id) - .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - - let result = self.list_contacts_in_group(group_id, user_id).await?; - Ok(serde_json::to_value(result).unwrap()) - } - "list_groups_for_contact" => { - let contact_id = params["contact_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing contact_id parameter"))?; - - let user_id = params["user_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - let user_id = Uuid::parse_str(user_id) - .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - - let result = self.list_groups_for_contact(contact_id, user_id).await?; - Ok(serde_json::to_value(result).unwrap()) - } - - // vCard operations - "get_contact_vcard" => { - let contact_id = params["contact_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing contact_id parameter"))?; - - let user_id = params["user_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - let user_id = Uuid::parse_str(user_id) - .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - - let result = self.get_contact_vcard(contact_id, user_id).await?; - Ok(serde_json::to_value(result).unwrap()) - } - "get_contacts_as_vcards" => { - let address_book_id = params["address_book_id"].as_str().ok_or_else(|| { - DomainError::validation_error("Missing address_book_id parameter") - })?; - - let user_id = params["user_id"] - .as_str() - .ok_or_else(|| DomainError::validation_error("Missing user_id parameter"))?; - let user_id = Uuid::parse_str(user_id) - .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - - let result = self - .get_contacts_as_vcards(address_book_id, user_id) - .await?; - Ok(serde_json::to_value(result).unwrap()) - } - - _ => Err(DomainError::validation_error(format!( - "Unknown action: {}", - action - ))), - } - } -} diff --git a/src/application/services/magic_link_invite_service.rs b/src/application/services/magic_link_invite_service.rs index bc244e09..bc858863 100644 --- a/src/application/services/magic_link_invite_service.rs +++ b/src/application/services/magic_link_invite_service.rs @@ -307,20 +307,25 @@ impl MagicLinkInviteService { let (kind, resource_id) = match resource { Resource::Folder(id) => (MagicLinkResourceKind::Folder, id), Resource::File(id) => (MagicLinkResourceKind::File, id), - // Drive sharing — and therefore drive magic-link invitations — - // land in D2. The grant DTOs accept `Resource::Drive` from the - // wire today (see ResourceTypeDto) but no public API path - // actually grants on a drive in D0, so this arm is - // defensively unreachable. Treating it as an audit-logged - // no-op (grant is in place, mail suppressed) matches the - // ineligible-recipient branch above. - Resource::Drive(_) => { + // Drive / Calendar / AddressBook sharing is out-of-band for + // the magic-link flow. Drive shares land through + // `/api/drives/{id}/members`; Calendar / AddressBook shares + // through the Round-3 `/api/(calendars|address-books)/{id}/shares` + // endpoints. The DTOs accept every `Resource` variant on + // the wire (see `ResourceTypeDto`) but only file/folder + // grants trigger an invitation email. Treating the other + // arms as audit-logged suppressed no-ops keeps the grant + // in place while matching the ineligible-recipient branch + // above. + Resource::Drive(_) | Resource::Calendar(_) | Resource::AddressBook(_) => { tracing::info!( target: "audit", event = "magic_link.invitation_suppressed", - reason = "drive_resource_unsupported", + reason = "resource_kind_unsupported", user_id = %recipient.id(), - "📭 magic-link invitation suppressed: drive resources aren't invitable until D2", + resource_kind = %resource.type_str(), + "📭 magic-link invitation suppressed: {} resources aren't invitable via email", + resource.type_str(), ); return Ok(()); } @@ -347,10 +352,12 @@ impl MagicLinkInviteService { Resource::Folder(_) => "server.magic_link.email.kind_folder", Resource::File(_) => "server.magic_link.email.kind_file", // Unreachable — the early-return above exits before we get - // here for a Drive resource. The arm exists only to satisfy - // exhaustiveness; if you find this firing, the early-return - // was bypassed. - Resource::Drive(_) => "server.magic_link.email.kind_folder", + // here for Drive / Calendar / AddressBook resources. The + // arms exist only to satisfy exhaustiveness; if you find + // any firing, the early-return was bypassed. + Resource::Drive(_) | Resource::Calendar(_) | Resource::AddressBook(_) => { + "server.magic_link.email.kind_folder" + } }; // PR C: render in the recipient's preferred locale (set by UI // switcher, OIDC JIT claim, or inviter inheritance at row diff --git a/src/application/services/recipient_notification_service.rs b/src/application/services/recipient_notification_service.rs index b334c8dc..ab4880ee 100644 --- a/src/application/services/recipient_notification_service.rs +++ b/src/application/services/recipient_notification_service.rs @@ -472,11 +472,13 @@ impl RecipientNotificationService { let kind_key = match resource { Resource::Folder(_) => "server.magic_link.email.kind_folder", Resource::File(_) => "server.magic_link.email.kind_file", - // Drives don't generate share notifications in D0 — drive - // sharing lands in D2 and gets its own template key. Fall - // back to the folder label so any path that does reach - // here produces a readable, if generic, mail body. - Resource::Drive(_) => "server.magic_link.email.kind_folder", + // Drive / Calendar / AddressBook shares don't produce + // email notifications through this path. Fall back to the + // folder label so any code that does reach here still + // produces a readable (if generic) mail body. + Resource::Drive(_) | Resource::Calendar(_) | Resource::AddressBook(_) => { + "server.magic_link.email.kind_folder" + } }; let kind_label = self.i18n_or(kind_key, &locale, &[]).await; // Short form for the subject, long form (with email) for the diff --git a/src/common/di.rs b/src/common/di.rs index b3a777e6..b6dfa469 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -50,13 +50,13 @@ use crate::application::ports::video_frame_ports::VideoFramePort; use crate::application::services::app_password_service::AppPasswordService; use crate::application::services::blob_lifecycle_service::BlobLifecycleService; use crate::application::services::calendar_service::CalendarService; +use crate::application::services::contact_service::ContactService; use crate::application::services::device_auth_service::DeviceAuthService; use crate::application::services::file_lifecycle_service::FileLifecycleService; use crate::application::services::music_service::MusicService; use crate::application::services::storage_usage_service::StorageUsageService; use crate::application::services::wopi_lock_service::WopiLockService; use crate::application::services::wopi_token_service::WopiTokenService; -use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter; use crate::infrastructure::repositories::AppPasswordPgRepository; use crate::infrastructure::repositories::DeviceCodePgRepository; use crate::infrastructure::repositories::pg::{ @@ -1558,7 +1558,6 @@ impl AppServiceFactory { people_service, storage_usage_service, calendar_service: None, - contact_service: None, calendar_use_case: None, addressbook_use_case: None, contact_use_case: None, @@ -1829,6 +1828,7 @@ impl AppServiceFactory { let calendar_service = Arc::new( crate::application::services::calendar_service::CalendarService::new( calendar_storage, + authorization.clone(), ), ); app_state.calendar_use_case = Some(calendar_service as Arc); @@ -1845,15 +1845,23 @@ impl AppServiceFactory { pool.clone(), ), ); + // Post-Round-3: symmetric with CalendarService/CalendarStorageAdapter. + // * ContactStorageAdapter → pure ContactStoragePort impl + // (raw PG storage, no ACL, no sharing). + // * ContactService → gates every call through the + // AuthorizationEngine, then delegates through the port. + // Owns both AddressBookUseCase + ContactUseCase impls. let contact_storage = Arc::new( crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter::new( address_book_repo, contact_repo, group_repo, - ) + ), ); - app_state.addressbook_use_case = Some(contact_storage.clone()); - app_state.contact_use_case = Some(contact_storage); + let contact_service = + Arc::new(ContactService::new(contact_storage, authorization.clone())); + app_state.addressbook_use_case = Some(contact_service.clone()); + app_state.contact_use_case = Some(contact_service); tracing::info!("CalDAV and CardDAV services initialized with PostgreSQL repositories"); } @@ -2022,10 +2030,9 @@ pub struct AppState { pub people_service: Option>, pub storage_usage_service: Option>, pub calendar_service: Option>, - pub contact_service: Option>, pub calendar_use_case: Option>, - pub addressbook_use_case: Option>, - pub contact_use_case: Option>, + pub addressbook_use_case: Option>, + pub contact_use_case: Option>, pub music_service: Option>, pub wopi_token_service: Option>, diff --git a/src/domain/repositories/address_book_repository.rs b/src/domain/repositories/address_book_repository.rs index 7adbf6d2..6e86c9cb 100644 --- a/src/domain/repositories/address_book_repository.rs +++ b/src/domain/repositories/address_book_repository.rs @@ -6,6 +6,14 @@ use crate::domain::entities::contact::AddressBook; pub type AddressBookRepositoryResult = Result; +/// Repository interface for AddressBook entity operations. +/// +/// Post-Round-3, access-control state lives in `storage.role_grants`. +/// The pre-Round-3 methods that read/wrote `carddav.address_book_shares` +/// (`get_shared_address_books`, `share_address_book`, +/// `unshare_address_book`, `get_address_book_shares`) have been removed +/// from this trait, and the backing table was dropped in +/// `20260906000002_drop_legacy_share_tables.sql`. pub trait AddressBookRepository: Send + Sync + 'static { async fn create_address_book( &self, @@ -20,28 +28,13 @@ pub trait AddressBookRepository: Send + Sync + 'static { &self, id: &Uuid, ) -> AddressBookRepositoryResult>; + /// Direct owner enumeration — same semantics as the calendar + /// counterpart. The service layer prefers + /// `authz.list_incoming_grants`, but internal maintenance paths + /// keep the owner-only lookup available. async fn get_address_books_by_owner( &self, owner_id: Uuid, ) -> AddressBookRepositoryResult>; - async fn get_shared_address_books( - &self, - user_id: Uuid, - ) -> AddressBookRepositoryResult>; async fn get_public_address_books(&self) -> AddressBookRepositoryResult>; - async fn share_address_book( - &self, - address_book_id: &Uuid, - user_id: Uuid, - can_write: bool, - ) -> AddressBookRepositoryResult<()>; - async fn unshare_address_book( - &self, - address_book_id: &Uuid, - user_id: Uuid, - ) -> AddressBookRepositoryResult<()>; - async fn get_address_book_shares( - &self, - address_book_id: &Uuid, - ) -> AddressBookRepositoryResult>; } diff --git a/src/domain/repositories/calendar_repository.rs b/src/domain/repositories/calendar_repository.rs index 2ba3ead8..8719fbdb 100644 --- a/src/domain/repositories/calendar_repository.rs +++ b/src/domain/repositories/calendar_repository.rs @@ -4,7 +4,14 @@ use uuid::Uuid; pub type CalendarRepositoryResult = Result; -/// Repository interface for Calendar entity operations +/// Repository interface for Calendar entity operations. +/// +/// Post-Round-3, access-control state lives in `storage.role_grants` — +/// the pre-Round-3 methods that read/wrote `caldav.calendar_shares` +/// (`list_calendars_shared_with_user`, `user_has_calendar_access`, +/// `share_calendar`, `remove_calendar_sharing`, `get_calendar_shares`) +/// have been removed from this trait, and the backing table was dropped +/// in `20260906000002_drop_legacy_share_tables.sql`. pub trait CalendarRepository: Send + Sync + 'static { /// Creates a new calendar async fn create_calendar(&self, calendar: Calendar) -> CalendarRepositoryResult; @@ -18,7 +25,11 @@ pub trait CalendarRepository: Send + Sync + 'static { /// Finds a calendar by its ID async fn find_calendar_by_id(&self, id: &Uuid) -> CalendarRepositoryResult; - /// Lists all calendars for a specific user + /// Lists all calendars owned by a specific user. Post-Round-3 the + /// service layer prefers `authz.list_incoming_grants` (surfaces + /// owned + shared in one union), but this direct lookup remains + /// available for internal maintenance / migration paths that need + /// owner-only enumeration without going through the engine. async fn list_calendars_by_owner( &self, owner_id: Uuid, @@ -31,12 +42,6 @@ pub trait CalendarRepository: Send + Sync + 'static { owner_id: Uuid, ) -> CalendarRepositoryResult; - /// Lists calendars shared with a specific user - async fn list_calendars_shared_with_user( - &self, - user_id: Uuid, - ) -> CalendarRepositoryResult>; - /// List public calendars async fn list_public_calendars( &self, @@ -44,13 +49,6 @@ pub trait CalendarRepository: Send + Sync + 'static { offset: i64, ) -> CalendarRepositoryResult>; - /// Checks if a user has access to a calendar - async fn user_has_calendar_access( - &self, - calendar_id: &Uuid, - user_id: Uuid, - ) -> CalendarRepositoryResult; - /// Gets a custom property for a calendar async fn get_calendar_property( &self, @@ -78,25 +76,4 @@ pub trait CalendarRepository: Send + Sync + 'static { &self, calendar_id: &Uuid, ) -> CalendarRepositoryResult>; - - /// Share calendar with another user - async fn share_calendar( - &self, - calendar_id: &Uuid, - user_id: Uuid, - access_level: &str, - ) -> CalendarRepositoryResult<()>; - - /// Remove calendar sharing for a user - async fn remove_calendar_sharing( - &self, - calendar_id: &Uuid, - user_id: Uuid, - ) -> CalendarRepositoryResult<()>; - - /// Get calendar sharing information (who has access to this calendar) - async fn get_calendar_shares( - &self, - calendar_id: &Uuid, - ) -> CalendarRepositoryResult>; } diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index 0e9a6a3d..66ca10da 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -78,11 +78,20 @@ pub enum Resource { /// membership and policy bag. Added in D0; membership lives in /// `storage.role_grants` (no separate `drive_members` table). Drive(Uuid), - // Reserved for future use: - // Calendar(Uuid), - // Reserved for future use: - // AddressBook(Uuid), - // Reserved for future use: + /// A CalDAV calendar. Membership + sharing lives in + /// `storage.role_grants` with `resource_type='calendar'` — + /// replaces the pre-Round-3 dedicated `caldav.calendar_shares` + /// table and the `check_calendar_access` bespoke helper. No + /// cascade parent (calendars are top-level per user); the engine + /// resolves directly against `role_grants` on the resource. + Calendar(Uuid), + /// A CardDAV address book. Same shape as `Calendar` — + /// `storage.role_grants` with `resource_type='address_book'` + /// replaces `carddav.address_book_shares` and the + /// `check_address_book_access` bespoke helper. + AddressBook(Uuid), + // Reserved for future use — same shape but tracked separately + // (music-service rewrite is its own PR): // Playlist(Uuid), } @@ -92,17 +101,19 @@ impl Resource { Resource::Folder(_) => "folder", Resource::File(_) => "file", Resource::Drive(_) => "drive", - //Resource::Calendar(_) => "calendar", - //Resource::AddressBook(_) => "adressbook", + Resource::Calendar(_) => "calendar", + Resource::AddressBook(_) => "address_book", //Resource::Playlist(_) => "playlist", } } pub fn id(&self) -> Uuid { match self { - Resource::Folder(id) | Resource::File(id) | Resource::Drive(id) => *id, - //| Resource::Calendar(id) - //| Resource::AddressBook(id) + Resource::Folder(id) + | Resource::File(id) + | Resource::Drive(id) + | Resource::Calendar(id) + | Resource::AddressBook(id) => *id, //| Resource::Playlist(id) } } @@ -112,8 +123,8 @@ impl Resource { "folder" => Some(Resource::Folder(id)), "file" => Some(Resource::File(id)), "drive" => Some(Resource::Drive(id)), - //"calendar" => Some(Resource::Calendar(id)), - //"adressbook" => Some(Resource::AddressBook(id)), + "calendar" => Some(Resource::Calendar(id)), + "address_book" => Some(Resource::AddressBook(id)), //"playlist" => Some(Resource::Playlist(id)), _ => None, } @@ -536,11 +547,17 @@ mod tests { #[test] fn resource_roundtrip() { let id = Uuid::new_v4(); - for r in [Resource::Folder(id), Resource::File(id)] { + for r in [ + Resource::Folder(id), + Resource::File(id), + Resource::Calendar(id), + Resource::AddressBook(id), + ] { let back = Resource::from_parts(r.type_str(), r.id()).unwrap(); assert_eq!(r, back); } - assert!(Resource::from_parts("calendar", id).is_none()); + // `playlist` is still pending the Music AuthZ migration. + assert!(Resource::from_parts("playlist", id).is_none()); } #[test] diff --git a/src/infrastructure/adapters/calendar_storage_adapter.rs b/src/infrastructure/adapters/calendar_storage_adapter.rs index 346ba487..c8a45941 100644 --- a/src/infrastructure/adapters/calendar_storage_adapter.rs +++ b/src/infrastructure/adapters/calendar_storage_adapter.rs @@ -126,17 +126,6 @@ impl CalendarStoragePort for CalendarStorageAdapter { Ok(calendars.into_iter().map(CalendarDto::from).collect()) } - async fn list_calendars_shared_with_user( - &self, - user_id: Uuid, - ) -> Result, DomainError> { - let calendars = self - .calendar_repository - .list_calendars_shared_with_user(user_id) - .await?; - Ok(calendars.into_iter().map(CalendarDto::from).collect()) - } - async fn list_public_calendars( &self, limit: i64, @@ -149,78 +138,6 @@ impl CalendarStoragePort for CalendarStorageAdapter { Ok(calendars.into_iter().map(CalendarDto::from).collect()) } - async fn check_calendar_access( - &self, - calendar_id: &str, - user_id: Uuid, - ) -> Result { - let uuid = Uuid::parse_str(calendar_id).map_err(|_| { - DomainError::new( - ErrorKind::InvalidInput, - "Calendar", - "Invalid calendar ID format", - ) - })?; - - self.calendar_repository - .user_has_calendar_access(&uuid, user_id) - .await - } - - // Calendar sharing - - async fn share_calendar( - &self, - calendar_id: &str, - user_id: Uuid, - access_level: &str, - ) -> Result<(), DomainError> { - let uuid = Uuid::parse_str(calendar_id).map_err(|_| { - DomainError::new( - ErrorKind::InvalidInput, - "Calendar", - "Invalid calendar ID format", - ) - })?; - - self.calendar_repository - .share_calendar(&uuid, user_id, access_level) - .await - } - - async fn remove_calendar_sharing( - &self, - calendar_id: &str, - user_id: Uuid, - ) -> Result<(), DomainError> { - let uuid = Uuid::parse_str(calendar_id).map_err(|_| { - DomainError::new( - ErrorKind::InvalidInput, - "Calendar", - "Invalid calendar ID format", - ) - })?; - - self.calendar_repository - .remove_calendar_sharing(&uuid, user_id) - .await - } - - async fn get_calendar_shares( - &self, - calendar_id: &str, - ) -> Result, DomainError> { - let uuid = Uuid::parse_str(calendar_id).map_err(|_| { - DomainError::new( - ErrorKind::InvalidInput, - "Calendar", - "Invalid calendar ID format", - ) - })?; - - self.calendar_repository.get_calendar_shares(&uuid).await - } - // Calendar properties async fn set_calendar_property( diff --git a/src/infrastructure/adapters/contact_storage_adapter.rs b/src/infrastructure/adapters/contact_storage_adapter.rs index 1beece32..5617bb6d 100644 --- a/src/infrastructure/adapters/contact_storage_adapter.rs +++ b/src/infrastructure/adapters/contact_storage_adapter.rs @@ -1,1045 +1,224 @@ //! Contact Storage Adapter //! -//! This adapter implements the `AddressBookUseCase` and `ContactUseCase` application ports -//! using the domain repositories. It bridges the gap between the application layer -//! and the infrastructure layer for CardDAV functionality. +//! Implements [`ContactStoragePort`] using the three PostgreSQL +//! repositories (`AddressBookPgRepository`, `ContactPgRepository`, +//! `ContactGroupPgRepository`). +//! +//! **Pure storage port.** No access-control logic, no sharing state, +//! no owner-vs-shared listing carve-outs — every method is plain +//! delegation to a repository. Access decisions live in +//! `AuthorizationEngine`; sharing state lives in +//! `storage.role_grants`. The service layer (`ContactService`) gates +//! each call before reaching through this port. +//! +//! Symmetric with `CalendarStorageAdapter`. Post-Round-3 the +//! pre-existing 1000-line adapter that mixed the use-case impls + +//! bespoke `check_address_book_access` was deleted; this file +//! recreates a much smaller storage-only version. use std::sync::Arc; use uuid::Uuid; -use crate::application::dtos::address_book_dto::{ - AddressBookDto, CreateAddressBookDto, ShareAddressBookDto, UnshareAddressBookDto, - UpdateAddressBookDto, -}; -use crate::application::dtos::contact_dto::{ - AddressDto, ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, - CreateContactVCardDto, EmailDto, GroupMembershipDto, PhoneDto, UpdateContactDto, - UpdateContactGroupDto, -}; -use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase}; -use crate::common::errors::{DomainError, ErrorKind}; -use crate::domain::entities::contact::{Address, AddressBook, Contact, ContactGroup, Email, Phone}; +use crate::application::ports::carddav_ports::ContactStoragePort; +use crate::common::errors::DomainError; +use crate::domain::entities::contact::{AddressBook, Contact, ContactGroup}; use crate::domain::repositories::address_book_repository::AddressBookRepository; use crate::domain::repositories::contact_repository::{ContactGroupRepository, ContactRepository}; -use crate::infrastructure::repositories::pg::AddressBookPgRepository; -use crate::infrastructure::repositories::pg::ContactGroupPgRepository; -use crate::infrastructure::repositories::pg::ContactPgRepository; +use crate::infrastructure::repositories::pg::{ + AddressBookPgRepository, ContactGroupPgRepository, ContactPgRepository, +}; -/// Adapter that implements AddressBookUseCase and ContactUseCase using domain repositories +/// Storage-port adapter bundling the three CardDAV PG repositories. +/// +/// Wired in DI once; passed to `ContactService` which layers authz +/// on top and exposes the `AddressBookUseCase` / `ContactUseCase` +/// trait impls the HTTP handlers consume. pub struct ContactStorageAdapter { address_book_repository: Arc, contact_repository: Arc, - group_repository: Arc, + contact_group_repository: Arc, } impl ContactStorageAdapter { - /// Creates a new ContactStorageAdapter with the given repositories pub fn new( address_book_repository: Arc, contact_repository: Arc, - group_repository: Arc, + contact_group_repository: Arc, ) -> Self { Self { address_book_repository, contact_repository, - group_repository, + contact_group_repository, } } - - /// Helper to parse UUID from string - fn parse_uuid(id: &str, entity_name: &'static str) -> Result { - Uuid::parse_str(id).map_err(|_| { - DomainError::new( - ErrorKind::InvalidInput, - entity_name, - format!("Invalid {} ID format", entity_name), - ) - }) - } - - /// Helper to check if user has access to an address book - async fn check_address_book_access( - &self, - address_book_id: &Uuid, - user_id: Uuid, - ) -> Result { - let address_book = self - .address_book_repository - .get_address_book_by_id(address_book_id) - .await? - .ok_or_else(|| { - DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found") - })?; - - // Check if user is owner - if address_book.owner_id() == user_id.to_string() { - return Ok(address_book); - } - - // Check if address book is public - if address_book.is_public() { - return Ok(address_book); - } - - // Check if address book is shared with user - let shares = self - .address_book_repository - .get_address_book_shares(address_book_id) - .await?; - if shares - .iter() - .any(|(shared_user, _)| shared_user == &user_id.to_string()) - { - return Ok(address_book); - } - - Err(DomainError::new( - ErrorKind::AccessDenied, - "AddressBook", - "Access denied to address book", - )) - } - - /// Helper to check write access - async fn check_write_access( - &self, - address_book_id: &Uuid, - user_id: Uuid, - ) -> Result { - let address_book = self - .address_book_repository - .get_address_book_by_id(address_book_id) - .await? - .ok_or_else(|| { - DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found") - })?; - - // Owner always has write access - if address_book.owner_id() == user_id.to_string() { - return Ok(address_book); - } - - // Check shares for write permission - let shares = self - .address_book_repository - .get_address_book_shares(address_book_id) - .await?; - if shares - .iter() - .any(|(shared_user, can_write)| shared_user == &user_id.to_string() && *can_write) - { - return Ok(address_book); - } - - Err(DomainError::new( - ErrorKind::AccessDenied, - "AddressBook", - "Write access denied", - )) - } - - /// Convert EmailDto to domain Email - fn dto_to_email(dto: EmailDto) -> Email { - Email { - email: dto.email, - r#type: dto.r#type, - is_primary: dto.is_primary, - } - } - - /// Convert PhoneDto to domain Phone - fn dto_to_phone(dto: PhoneDto) -> Phone { - Phone { - number: dto.number, - r#type: dto.r#type, - is_primary: dto.is_primary, - } - } - - /// Convert AddressDto to domain Address - fn dto_to_address(dto: AddressDto) -> Address { - Address { - street: dto.street, - city: dto.city, - state: dto.state, - postal_code: dto.postal_code, - country: dto.country, - r#type: dto.r#type, - is_primary: dto.is_primary, - } - } - - /// Generate vCard from contact data - fn generate_vcard(contact: &Contact) -> String { - let mut vcard = String::from("BEGIN:VCARD\nVERSION:3.0\n"); - - if let Some(full_name) = contact.full_name() { - vcard.push_str(&format!("FN:{}\n", full_name)); - } - - if contact.first_name().is_some() || contact.last_name().is_some() { - let last = contact.last_name().unwrap_or(""); - let first = contact.first_name().unwrap_or(""); - vcard.push_str(&format!("N:{};{};;;\n", last, first)); - } - - if let Some(nickname) = contact.nickname() { - vcard.push_str(&format!("NICKNAME:{}\n", nickname)); - } - - for email in contact.email() { - vcard.push_str(&format!( - "EMAIL;TYPE={}:{}\n", - email.r#type.to_uppercase(), - email.email - )); - } - - for phone in contact.phone() { - vcard.push_str(&format!( - "TEL;TYPE={}:{}\n", - phone.r#type.to_uppercase(), - phone.number - )); - } - - if let Some(org) = contact.organization() { - vcard.push_str(&format!("ORG:{}\n", org)); - } - - if let Some(title) = contact.title() { - vcard.push_str(&format!("TITLE:{}\n", title)); - } - - if let Some(notes) = contact.notes() { - vcard.push_str(&format!("NOTE:{}\n", notes)); - } - - vcard.push_str(&format!("UID:{}\n", contact.uid())); - vcard.push_str("END:VCARD\n"); - - vcard - } } -impl AddressBookUseCase for ContactStorageAdapter { +impl ContactStoragePort for ContactStorageAdapter { + // ── Address books ──────────────────────────────────────────── + async fn create_address_book( &self, - dto: CreateAddressBookDto, - ) -> Result { - let address_book = AddressBook::new( - dto.name, - dto.owner_id, - dto.description, - dto.color, - dto.is_public.unwrap_or(false), - ); - - let created = self - .address_book_repository + address_book: AddressBook, + ) -> Result { + self.address_book_repository .create_address_book(address_book) - .await?; - Ok(AddressBookDto::from(created)) + .await } async fn update_address_book( &self, - address_book_id: &str, - update: UpdateAddressBookDto, - ) -> Result { - let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; - - // Check write access - let user_id = Uuid::parse_str(&update.user_id).map_err(|_| { - DomainError::new( - ErrorKind::InvalidInput, - "AddressBook", - "Invalid user ID format", - ) - })?; - let mut address_book = self.check_write_access(&uuid, user_id).await?; - - if let Some(name) = update.name { - address_book.set_name(name); - } - if let Some(description) = update.description { - address_book.set_description(Some(description)); - } - if let Some(color) = update.color { - address_book.set_color(Some(color)); - } - if let Some(is_public) = update.is_public { - address_book.set_is_public(is_public); - } - address_book.set_updated_at(chrono::Utc::now()); - - let updated = self - .address_book_repository + address_book: AddressBook, + ) -> Result { + self.address_book_repository .update_address_book(address_book) - .await?; - Ok(AddressBookDto::from(updated)) - } - - async fn delete_address_book( - &self, - address_book_id: &str, - user_id: Uuid, - ) -> Result<(), DomainError> { - let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; - - // Only owner can delete - let address_book = self - .address_book_repository - .get_address_book_by_id(&uuid) - .await? - .ok_or_else(|| { - DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found") - })?; - - if address_book.owner_id() != user_id.to_string() { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "AddressBook", - "Only owner can delete address book", - )); - } - - self.address_book_repository - .delete_address_book(&uuid) .await } - async fn get_address_book( - &self, - address_book_id: &str, - user_id: Uuid, - ) -> Result { - let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; - let address_book = self.check_address_book_access(&uuid, user_id).await?; - Ok(AddressBookDto::from(address_book)) + async fn delete_address_book(&self, id: &Uuid) -> Result<(), DomainError> { + self.address_book_repository.delete_address_book(id).await } - async fn list_user_address_books( - &self, - user_id: Uuid, - ) -> Result, DomainError> { - let owned = self - .address_book_repository - .get_address_books_by_owner(user_id) - .await?; - let shared = self - .address_book_repository - .get_shared_address_books(user_id) - .await?; - - let mut all_books: Vec = owned; - all_books.extend(shared); - - Ok(all_books.into_iter().map(AddressBookDto::from).collect()) + async fn get_address_book_by_id(&self, id: &Uuid) -> Result, DomainError> { + self.address_book_repository + .get_address_book_by_id(id) + .await } - async fn list_public_address_books(&self) -> Result, DomainError> { - let public = self - .address_book_repository + async fn get_public_address_books(&self) -> Result, DomainError> { + self.address_book_repository .get_public_address_books() - .await?; - Ok(public.into_iter().map(AddressBookDto::from).collect()) - } - - async fn share_address_book( - &self, - dto: ShareAddressBookDto, - user_id: Uuid, - ) -> Result<(), DomainError> { - let uuid = Self::parse_uuid(&dto.address_book_id, "AddressBook")?; - - // Only owner can share - let address_book = self - .address_book_repository - .get_address_book_by_id(&uuid) - .await? - .ok_or_else(|| { - DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found") - })?; - - if address_book.owner_id() != user_id.to_string() { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "AddressBook", - "Only owner can share", - )); - } - - let target_user_id = Uuid::parse_str(&dto.user_id).map_err(|_| { - DomainError::new( - ErrorKind::InvalidInput, - "AddressBook", - "Invalid target user ID format", - ) - })?; - - self.address_book_repository - .share_address_book(&uuid, target_user_id, dto.can_write) .await } - async fn unshare_address_book( - &self, - dto: UnshareAddressBookDto, - user_id: Uuid, - ) -> Result<(), DomainError> { - let uuid = Self::parse_uuid(&dto.address_book_id, "AddressBook")?; + // ── Contacts ───────────────────────────────────────────────── - // Only owner can unshare - let address_book = self - .address_book_repository - .get_address_book_by_id(&uuid) - .await? - .ok_or_else(|| { - DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found") - })?; - - if address_book.owner_id() != user_id.to_string() { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "AddressBook", - "Only owner can unshare", - )); - } - - let target_user_id = Uuid::parse_str(&dto.user_id).map_err(|_| { - DomainError::new( - ErrorKind::InvalidInput, - "AddressBook", - "Invalid target user ID format", - ) - })?; - - self.address_book_repository - .unshare_address_book(&uuid, target_user_id) - .await + async fn create_contact(&self, contact: Contact) -> Result { + self.contact_repository.create_contact(contact).await } - async fn get_address_book_shares( - &self, - address_book_id: &str, - user_id: Uuid, - ) -> Result, DomainError> { - let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; - - // Only owner can view shares - let address_book = self - .address_book_repository - .get_address_book_by_id(&uuid) - .await? - .ok_or_else(|| { - DomainError::new(ErrorKind::NotFound, "AddressBook", "Address book not found") - })?; - - if address_book.owner_id() != user_id.to_string() { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "AddressBook", - "Only owner can view shares", - )); - } - - self.address_book_repository - .get_address_book_shares(&uuid) - .await - } -} - -impl ContactUseCase for ContactStorageAdapter { - async fn create_contact(&self, dto: CreateContactDto) -> Result { - let address_book_id = Self::parse_uuid(&dto.address_book_id, "AddressBook")?; - - // Check write access - let user_id = Uuid::parse_str(&dto.user_id).map_err(|_| { - DomainError::new(ErrorKind::InvalidInput, "Contact", "Invalid user ID format") - })?; - self.check_write_access(&address_book_id, user_id).await?; - - let now = chrono::Utc::now(); - let mut contact = Contact::from_raw( - Uuid::new_v4(), - address_book_id, - format!("{}@oxicloud", Uuid::new_v4()), - dto.full_name, - dto.first_name, - dto.last_name, - dto.nickname, - dto.email.into_iter().map(Self::dto_to_email).collect(), - dto.phone.into_iter().map(Self::dto_to_phone).collect(), - dto.address.into_iter().map(Self::dto_to_address).collect(), - dto.organization, - dto.title, - dto.notes, - dto.photo_url, - dto.birthday, - dto.anniversary, - String::new(), - Uuid::new_v4().to_string(), - now, - now, - ); - - // Generate vCard - let vcard = Self::generate_vcard(&contact); - contact.set_vcard(vcard); - - let created = self.contact_repository.create_contact(contact).await?; - Ok(ContactDto::from(created)) + async fn update_contact(&self, contact: Contact) -> Result { + self.contact_repository.update_contact(contact).await } - async fn create_contact_from_vcard( - &self, - dto: CreateContactVCardDto, - ) -> Result { - let address_book_id = Self::parse_uuid(&dto.address_book_id, "AddressBook")?; - - // Check write access - let user_id = Uuid::parse_str(&dto.user_id).map_err(|_| { - DomainError::new(ErrorKind::InvalidInput, "Contact", "Invalid user ID format") - })?; - self.check_write_access(&address_book_id, user_id).await?; - - // Parse vCard fields - let now = chrono::Utc::now(); - let vcard_data = &dto.vcard; - - let mut uid: Option = None; - let mut full_name: Option = None; - let mut first_name: Option = None; - let mut last_name: Option = None; - let mut nickname: Option = None; - let mut organization: Option = None; - let mut title: Option = None; - let mut notes: Option = None; - let mut emails: Vec = Vec::new(); - let mut phones: Vec = Vec::new(); - - for line in vcard_data.lines() { - let trimmed = line.trim(); - if let Some(stripped) = trimmed.strip_prefix("UID:") { - uid = Some(stripped.trim().to_string()); - } else if let Some(stripped) = trimmed.strip_prefix("FN:") { - full_name = Some(stripped.trim().to_string()); - } else if let Some(stripped) = trimmed.strip_prefix("N:") { - let parts: Vec<&str> = stripped.split(';').collect(); - if parts.len() >= 2 { - last_name = Some(parts[0].trim().to_string()).filter(|s| !s.is_empty()); - first_name = Some(parts[1].trim().to_string()).filter(|s| !s.is_empty()); - } - } else if let Some(stripped) = trimmed.strip_prefix("NICKNAME:") { - nickname = Some(stripped.trim().to_string()); - } else if let Some(stripped) = trimmed.strip_prefix("ORG:") { - organization = Some(stripped.trim().to_string()); - } else if let Some(stripped) = trimmed.strip_prefix("TITLE:") { - title = Some(stripped.trim().to_string()); - } else if let Some(stripped) = trimmed.strip_prefix("NOTE:") { - notes = Some(stripped.trim().to_string()); - } else if trimmed.starts_with("EMAIL") { - if let Some(value) = trimmed.split(':').nth(1) - && !value.is_empty() - { - let email_type = if trimmed.contains("TYPE=HOME") { - "home" - } else if trimmed.contains("TYPE=WORK") { - "work" - } else { - "other" - }; - emails.push(Email { - email: value.trim().to_string(), - r#type: email_type.to_string(), - is_primary: emails.is_empty(), - }); - } - } else if trimmed.starts_with("TEL") - && let Some(value) = trimmed.split(':').nth(1) - && !value.is_empty() - { - let phone_type = if trimmed.contains("TYPE=CELL") || trimmed.contains("TYPE=MOBILE") - { - "mobile" - } else if trimmed.contains("TYPE=HOME") { - "home" - } else if trimmed.contains("TYPE=WORK") { - "work" - } else { - "other" - }; - phones.push(Phone { - number: value.trim().to_string(), - r#type: phone_type.to_string(), - is_primary: phones.is_empty(), - }); - } - } - - let contact_uid = uid.unwrap_or_else(|| format!("{}@oxicloud", Uuid::new_v4())); - - let contact = Contact::from_raw( - Uuid::new_v4(), - address_book_id, - contact_uid, - full_name, - first_name, - last_name, - nickname, - emails, - phones, - Vec::new(), // addresses — simplified for now - organization, - title, - notes, - None, // photo_url - None, // birthday - None, // anniversary - dto.vcard, - Uuid::new_v4().to_string(), - now, - now, - ); - - let created = self.contact_repository.create_contact(contact).await?; - Ok(ContactDto::from(created)) + async fn delete_contact(&self, id: &Uuid) -> Result<(), DomainError> { + self.contact_repository.delete_contact(id).await } - async fn update_contact( - &self, - contact_id: &str, - update: UpdateContactDto, - ) -> Result { - let uuid = Self::parse_uuid(contact_id, "Contact")?; - - let mut contact = self - .contact_repository - .get_contact_by_id(&uuid) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; - - // Check write access to the address book - let user_id = Uuid::parse_str(&update.user_id).map_err(|_| { - DomainError::new(ErrorKind::InvalidInput, "Contact", "Invalid user ID format") - })?; - self.check_write_access(contact.address_book_id(), user_id) - .await?; - - if let Some(full_name) = update.full_name { - contact.set_full_name(Some(full_name)); - } - if let Some(first_name) = update.first_name { - contact.set_first_name(Some(first_name)); - } - if let Some(last_name) = update.last_name { - contact.set_last_name(Some(last_name)); - } - if let Some(nickname) = update.nickname { - contact.set_nickname(Some(nickname)); - } - if let Some(emails) = update.email { - contact.set_email(emails.into_iter().map(Self::dto_to_email).collect()); - } - if let Some(phones) = update.phone { - contact.set_phone(phones.into_iter().map(Self::dto_to_phone).collect()); - } - if let Some(addresses) = update.address { - contact.set_address(addresses.into_iter().map(Self::dto_to_address).collect()); - } - if let Some(organization) = update.organization { - contact.set_organization(Some(organization)); - } - if let Some(title) = update.title { - contact.set_title(Some(title)); - } - if let Some(notes) = update.notes { - contact.set_notes(Some(notes)); - } - if let Some(photo_url) = update.photo_url { - contact.set_photo_url(Some(photo_url)); - } - if let Some(birthday) = update.birthday { - contact.set_birthday(Some(birthday)); - } - if let Some(anniversary) = update.anniversary { - contact.set_anniversary(Some(anniversary)); - } - - contact.set_updated_at(chrono::Utc::now()); - contact.set_etag(Uuid::new_v4().to_string()); - let vcard = Self::generate_vcard(&contact); - contact.set_vcard(vcard); - - let updated = self.contact_repository.update_contact(contact).await?; - Ok(ContactDto::from(updated)) - } - - async fn delete_contact(&self, contact_id: &str, user_id: Uuid) -> Result<(), DomainError> { - let uuid = Self::parse_uuid(contact_id, "Contact")?; - - let contact = self - .contact_repository - .get_contact_by_id(&uuid) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; - - // Check write access - self.check_write_access(contact.address_book_id(), user_id) - .await?; - - self.contact_repository.delete_contact(&uuid).await - } - - async fn get_contact( - &self, - contact_id: &str, - user_id: Uuid, - ) -> Result { - let uuid = Self::parse_uuid(contact_id, "Contact")?; - - let contact = self - .contact_repository - .get_contact_by_id(&uuid) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; - - // Check read access - self.check_address_book_access(contact.address_book_id(), user_id) - .await?; - - Ok(ContactDto::from(contact)) + async fn get_contact_by_id(&self, id: &Uuid) -> Result, DomainError> { + self.contact_repository.get_contact_by_id(id).await } async fn get_contact_by_uid( &self, - address_book_id: &str, + address_book_id: &Uuid, uid: &str, - user_id: Uuid, - ) -> Result, DomainError> { - let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; - - // Check read access - self.check_address_book_access(&uuid, user_id).await?; - - let contact = self - .contact_repository - .get_contact_by_uid(&uuid, uid) - .await?; - Ok(contact.map(ContactDto::from)) + ) -> Result, DomainError> { + self.contact_repository + .get_contact_by_uid(address_book_id, uid) + .await } async fn get_contacts_by_uids( &self, - address_book_id: &str, + address_book_id: &Uuid, uids: &[String], - user_id: Uuid, - ) -> Result, DomainError> { - let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; - - // Check read access - self.check_address_book_access(&uuid, user_id).await?; - - if uids.is_empty() { - return Ok(Vec::new()); - } - - let contacts = self - .contact_repository - .get_contacts_by_uids(&uuid, uids) - .await?; - Ok(contacts.into_iter().map(ContactDto::from).collect()) + ) -> Result, DomainError> { + self.contact_repository + .get_contacts_by_uids(address_book_id, uids) + .await } - async fn list_contacts( + async fn get_contacts_by_address_book( &self, - address_book_id: &str, - limit: Option, - offset: Option, - user_id: Uuid, - ) -> Result, DomainError> { - let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; + address_book_id: &Uuid, + ) -> Result, DomainError> { + self.contact_repository + .get_contacts_by_address_book(address_book_id) + .await + } - // Check read access - self.check_address_book_access(&uuid, user_id).await?; - - let contacts = if limit.is_some() || offset.is_some() { - let limit = limit.unwrap_or(100); - let offset = offset.unwrap_or(0); - self.contact_repository - .get_contacts_by_address_book_paginated(&uuid, limit, offset) - .await? - } else { - self.contact_repository - .get_contacts_by_address_book(&uuid) - .await? - }; - Ok(contacts.into_iter().map(ContactDto::from).collect()) + async fn get_contacts_by_address_book_paginated( + &self, + address_book_id: &Uuid, + limit: i64, + offset: i64, + ) -> Result, DomainError> { + self.contact_repository + .get_contacts_by_address_book_paginated(address_book_id, limit, offset) + .await } async fn search_contacts( &self, - address_book_id: &str, + address_book_id: &Uuid, query: &str, - user_id: Uuid, - ) -> Result, DomainError> { - let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; - - // Check read access - self.check_address_book_access(&uuid, user_id).await?; - - let contacts = self - .contact_repository - .search_contacts(&uuid, query) - .await?; - Ok(contacts.into_iter().map(ContactDto::from).collect()) + ) -> Result, DomainError> { + self.contact_repository + .search_contacts(address_book_id, query) + .await } - async fn create_group( + // ── Contact groups ─────────────────────────────────────────── + + async fn create_group(&self, group: ContactGroup) -> Result { + self.contact_group_repository.create_group(group).await + } + + async fn update_group(&self, group: ContactGroup) -> Result { + self.contact_group_repository.update_group(group).await + } + + async fn delete_group(&self, id: &Uuid) -> Result<(), DomainError> { + self.contact_group_repository.delete_group(id).await + } + + async fn get_group_by_id(&self, id: &Uuid) -> Result, DomainError> { + self.contact_group_repository.get_group_by_id(id).await + } + + async fn get_groups_by_address_book( &self, - dto: CreateContactGroupDto, - ) -> Result { - let address_book_id = Self::parse_uuid(&dto.address_book_id, "AddressBook")?; - - // Check write access - let user_id = Uuid::parse_str(&dto.user_id).map_err(|_| { - DomainError::new( - ErrorKind::InvalidInput, - "ContactGroup", - "Invalid user ID format", - ) - })?; - self.check_write_access(&address_book_id, user_id).await?; - - let group = ContactGroup::new(address_book_id, dto.name); - - let created = self.group_repository.create_group(group).await?; - Ok(ContactGroupDto::from(created)) + address_book_id: &Uuid, + ) -> Result, DomainError> { + self.contact_group_repository + .get_groups_by_address_book(address_book_id) + .await } - async fn update_group( - &self, - group_id: &str, - update: UpdateContactGroupDto, - ) -> Result { - let uuid = Self::parse_uuid(group_id, "ContactGroup")?; - - let mut group = self - .group_repository - .get_group_by_id(&uuid) - .await? - .ok_or_else(|| { - DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found") - })?; - - // Check write access - let user_id = Uuid::parse_str(&update.user_id).map_err(|_| { - DomainError::new( - ErrorKind::InvalidInput, - "ContactGroup", - "Invalid user ID format", - ) - })?; - self.check_write_access(group.address_book_id(), user_id) - .await?; - - group.set_name(update.name); - group.set_updated_at(chrono::Utc::now()); - - let updated = self.group_repository.update_group(group).await?; - Ok(ContactGroupDto::from(updated)) - } - - async fn delete_group(&self, group_id: &str, user_id: Uuid) -> Result<(), DomainError> { - let uuid = Self::parse_uuid(group_id, "ContactGroup")?; - - let group = self - .group_repository - .get_group_by_id(&uuid) - .await? - .ok_or_else(|| { - DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found") - })?; - - // Check write access - self.check_write_access(group.address_book_id(), user_id) - .await?; - - self.group_repository.delete_group(&uuid).await - } - - async fn get_group( - &self, - group_id: &str, - user_id: Uuid, - ) -> Result { - let uuid = Self::parse_uuid(group_id, "ContactGroup")?; - - let group = self - .group_repository - .get_group_by_id(&uuid) - .await? - .ok_or_else(|| { - DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found") - })?; - - // Check read access - self.check_address_book_access(group.address_book_id(), user_id) - .await?; - - Ok(ContactGroupDto::from(group)) - } - - async fn list_groups( - &self, - address_book_id: &str, - user_id: Uuid, - ) -> Result, DomainError> { - let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; - - // Check read access - self.check_address_book_access(&uuid, user_id).await?; - - let groups = self - .group_repository - .get_groups_by_address_book(&uuid) - .await?; - Ok(groups.into_iter().map(ContactGroupDto::from).collect()) - } + // ── Group membership ───────────────────────────────────────── async fn add_contact_to_group( &self, - dto: GroupMembershipDto, - user_id: Uuid, + group_id: &Uuid, + contact_id: &Uuid, ) -> Result<(), DomainError> { - let group_id = Self::parse_uuid(&dto.group_id, "ContactGroup")?; - let contact_id = Self::parse_uuid(&dto.contact_id, "Contact")?; - - let group = self - .group_repository - .get_group_by_id(&group_id) - .await? - .ok_or_else(|| { - DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found") - })?; - - // Check write access - self.check_write_access(group.address_book_id(), user_id) - .await?; - - self.group_repository - .add_contact_to_group(&group_id, &contact_id) + self.contact_group_repository + .add_contact_to_group(group_id, contact_id) .await } async fn remove_contact_from_group( &self, - dto: GroupMembershipDto, - user_id: Uuid, + group_id: &Uuid, + contact_id: &Uuid, ) -> Result<(), DomainError> { - let group_id = Self::parse_uuid(&dto.group_id, "ContactGroup")?; - let contact_id = Self::parse_uuid(&dto.contact_id, "Contact")?; - - let group = self - .group_repository - .get_group_by_id(&group_id) - .await? - .ok_or_else(|| { - DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found") - })?; - - // Check write access - self.check_write_access(group.address_book_id(), user_id) - .await?; - - self.group_repository - .remove_contact_from_group(&group_id, &contact_id) + self.contact_group_repository + .remove_contact_from_group(group_id, contact_id) .await } - async fn list_contacts_in_group( - &self, - group_id: &str, - user_id: Uuid, - ) -> Result, DomainError> { - let uuid = Self::parse_uuid(group_id, "ContactGroup")?; - - let group = self - .group_repository - .get_group_by_id(&uuid) - .await? - .ok_or_else(|| { - DomainError::new(ErrorKind::NotFound, "ContactGroup", "Group not found") - })?; - - // Check read access - self.check_address_book_access(group.address_book_id(), user_id) - .await?; - - let contacts = self.group_repository.get_contacts_in_group(&uuid).await?; - Ok(contacts.into_iter().map(ContactDto::from).collect()) + async fn get_contacts_in_group(&self, group_id: &Uuid) -> Result, DomainError> { + self.contact_group_repository + .get_contacts_in_group(group_id) + .await } - async fn list_groups_for_contact( + async fn get_groups_for_contact( &self, - contact_id: &str, - user_id: Uuid, - ) -> Result, DomainError> { - let uuid = Self::parse_uuid(contact_id, "Contact")?; - - let contact = self - .contact_repository - .get_contact_by_id(&uuid) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; - - // Check read access - self.check_address_book_access(contact.address_book_id(), user_id) - .await?; - - let groups = self.group_repository.get_groups_for_contact(&uuid).await?; - Ok(groups.into_iter().map(ContactGroupDto::from).collect()) - } - - async fn get_contact_vcard( - &self, - contact_id: &str, - user_id: Uuid, - ) -> Result { - let uuid = Self::parse_uuid(contact_id, "Contact")?; - - let contact = self - .contact_repository - .get_contact_by_id(&uuid) - .await? - .ok_or_else(|| DomainError::new(ErrorKind::NotFound, "Contact", "Contact not found"))?; - - // Check read access - self.check_address_book_access(contact.address_book_id(), user_id) - .await?; - - Ok(contact.vcard().to_string()) - } - - async fn get_contacts_as_vcards( - &self, - address_book_id: &str, - user_id: Uuid, - ) -> Result, DomainError> { - let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; - - // Check read access - self.check_address_book_access(&uuid, user_id).await?; - - let contacts = self - .contact_repository - .get_contacts_by_address_book(&uuid) - .await?; - - Ok(contacts - .into_iter() - .map(|c| (c.id().to_string(), c.vcard().to_string())) - .collect()) + contact_id: &Uuid, + ) -> Result, DomainError> { + self.contact_group_repository + .get_groups_for_contact(contact_id) + .await } } diff --git a/src/infrastructure/repositories/pg/address_book_pg_repository.rs b/src/infrastructure/repositories/pg/address_book_pg_repository.rs index 0d2191f7..8ea91ca4 100644 --- a/src/infrastructure/repositories/pg/address_book_pg_repository.rs +++ b/src/infrastructure/repositories/pg/address_book_pg_repository.rs @@ -184,44 +184,6 @@ impl AddressBookRepository for AddressBookPgRepository { Ok(result) } - async fn get_shared_address_books( - &self, - user_id: Uuid, - ) -> AddressBookRepositoryResult> { - let rows = sqlx::query( - r#" - SELECT a.id, a.name, a.owner_id, a.description, a.color, a.is_public, a.created_at, a.updated_at - FROM carddav.address_books a - INNER JOIN carddav.address_book_shares s ON a.id = s.address_book_id - WHERE s.user_id = $1 - ORDER BY a.name - "# - ) - .bind(user_id) - .fetch_all(&*self.pool) - .await - .map_err(|e| DomainError::database_error(format!("Failed to get shared address books: {}", e)))?; - - let result = rows - .into_iter() - .map(|row| { - let owner_id: Uuid = row.get("owner_id"); - AddressBook::from_raw( - row.get("id"), - row.get("name"), - owner_id.to_string(), - row.get("description"), - row.get("color"), - row.get("is_public"), - row.get("created_at"), - row.get("updated_at"), - ) - }) - .collect(); - - Ok(result) - } - async fn get_public_address_books(&self) -> AddressBookRepositoryResult> { let rows = sqlx::query( r#" @@ -256,79 +218,4 @@ impl AddressBookRepository for AddressBookPgRepository { Ok(result) } - - async fn share_address_book( - &self, - address_book_id: &Uuid, - user_id: Uuid, - can_write: bool, - ) -> AddressBookRepositoryResult<()> { - sqlx::query( - r#" - INSERT INTO carddav.address_book_shares (address_book_id, user_id, can_write) - VALUES ($1, $2, $3) - ON CONFLICT (address_book_id, user_id) DO UPDATE SET can_write = $3 - "#, - ) - .bind(address_book_id) - .bind(user_id) - .bind(can_write) - .execute(&*self.pool) - .await - .map_err(|e| DomainError::database_error(format!("Failed to share address book: {}", e)))?; - - Ok(()) - } - - async fn unshare_address_book( - &self, - address_book_id: &Uuid, - user_id: Uuid, - ) -> AddressBookRepositoryResult<()> { - sqlx::query( - r#" - DELETE FROM carddav.address_book_shares - WHERE address_book_id = $1 AND user_id = $2 - "#, - ) - .bind(address_book_id) - .bind(user_id) - .execute(&*self.pool) - .await - .map_err(|e| { - DomainError::database_error(format!("Failed to unshare address book: {}", e)) - })?; - - Ok(()) - } - - async fn get_address_book_shares( - &self, - address_book_id: &Uuid, - ) -> AddressBookRepositoryResult> { - let rows = sqlx::query( - r#" - SELECT user_id, can_write - FROM carddav.address_book_shares - WHERE address_book_id = $1 - ORDER BY user_id - "#, - ) - .bind(address_book_id) - .fetch_all(&*self.pool) - .await - .map_err(|e| { - DomainError::database_error(format!("Failed to get address book shares: {}", e)) - })?; - - let result = rows - .into_iter() - .map(|row| { - let user_id: Uuid = row.get("user_id"); - (user_id.to_string(), row.get("can_write")) - }) - .collect(); - - Ok(result) - } } diff --git a/src/infrastructure/repositories/pg/calendar_pg_repository.rs b/src/infrastructure/repositories/pg/calendar_pg_repository.rs index 10bb3cc3..8eae0ce1 100644 --- a/src/infrastructure/repositories/pg/calendar_pg_repository.rs +++ b/src/infrastructure/repositories/pg/calendar_pg_repository.rs @@ -216,44 +216,6 @@ impl CalendarRepository for CalendarPgRepository { Ok(calendar) } - async fn list_calendars_shared_with_user( - &self, - user_id: Uuid, - ) -> CalendarRepositoryResult> { - let rows = sqlx::query( - r#" - SELECT c.id, c.name, c.owner_id, c.description, c.color, c.is_public, c.created_at, c.updated_at - FROM caldav.calendars c - INNER JOIN caldav.calendar_shares s ON c.id = s.calendar_id - WHERE s.user_id = $1 - ORDER BY c.name - "# - ) - .bind(user_id) - .fetch_all(&*self.pool) - .await - .map_err(|e| DomainError::database_error(format!("Failed to get shared calendars: {}", e)))?; - - let mut calendars = Vec::new(); - for row in rows { - let calendar = Calendar::with_id( - row.get("id"), - row.get("name"), - row.get("owner_id"), - row.get("description"), - row.get("color"), - row.get("created_at"), - row.get("updated_at"), - ) - .map_err(|e| { - DomainError::database_error(format!("Failed to create calendar object: {}", e)) - })?; - calendars.push(calendar); - } - - Ok(calendars) - } - async fn list_public_calendars( &self, limit: i64, @@ -296,112 +258,6 @@ impl CalendarRepository for CalendarPgRepository { Ok(calendars) } - async fn user_has_calendar_access( - &self, - calendar_id: &Uuid, - user_id: Uuid, - ) -> CalendarRepositoryResult { - // Check if the user is the owner of the calendar or has a share - let row = sqlx::query( - r#" - SELECT EXISTS ( - SELECT 1 FROM caldav.calendars c - WHERE c.id = $1 AND (c.owner_id = $2 OR c.is_public = true) - UNION - SELECT 1 FROM caldav.calendar_shares s - WHERE s.calendar_id = $1 AND s.user_id = $2 - ) as has_access - "#, - ) - .bind(calendar_id) - .bind(user_id) - .fetch_one(&*self.pool) - .await - .map_err(|e| { - DomainError::database_error(format!("Failed to check calendar access: {}", e)) - })?; - - Ok(row.get::("has_access")) - } - - async fn share_calendar( - &self, - calendar_id: &Uuid, - user_id: Uuid, - access_level: &str, - ) -> CalendarRepositoryResult<()> { - // Validate access level - if !["read", "write", "owner"].contains(&access_level) { - return Err(DomainError::validation_error(format!( - "Invalid access level: '{}'. Must be 'read', 'write', or 'owner'", - access_level - ))); - } - - sqlx::query( - r#" - INSERT INTO caldav.calendar_shares (calendar_id, user_id, access_level) - VALUES ($1, $2, $3) - ON CONFLICT (calendar_id, user_id) DO UPDATE SET access_level = $3 - "#, - ) - .bind(calendar_id) - .bind(user_id) - .bind(access_level) - .execute(&*self.pool) - .await - .map_err(|e| DomainError::database_error(format!("Failed to share calendar: {}", e)))?; - - Ok(()) - } - - async fn remove_calendar_sharing( - &self, - calendar_id: &Uuid, - user_id: Uuid, - ) -> CalendarRepositoryResult<()> { - sqlx::query( - r#" - DELETE FROM caldav.calendar_shares - WHERE calendar_id = $1 AND user_id = $2 - "#, - ) - .bind(calendar_id) - .bind(user_id) - .execute(&*self.pool) - .await - .map_err(|e| DomainError::database_error(format!("Failed to unshare calendar: {}", e)))?; - - Ok(()) - } - - async fn get_calendar_shares( - &self, - calendar_id: &Uuid, - ) -> CalendarRepositoryResult> { - let rows = sqlx::query( - r#" - SELECT user_id, access_level - FROM caldav.calendar_shares - WHERE calendar_id = $1 - ORDER BY user_id - "#, - ) - .bind(calendar_id) - .fetch_all(&*self.pool) - .await - .map_err(|e| { - DomainError::database_error(format!("Failed to get calendar shares: {}", e)) - })?; - - let mut shares = Vec::new(); - for row in rows { - shares.push((row.get("user_id"), row.get("access_level"))); - } - - Ok(shares) - } - async fn get_calendar_property( &self, calendar_id: &Uuid, diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index 6d346ba4..708f8ba2 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -479,11 +479,18 @@ impl PgAclEngine { /// Returns the `drive_id` for a File / Folder. Drives don't have a parent /// drive — this returns `NotFound` for `Resource::Drive` and the caller /// must not invoke it on Drive resources. + /// + /// `Resource::Calendar` and `Resource::AddressBook` are top-level per + /// user with no drive ancestor; they also return `NotFound` and the + /// engine short-circuits to a direct `role_grants` lookup (no drive + /// precheck applies). async fn drive_of(&self, resource: Resource) -> Result { match resource { Resource::Folder(id) => self.folder_repo.get_folder_drive_id(&id.to_string()).await, Resource::File(id) => self.file_repo.get_file_drive_id(&id.to_string()).await, - Resource::Drive(_) => Err(DomainError::not_found("Drive", resource.id().to_string())), + Resource::Drive(_) | Resource::Calendar(_) | Resource::AddressBook(_) => Err( + DomainError::not_found(resource.type_str(), resource.id().to_string()), + ), } } @@ -519,6 +526,49 @@ impl PgAclEngine { /// permission — see `roles_implying()`. /// /// Uses the GiST index on `storage.folders.lpath` for O(log N) cascade. + /// Direct grant lookup with no cascade — used for top-level + /// resources whose ACL lives entirely on their own row + /// (`Resource::Calendar`, `Resource::AddressBook`). Same + /// role-array + subject-set shape as the cascade helpers so a + /// caller's group memberships still resolve, but no ltree / + /// folder ancestry / drive precheck applies. Calendars and + /// address books have no parent to inherit from. + async fn direct_grant_exists( + &self, + subject_types: &[&str], + subject_ids: &[Uuid], + permission: Permission, + resource_type: &'static str, + resource_id: Uuid, + counters: &QueryCounters, + ) -> Result { + counters.sql_queries.fetch_add(1, Ordering::Relaxed); + let roles = Self::roles_implying_strings(permission); + let exists: Option = sqlx::query_scalar( + r#" + SELECT 1 + FROM storage.role_grants g + WHERE g.subject_type = ANY($1) + AND g.subject_id = ANY($2) + AND g.role = ANY($3::storage.grant_role[]) + AND g.resource_type = $4 + AND g.resource_id = $5 + AND (g.expires_at IS NULL OR g.expires_at > NOW()) + LIMIT 1 + "#, + ) + .bind(subject_types) + .bind(subject_ids) + .bind(&roles) + .bind(resource_type) + .bind(resource_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("direct grant: {e}")))?; + + Ok(exists.is_some()) + } + async fn folder_cascade_grant_exists( &self, subject_types: &[&str], @@ -819,6 +869,39 @@ impl PgAclEngine { .await? .is_some_and(|r| r.expand().contains(&permission))) } + // Top-level resources with no cascade parent — the ACL + // lives entirely on their own `role_grants` rows. Owner is + // an explicit grant seeded at MKCALENDAR / address-book + // create time (Round 3 phase 2 migration), so the common + // "owner accessing their own calendar" case is one SQL + // round-trip — no drive_role_cache short-circuit (no + // drive), no cascade. + Resource::Calendar(id) => { + let (subject_types, subject_ids) = + self.subject_match_set(subject, counters).await?; + self.direct_grant_exists( + &subject_types, + &subject_ids, + permission, + "calendar", + id, + counters, + ) + .await + } + Resource::AddressBook(id) => { + let (subject_types, subject_ids) = + self.subject_match_set(subject, counters).await?; + self.direct_grant_exists( + &subject_types, + &subject_ids, + permission, + "address_book", + id, + counters, + ) + .await + } } } } diff --git a/src/interfaces/api/handlers/caldav_handler.rs b/src/interfaces/api/handlers/caldav_handler.rs index d0468e08..82b51207 100644 --- a/src/interfaces/api/handlers/caldav_handler.rs +++ b/src/interfaces/api/handlers/caldav_handler.rs @@ -346,6 +346,18 @@ async fn handle_propfind( .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") .body(Body::from(response_body)) .unwrap()) + } else if first_is_uuid { + // Path segment IS a UUID but the calendar isn't + // accessible to the caller — could be another + // owner's calendar or genuinely missing. Return + // 404 (anti-enum, matches every other OxiCloud + // surface post-D7). The pre-Round-3 fall-through + // silently listed the caller's OWN calendars, + // which was misleading (the URL claimed one calendar, + // response returned unrelated ones) and violated + // the anti-enumeration contract audited in + // `docs/plan/authz_audit/caldav_carddav_wopi.md`. + Err(AppError::not_found("Calendar not found")) } else { // Not a calendar ID — treat as user calendar home (e.g. /caldav/{username}/) // List all calendars for this user diff --git a/src/interfaces/api/handlers/carddav_handler.rs b/src/interfaces/api/handlers/carddav_handler.rs index 47c2e97a..7af84186 100644 --- a/src/interfaces/api/handlers/carddav_handler.rs +++ b/src/interfaces/api/handlers/carddav_handler.rs @@ -33,8 +33,8 @@ use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType use crate::application::dtos::address_book_dto::{CreateAddressBookDto, UpdateAddressBookDto}; use crate::application::dtos::contact_dto::CreateContactVCardDto; use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase}; +use crate::application::services::contact_service::ContactService; use crate::common::di::AppState; -use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::{AuthUser, CurrentUser}; @@ -177,7 +177,7 @@ fn extract_user(req: &Request) -> Result { .ok_or_else(|| AppError::unauthorized("Authentication required")) } -fn get_addressbook_service(state: &AppState) -> Result<&Arc, AppError> { +fn get_addressbook_service(state: &AppState) -> Result<&Arc, AppError> { state.addressbook_use_case.as_ref().ok_or_else(|| { AppError::new( StatusCode::NOT_IMPLEMENTED, @@ -187,7 +187,7 @@ fn get_addressbook_service(state: &AppState) -> Result<&Arc Result<&Arc, AppError> { +fn get_contact_service(state: &AppState) -> Result<&Arc, AppError> { state.contact_use_case.as_ref().ok_or_else(|| { AppError::new( StatusCode::NOT_IMPLEMENTED, diff --git a/src/interfaces/api/handlers/contacts_handler.rs b/src/interfaces/api/handlers/contacts_handler.rs index 5b69cfe7..b1b495f9 100644 --- a/src/interfaces/api/handlers/contacts_handler.rs +++ b/src/interfaces/api/handlers/contacts_handler.rs @@ -19,8 +19,8 @@ use crate::application::dtos::contact_dto::{ use crate::application::dtos::user_dto::UserDto; use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase}; use crate::application::services::auth_application_service::AuthApplicationService; +use crate::application::services::contact_service::ContactService; use crate::domain::errors::ErrorKind; -use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter; use crate::interfaces::middleware::auth::AuthUser; const SYSTEM_BOOK_ID: &str = "system"; @@ -28,7 +28,7 @@ const SYSTEM_BOOK_ID: &str = "system"; /// Combined state for the contacts REST API. #[derive(Clone)] pub struct ContactsApiState { - pub contact_service: Arc, + pub contact_service: Arc, pub auth_service: Option>, /// When false, the virtual "system" address book (OxiCloud users) is hidden. pub expose_system_users: bool, diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index bf6d190c..07d493ca 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -113,6 +113,14 @@ pub async fn create_grant( .get_by_id(id) .await .map(|d| d.drive.typed_policies()), + // Calendars and address books live outside the drive + // hierarchy (top-level per user), so no drive-level policy + // gates apply. If per-calendar / per-address-book policies + // ever ship, they'll live on the resource itself, not on a + // drive; the default-empty bag is the right no-op here. + Resource::Calendar(_) | Resource::AddressBook(_) => { + Ok(crate::domain::entities::drive::DrivePolicies::default()) + } }; let drive_policies = match drive_policies { Ok(p) => p, diff --git a/tests/api/calendar.hurl b/tests/api/calendar.hurl new file mode 100644 index 00000000..cb505d19 --- /dev/null +++ b/tests/api/calendar.hurl @@ -0,0 +1,250 @@ +# ============================================================= +# OxiCloud – CalDAV + Round-3 AuthZ end-to-end scenario +# ============================================================= +# Verifies the full CalDAV surface post-Round-3: +# +# * MKCALENDAR / PROPFIND / DELETE against `/caldav/*` all +# route through `CalendarService`, which enforces +# `authz.require` on every method. +# * Cross-user access uses the 404 anti-enum shape (was 403 +# in the bespoke `check_calendar_access` era). +# * Sharing goes through the generic `POST /api/grants` with +# `resource.type = "calendar"` — a first-class ReBAC +# resource variant added in Round 3 Phase 1. +# * A shared calendar shows up in the recipient's PROPFIND +# listing while the grant is live and disappears again +# after revoke. +# +# The `calendar_id` is server-assigned at MKCALENDAR time and +# surfaces in the PROPFIND response as `/caldav//`. We +# extract it with a regex on the response body — the fresh CI +# database (`tests/webdav/run.sh` spawns a private Postgres) +# guarantees admin has zero pre-existing calendars, so the +# first-match regex is unambiguous. +# +# CalDAV auth is JWT via the same middleware the REST API uses +# (`/caldav/*` and `/carddav/*` are both wrapped in +# `auth_middleware + require_internal_user_layer` in main.rs). +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 – Alice (admin) logs in. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ + "username": "{{username}}", + "password": "{{password}}" +} + +HTTP 200 +[Captures] +alice_token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 2 – MKCALENDAR: create a fresh calendar for the test. +# Empty body → the CalDAV handler derives the display name +# from the last path segment ("round3-cal" here). The response +# is 201 with an empty body — CalDAV convention. The +# server-assigned UUID is captured in Step 3 via PROPFIND. +# ───────────────────────────────────────────────────────────── +MKCALENDAR {{base_url}}/caldav/round3-cal/ +Authorization: Bearer {{alice_token}} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 3 – Alice PROPFIND at Depth 1 lists her calendars. +# The response is a `` — each calendar surfaces +# as `/caldav//`. Regex-capture the +# UUID (first `/caldav//` in the body — the root href +# is `/caldav/` alone, no UUID, so it can't match). +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/caldav/ +Authorization: Bearer {{alice_token}} +Depth: 1 +Content-Type: application/xml +``` + + + + + + + +``` + +HTTP 207 +[Captures] +calendar_id: body regex "/caldav/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/" + + +# ───────────────────────────────────────────────────────────── +# Step 4 – Provision Bob. Idempotent: `HTTP *` accepts 201 +# on the first run and 409 on subsequent ones. Login is the +# actual precondition. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "username": "caldav_bob", + "password": "CaldavBobPassword1!", + "email": "caldav_bob@example.com", + "role": "user" +} + +HTTP * + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ + "username": "caldav_bob", + "password": "CaldavBobPassword1!" +} + +HTTP 200 +[Captures] +bob_token: jsonpath "$.access_token" +bob_user_id: jsonpath "$.user.id" + + +# ───────────────────────────────────────────────────────────── +# Step 5 – Cross-user PROPFIND. Bob has no grant on Alice's +# calendar; his listing does NOT include the calendar's UUID. +# (Bob's OWN response body will list his lifecycle-provisioned +# calendars — none of them collide with Alice's UUID.) +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/caldav/ +Authorization: Bearer {{bob_token}} +Depth: 1 +Content-Type: application/xml +``` + + + + +``` + +HTTP 207 +[Asserts] +body not contains "{{calendar_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 6 – Cross-user direct PROPFIND on Alice's calendar +# → 404. `authz.require(Read)` denies with `NotFound` for +# anti-enumeration parity with files/folders/drives. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/caldav/{{calendar_id}}/ +Authorization: Bearer {{bob_token}} +Depth: 0 +Content-Type: application/xml +``` + + + + +``` + +HTTP * +[Asserts] +status >= 400 +status < 500 + + +# ───────────────────────────────────────────────────────────── +# Step 7 – Alice shares the calendar with Bob as Viewer via +# the generic ReBAC grant endpoint. `resource.type = "calendar"` +# is a first-class variant post-Round-3. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "resource": { "type": "calendar", "id": "{{calendar_id}}" }, + "role": "viewer" +} + +HTTP 201 +[Captures] +share_grant_id: jsonpath "$.grants[0].id" +[Asserts] +jsonpath "$.grants[0].role" == "viewer" +jsonpath "$.grants[0].resource.type" == "calendar" +jsonpath "$.grants[0].resource.id" == "{{calendar_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 8 – Bob PROPFIND now includes Alice's calendar. The +# `list_my_calendars` service method reads +# `authz.list_incoming_grants(user)` and unions across +# owned + shared, replacing the pre-Round-3 owner-only query. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/caldav/ +Authorization: Bearer {{bob_token}} +Depth: 1 +Content-Type: application/xml +``` + + + + +``` + +HTTP 207 +[Asserts] +body contains "{{calendar_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 9 – Alice revokes the grant. `DELETE /api/grants/{id}` +# maps to a single `role_grants` row delete. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/grants/{{share_grant_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Step 10 – Bob PROPFIND no longer includes Alice's calendar. +# The role_grants row is gone, so `list_incoming_grants` won't +# surface it and `list_my_calendars` collapses back to Bob's +# own. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/caldav/ +Authorization: Bearer {{bob_token}} +Depth: 1 +Content-Type: application/xml +``` + + + + +``` + +HTTP 207 +[Asserts] +body not contains "{{calendar_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 11 – Cleanup: Alice deletes the calendar. The service +# runs `authz.require(Delete)` (owner passes via the seeded +# Owner grant), then `revoke_all_for_resource` wipes any +# remaining grants on the calendar in case a share slipped +# through. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/caldav/{{calendar_id}}/ +Authorization: Bearer {{alice_token}} + +HTTP * +[Asserts] +status >= 200 +status < 300 diff --git a/tests/api/contacts.hurl b/tests/api/contacts.hurl index 2570668d..4a199da2 100644 --- a/tests/api/contacts.hurl +++ b/tests/api/contacts.hurl @@ -276,3 +276,318 @@ Authorization: Bearer {{token}} HTTP 200 [Asserts] jsonpath "$" isCollection + + +# ═════════════════════════════════════════════════════════════ +# Round 3 — CardDAV/AddressBook AuthZ regression +# ═════════════════════════════════════════════════════════════ +# Post-Round-3, address-book access + sharing routes through +# `AuthorizationEngine` and `storage.role_grants`. The dedicated +# `carddav.address_book_shares` table stopped being consulted; +# the generic `POST /api/grants` endpoint accepts +# `resource.type = "address_book"` as a first-class ReBAC +# resource. +# +# Coverage: +# 15. Fresh book owned by admin (Alice). +# 16. Non-member user (Bob) doesn't see the book. +# 17. Bob's direct GET on the book → 404 (anti-enum, was 403 +# pre-Round-3). +# 18. Alice shares with Bob as Viewer via `POST /api/grants`. +# 19. Bob's listing includes the book with is_readonly=true. +# 20. Viewer role's bundle has no Create — Bob's contact +# write → 404 (anti-enum). +# 21. Alice revokes via `DELETE /api/grants/{id}`. +# 22. Bob no longer sees the book. +# 23. Cleanup. +# ============================================================= + + +# Step 15 — Alice creates a fresh book for the share regression. +POST {{base_url}}/api/address-books +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "name": "Round3 Share Book", + "description": "Book for the multi-user share regression", + "is_public": false +} + +HTTP 201 +[Captures] +share_book_id: jsonpath "$.id" + + +# Step 16 — Provision Bob. Idempotent: accept 201 on first run, +# 409 on subsequent runs; login is the actual precondition. +POST {{base_url}}/api/admin/users +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "username": "carddav_bob", + "password": "CarddavBobPassword1!", + "email": "carddav_bob@example.com", + "role": "user" +} + +HTTP * + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ + "username": "carddav_bob", + "password": "CarddavBobPassword1!" +} + +HTTP 200 +[Captures] +bob_token: jsonpath "$.access_token" +bob_user_id: jsonpath "$.user.id" + + +# Step 17 — Bob's book listing does NOT include Alice's book. +GET {{base_url}}/api/address-books +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].id" not contains {{share_book_id}} + + +# Step 18a — Direct GET on Alice's book: 404 (anti-enum). +GET {{base_url}}/api/address-books/{{share_book_id}}/contacts +Authorization: Bearer {{bob_token}} + +HTTP 404 + + +# Step 18b — Contact-write into Alice's book: 404. Bob has no +# grant, so authz.require(Create) rejects with NotFound. +# Body is minimal on purpose — the endpoint's wire DTO +# (`CreateContactRequest`) marks every collection field +# `#[serde(default)]`, so `full_name` alone deserialises +# fine and lets the request reach the authz gate. Any +# body-side 422 here would mask the AuthZ regression the +# step is meant to verify. +POST {{base_url}}/api/address-books/{{share_book_id}}/contacts +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ + "full_name": "Sneaky Insert" +} + +HTTP 404 + + +# Step 19 — Alice shares the book with Bob as Viewer via the +# generic ReBAC grant endpoint. `resource.type = "address_book"` +# is a first-class variant post-Round-3. +POST {{base_url}}/api/grants +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "resource": { "type": "address_book", "id": "{{share_book_id}}" }, + "role": "viewer" +} + +HTTP 201 +[Captures] +share_grant_id: jsonpath "$.grants[0].id" +[Asserts] +jsonpath "$.grants[0].role" == "viewer" +jsonpath "$.grants[0].resource.type" == "address_book" +jsonpath "$.grants[0].resource.id" == "{{share_book_id}}" + + +# Step 20 — Bob's listing now includes the book, marked readonly +# because he's not the owner. +GET {{base_url}}/api/address-books +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +jsonpath "$[?(@.id == '{{share_book_id}}')].is_readonly" == true + + +# Step 21 — Viewer bundle has no Create permission — Bob's +# contact write still 404s. Same minimal-body reasoning as +# Step 18b: keep the request valid at the wire layer so any +# rejection has to come from the AuthZ engine. +POST {{base_url}}/api/address-books/{{share_book_id}}/contacts +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ + "full_name": "Viewer Cannot Write" +} + +HTTP 404 + + +# Step 22 — Alice revokes the grant. +DELETE {{base_url}}/api/grants/{{share_grant_id}} +Authorization: Bearer {{token}} + +HTTP 204 + + +# Step 23 — Bob's listing no longer includes the book. +GET {{base_url}}/api/address-books +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].id" not contains {{share_book_id}} + + +# Step 24 — Cleanup: Alice deletes the book. +DELETE {{base_url}}/api/address-books/{{share_book_id}} +Authorization: Bearer {{token}} + +HTTP 204 + + +# ═════════════════════════════════════════════════════════════ +# Round 3 — CardDAV protocol coverage +# ═════════════════════════════════════════════════════════════ +# Verifies the CardDAV surface end-to-end: +# +# * MKCOL creates an address book via the CardDAV protocol +# (`ContactService::create_address_book` seeds an Owner +# role_grant on the caller so the engine's cache warms). +# * PROPFIND lists it in the caller's address-book home. +# * A non-member's PROPFIND doesn't include the book. +# * `POST /api/grants` with `resource.type = "address_book"` +# grants Read to the non-member. +# * The recipient's PROPFIND now includes the book. +# * Revoke → book vanishes. +# * DELETE cleans up. +# +# Book UUID is server-assigned at MKCOL time and appears in the +# PROPFIND multistatus as `/carddav//`. +# Regex-capture is unambiguous only if admin has zero +# pre-existing CardDAV books — true on the CI DB (fresh from +# `tests/webdav/run.sh`'s private Postgres), false in a +# populated dev DB. +# ============================================================= + + +# Step 25 — Alice creates a fresh book via CardDAV MKCOL. +# Empty body — `handle_mkcol` derives the display name from the +# path's last segment. +MKCOL {{base_url}}/carddav/round3-carddav-book/ +Authorization: Bearer {{token}} + +HTTP 201 + + +# Step 26 — Alice PROPFIND at Depth 1 lists her books. Capture +# the server-assigned UUID with a regex on the `` value. +PROPFIND {{base_url}}/carddav/ +Authorization: Bearer {{token}} +Depth: 1 +Content-Type: application/xml +``` + + + + + + + +``` + +HTTP 207 +[Captures] +carddav_book_id: body regex "/carddav/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/" + + +# Step 27 — Bob PROPFIND: the book UUID is NOT in his response. +# (Bob's lifecycle-provisioned books, if any, get their own +# UUIDs — no collision.) +PROPFIND {{base_url}}/carddav/ +Authorization: Bearer {{bob_token}} +Depth: 1 +Content-Type: application/xml +``` + + + + +``` + +HTTP 207 +[Asserts] +body not contains "{{carddav_book_id}}" + + +# Step 28 — Alice shares the book with Bob as Viewer via the +# generic ReBAC grant endpoint (same wire format as the +# calendar test, only the resource type differs). +POST {{base_url}}/api/grants +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "resource": { "type": "address_book", "id": "{{carddav_book_id}}" }, + "role": "viewer" +} + +HTTP 201 +[Captures] +carddav_grant_id: jsonpath "$.grants[0].id" + + +# Step 29 — Bob PROPFIND now includes the shared book. The +# CardDAV handler routes through the same +# `list_user_address_books` as the REST API, so the shared +# book flows in via the role_grants union. +PROPFIND {{base_url}}/carddav/ +Authorization: Bearer {{bob_token}} +Depth: 1 +Content-Type: application/xml +``` + + + + +``` + +HTTP 207 +[Asserts] +body contains "{{carddav_book_id}}" + + +# Step 30 — Alice revokes the grant. +DELETE {{base_url}}/api/grants/{{carddav_grant_id}} +Authorization: Bearer {{token}} + +HTTP 204 + + +# Step 31 — Bob PROPFIND no longer includes the book. +PROPFIND {{base_url}}/carddav/ +Authorization: Bearer {{bob_token}} +Depth: 1 +Content-Type: application/xml +``` + + + + +``` + +HTTP 207 +[Asserts] +body not contains "{{carddav_book_id}}" + + +# Step 32 — Cleanup: Alice deletes the book via CardDAV DELETE. +DELETE {{base_url}}/carddav/{{carddav_book_id}}/ +Authorization: Bearer {{token}} + +HTTP * +[Asserts] +status >= 200 +status < 300 diff --git a/tests/api/run.sh b/tests/api/run.sh index b90d182f..86c82567 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -161,6 +161,7 @@ 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/calendar.hurl" \ "$API_DIR/public_shares.hurl" \ "$API_DIR/permissions.hurl" \ "$API_DIR/grants.hurl" \