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/src/application/ports/carddav_ports.rs b/src/application/ports/carddav_ports.rs index cb842c14..dd589a7a 100644 --- a/src/application/ports/carddav_ports.rs +++ b/src/application/ports/carddav_ports.rs @@ -7,10 +7,100 @@ use crate::application::dtos::contact_dto::{ 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( diff --git a/src/application/services/calendar_service.rs b/src/application/services/calendar_service.rs index 762a38d9..b539325d 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,61 @@ 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?; + + // 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_shared_calendars(&self, user_id: Uuid) -> Result, DomainError> { - self.calendar_storage - .list_calendars_shared_with_user(user_id) - .await + // Kept for API compatibility (some frontends may still call + // this). Post-Round-3 the concept of "shared vs owned" is a + // client-side filter — the server hands back everything the + // caller has Read on. Callers wanting the strict "shared + // with me, not owned by me" subset filter by `owner_id != caller`. + self.list_my_calendars(user_id).await } async fn list_public_calendars( @@ -103,6 +214,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 @@ -117,30 +230,39 @@ impl CalendarUseCase for CalendarService { 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" => {} - _ => { + let uuid = self + .require_calendar_perm(calendar_id, caller_user_id, Permission::Share) + .await?; + // Map the legacy string-shaped `access_level` onto the ReBAC + // Role enum. `owner` transfers ownership — the storage side + // used to allow this; keep semantics identical here so any + // pending client keeps working. `viewer` / `editor` mirror + // the pre-Round-3 `read` / `write` behaviour. + let role = match access_level { + "read" => Role::Viewer, + "write" => Role::Editor, + "owner" => Role::Owner, + other => { return Err(DomainError::new( ErrorKind::InvalidInput, "Calendar", format!( "Invalid access level: {}. Valid values are: read, write, owner", - access_level + other ), )); } - } - self.calendar_storage - .share_calendar(calendar_id, target_user_id, access_level) - .await + }; + self.authz + .set_role( + caller_user_id, + Subject::User(target_user_id), + role, + Resource::Calendar(uuid), + None, + ) + .await?; + Ok(()) } async fn remove_calendar_sharing( @@ -149,16 +271,11 @@ impl CalendarUseCase for CalendarService { 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) + let uuid = self + .require_calendar_perm(calendar_id, caller_user_id, Permission::Share) + .await?; + self.authz + .clear_role(Subject::User(target_user_id), Resource::Calendar(uuid)) .await } @@ -167,15 +284,36 @@ impl CalendarUseCase for CalendarService { 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 + let uuid = self + .require_calendar_perm(calendar_id, user_id, Permission::Manage) + .await?; + // Translate the engine's `Grant` view into the legacy + // `(user_id, access_level_string)` tuple the handler still + // consumes. `Role → &str` uses the SQL discriminator so a + // client that expects `"read"` / `"write"` / `"owner"` + // keeps working through the transition. + let grants = self + .authz + .list_grants_on_resource(Resource::Calendar(uuid)) + .await?; + Ok(grants + .into_iter() + .filter_map(|g| { + // The legacy shape lists user subjects only. Group / + // token subjects on a calendar didn't exist pre-Round-3; + // the new listing endpoint added in Phase 4 will + // surface them properly. + let Subject::User(user_id) = g.subject else { + return None; + }; + let access = match g.role { + Role::Owner => "owner", + Role::Editor | Role::Contributor => "write", + _ => "read", + }; + Some((user_id.to_string(), access.to_string())) + }) + .collect()) } async fn create_event( @@ -183,17 +321,8 @@ impl CalendarUseCase for CalendarService { 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 +331,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 +343,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 +361,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 +382,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 +401,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 +424,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 +452,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..a14dc07e 100644 --- a/src/application/services/contact_service.rs +++ b/src/application/services/contact_service.rs @@ -10,102 +10,99 @@ 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::authorization_ports::AuthorizationEngine; +use crate::application::ports::carddav_ports::{ + AddressBookUseCase, ContactStoragePort, ContactUseCase, +}; use crate::application::ports::storage_ports::StorageUseCase; 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 +268,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 +282,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 +308,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 +338,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 +352,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 +379,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,57 +389,55 @@ 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) @@ -437,20 +453,16 @@ impl AddressBookUseCase for ContactService { 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"))?; + // AuthZ: caller must have Share on the address book. Only + // Owner grants include Share today; matches the pre-Round-3 + // owner-only rule. + self.require_address_book_perm(&id, &user_id, Permission::Share) + .await?; - 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 + // Don't allow sharing with yourself. `authz.set_role` would + // silently no-op via `ON CONFLICT UPDATE` but the earlier + // service returned a validation error to help the client + // catch a UX bug — preserve that behaviour. if dto.user_id == user_id.to_string() { return Err(DomainError::validation_error( "Cannot share an address book with yourself", @@ -459,8 +471,19 @@ impl AddressBookUseCase for ContactService { 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) + let role = if dto.can_write { + Role::Editor + } else { + Role::Viewer + }; + self.authz + .set_role( + user_id, + Subject::User(target_user_id), + role, + Resource::AddressBook(id), + None, + ) .await?; Ok(()) } @@ -473,23 +496,15 @@ impl AddressBookUseCase for ContactService { 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", - )); - } + // AuthZ: caller must have Share on the address book (same + // permission that gates share creation gates removal too). + self.require_address_book_perm(&id, &user_id, Permission::Share) + .await?; 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) + self.authz + .clear_role(Subject::User(target_user_id), Resource::AddressBook(id)) .await?; Ok(()) } @@ -502,24 +517,31 @@ 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 view address book shares", - )); - } - - let shares = self - .address_book_repository - .get_address_book_shares(&id) + // AuthZ: caller must have Manage on the address book. Only + // Owner grants include Manage — matches the pre-Round-3 + // owner-only rule for the shares listing. + self.require_address_book_perm(&id, &user_id, Permission::Manage) .await?; - Ok(shares) + + let grants = self + .authz + .list_grants_on_resource(Resource::AddressBook(id)) + .await?; + // Translate the engine's `Grant` view into the legacy + // `(user_id_str, can_write_bool)` tuple the handler still + // consumes. Non-user subjects (groups / tokens) are dropped + // from this listing — a phase-4 endpoint will surface them + // properly. + Ok(grants + .into_iter() + .filter_map(|g| { + let Subject::User(uid) = g.subject else { + return None; + }; + let can_write = matches!(g.role, Role::Editor | Role::Contributor | Role::Owner); + Some((uid.to_string(), can_write)) + }) + .collect()) } } @@ -529,12 +551,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 +616,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 +630,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 +647,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 +661,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 +669,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 +754,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 +766,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 +790,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 +812,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 +829,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 +851,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 +881,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 +899,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 +920,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 +940,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 +950,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 +974,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 +1001,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 +1024,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 +1053,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 +1079,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 +1105,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 +1131,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 +1153,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?; 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/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/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,