feat(calendar,addressbook): migrate share to authz engine
migrate DB entries to authz engine
and wire authz engine to caldav and carddav
This commit is contained in:
@@ -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$;
|
||||||
@@ -7,10 +7,100 @@ use crate::application::dtos::contact_dto::{
|
|||||||
GroupMembershipDto, UpdateContactDto, UpdateContactGroupDto,
|
GroupMembershipDto, UpdateContactDto, UpdateContactGroupDto,
|
||||||
};
|
};
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
|
use crate::domain::entities::contact::{AddressBook, Contact, ContactGroup};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub type CardDavRepositoryError = DomainError;
|
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<AddressBook, DomainError>;
|
||||||
|
async fn update_address_book(
|
||||||
|
&self,
|
||||||
|
address_book: AddressBook,
|
||||||
|
) -> Result<AddressBook, DomainError>;
|
||||||
|
async fn delete_address_book(&self, id: &Uuid) -> Result<(), DomainError>;
|
||||||
|
async fn get_address_book_by_id(&self, id: &Uuid) -> Result<Option<AddressBook>, DomainError>;
|
||||||
|
async fn get_public_address_books(&self) -> Result<Vec<AddressBook>, DomainError>;
|
||||||
|
|
||||||
|
// ── Contacts ─────────────────────────────────────────────────
|
||||||
|
async fn create_contact(&self, contact: Contact) -> Result<Contact, DomainError>;
|
||||||
|
async fn update_contact(&self, contact: Contact) -> Result<Contact, DomainError>;
|
||||||
|
async fn delete_contact(&self, id: &Uuid) -> Result<(), DomainError>;
|
||||||
|
async fn get_contact_by_id(&self, id: &Uuid) -> Result<Option<Contact>, 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<Option<Contact>, 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<Vec<Contact>, DomainError>;
|
||||||
|
async fn get_contacts_by_address_book(
|
||||||
|
&self,
|
||||||
|
address_book_id: &Uuid,
|
||||||
|
) -> Result<Vec<Contact>, DomainError>;
|
||||||
|
async fn get_contacts_by_address_book_paginated(
|
||||||
|
&self,
|
||||||
|
address_book_id: &Uuid,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<Contact>, DomainError>;
|
||||||
|
async fn search_contacts(
|
||||||
|
&self,
|
||||||
|
address_book_id: &Uuid,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<Vec<Contact>, DomainError>;
|
||||||
|
|
||||||
|
// ── Contact groups ───────────────────────────────────────────
|
||||||
|
async fn create_group(&self, group: ContactGroup) -> Result<ContactGroup, DomainError>;
|
||||||
|
async fn update_group(&self, group: ContactGroup) -> Result<ContactGroup, DomainError>;
|
||||||
|
async fn delete_group(&self, id: &Uuid) -> Result<(), DomainError>;
|
||||||
|
async fn get_group_by_id(&self, id: &Uuid) -> Result<Option<ContactGroup>, DomainError>;
|
||||||
|
async fn get_groups_by_address_book(
|
||||||
|
&self,
|
||||||
|
address_book_id: &Uuid,
|
||||||
|
) -> Result<Vec<ContactGroup>, 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<Vec<Contact>, DomainError>;
|
||||||
|
async fn get_groups_for_contact(
|
||||||
|
&self,
|
||||||
|
contact_id: &Uuid,
|
||||||
|
) -> Result<Vec<ContactGroup>, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
pub trait AddressBookUseCase: Send + Sync + 'static {
|
pub trait AddressBookUseCase: Send + Sync + 'static {
|
||||||
// Address Book operations
|
// Address Book operations
|
||||||
async fn create_address_book(
|
async fn create_address_book(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -6,17 +7,80 @@ use crate::application::dtos::calendar_dto::{
|
|||||||
CalendarDto, CalendarEventDto, CreateCalendarDto, CreateEventDto, CreateEventICalDto,
|
CalendarDto, CalendarEventDto, CreateCalendarDto, CreateEventDto, CreateEventICalDto,
|
||||||
UpdateCalendarDto, UpdateEventDto,
|
UpdateCalendarDto, UpdateEventDto,
|
||||||
};
|
};
|
||||||
|
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||||
use crate::application::ports::calendar_ports::{CalendarStoragePort, CalendarUseCase};
|
use crate::application::ports::calendar_ports::{CalendarStoragePort, CalendarUseCase};
|
||||||
use crate::common::errors::{DomainError, ErrorKind};
|
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::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 {
|
pub struct CalendarService {
|
||||||
calendar_storage: Arc<CalendarStorageAdapter>,
|
calendar_storage: Arc<CalendarStorageAdapter>,
|
||||||
|
/// 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<PgAclEngine>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CalendarService {
|
impl CalendarService {
|
||||||
pub fn new(calendar_storage: Arc<CalendarStorageAdapter>) -> Self {
|
pub fn new(calendar_storage: Arc<CalendarStorageAdapter>, authz: Arc<PgAclEngine>) -> Self {
|
||||||
Self { calendar_storage }
|
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<Uuid, DomainError> {
|
||||||
|
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<bool, DomainError> {
|
||||||
|
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,
|
calendar: CreateCalendarDto,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<CalendarDto, DomainError> {
|
) -> Result<CalendarDto, DomainError> {
|
||||||
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)
|
.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(
|
async fn update_calendar(
|
||||||
@@ -37,35 +122,28 @@ impl CalendarUseCase for CalendarService {
|
|||||||
update: UpdateCalendarDto,
|
update: UpdateCalendarDto,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<CalendarDto, DomainError> {
|
) -> Result<CalendarDto, DomainError> {
|
||||||
let has_access = self
|
self.require_calendar_perm(calendar_id, user_id, Permission::Update)
|
||||||
.calendar_storage
|
|
||||||
.check_calendar_access(calendar_id, user_id)
|
|
||||||
.await?;
|
.await?;
|
||||||
if !has_access {
|
|
||||||
return Err(DomainError::new(
|
|
||||||
ErrorKind::AccessDenied,
|
|
||||||
"Calendar",
|
|
||||||
"You don't have permission to update this calendar",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.calendar_storage
|
self.calendar_storage
|
||||||
.update_calendar(calendar_id, update)
|
.update_calendar(calendar_id, update)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_calendar(&self, calendar_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
async fn delete_calendar(&self, calendar_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
||||||
let has_access = self
|
let uuid = self
|
||||||
.calendar_storage
|
.require_calendar_perm(calendar_id, user_id, Permission::Delete)
|
||||||
.check_calendar_access(calendar_id, user_id)
|
|
||||||
.await?;
|
.await?;
|
||||||
if !has_access {
|
self.calendar_storage.delete_calendar(calendar_id).await?;
|
||||||
return Err(DomainError::new(
|
// Wipe every grant on this calendar so a re-used UUID (impossible
|
||||||
ErrorKind::AccessDenied,
|
// today but cheap to defend against) doesn't inherit stale ACLs.
|
||||||
"Calendar",
|
// The storage DELETE won't cascade to `storage.role_grants` — the
|
||||||
"You don't have permission to delete this calendar",
|
// legacy `caldav.calendar_shares` had an FK, `role_grants`
|
||||||
));
|
// doesn't (it's cross-schema).
|
||||||
}
|
let _ = self
|
||||||
self.calendar_storage.delete_calendar(calendar_id).await
|
.authz
|
||||||
|
.revoke_all_for_resource(Resource::Calendar(uuid))
|
||||||
|
.await;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_calendar(
|
async fn get_calendar(
|
||||||
@@ -74,28 +152,61 @@ impl CalendarUseCase for CalendarService {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<CalendarDto, DomainError> {
|
) -> Result<CalendarDto, DomainError> {
|
||||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||||
let has_access = self
|
// Public-calendar bypass: anonymous-ish read. `check` returns
|
||||||
.calendar_storage
|
// bool (no throw); combine with the public flag before
|
||||||
.check_calendar_access(calendar_id, user_id)
|
// deciding.
|
||||||
.await?;
|
let allowed = calendar.is_public
|
||||||
if !has_access && !calendar.is_public {
|
|| self
|
||||||
return Err(DomainError::new(
|
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||||
ErrorKind::AccessDenied,
|
.await?;
|
||||||
"Calendar",
|
if !allowed {
|
||||||
"You don't have permission to view this calendar",
|
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Ok(calendar)
|
Ok(calendar)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_my_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError> {
|
async fn list_my_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, 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<Uuid> = 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<Vec<CalendarDto>, DomainError> {
|
async fn list_shared_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError> {
|
||||||
self.calendar_storage
|
// Kept for API compatibility (some frontends may still call
|
||||||
.list_calendars_shared_with_user(user_id)
|
// this). Post-Round-3 the concept of "shared vs owned" is a
|
||||||
.await
|
// 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(
|
async fn list_public_calendars(
|
||||||
@@ -103,6 +214,8 @@ impl CalendarUseCase for CalendarService {
|
|||||||
limit: Option<i64>,
|
limit: Option<i64>,
|
||||||
offset: Option<i64>,
|
offset: Option<i64>,
|
||||||
) -> Result<Vec<CalendarDto>, DomainError> {
|
) -> Result<Vec<CalendarDto>, DomainError> {
|
||||||
|
// No caller gate: public listing by definition. Storage
|
||||||
|
// filters on `is_public = true`.
|
||||||
let limit = limit.unwrap_or(100);
|
let limit = limit.unwrap_or(100);
|
||||||
let offset = offset.unwrap_or(0);
|
let offset = offset.unwrap_or(0);
|
||||||
self.calendar_storage
|
self.calendar_storage
|
||||||
@@ -117,30 +230,39 @@ impl CalendarUseCase for CalendarService {
|
|||||||
access_level: &str,
|
access_level: &str,
|
||||||
caller_user_id: Uuid,
|
caller_user_id: Uuid,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
let uuid = self
|
||||||
if calendar.owner_id != caller_user_id.to_string() {
|
.require_calendar_perm(calendar_id, caller_user_id, Permission::Share)
|
||||||
return Err(DomainError::new(
|
.await?;
|
||||||
ErrorKind::AccessDenied,
|
// Map the legacy string-shaped `access_level` onto the ReBAC
|
||||||
"Calendar",
|
// Role enum. `owner` transfers ownership — the storage side
|
||||||
"Only the calendar owner can change sharing settings",
|
// used to allow this; keep semantics identical here so any
|
||||||
));
|
// pending client keeps working. `viewer` / `editor` mirror
|
||||||
}
|
// the pre-Round-3 `read` / `write` behaviour.
|
||||||
match access_level {
|
let role = match access_level {
|
||||||
"read" | "write" | "owner" => {}
|
"read" => Role::Viewer,
|
||||||
_ => {
|
"write" => Role::Editor,
|
||||||
|
"owner" => Role::Owner,
|
||||||
|
other => {
|
||||||
return Err(DomainError::new(
|
return Err(DomainError::new(
|
||||||
ErrorKind::InvalidInput,
|
ErrorKind::InvalidInput,
|
||||||
"Calendar",
|
"Calendar",
|
||||||
format!(
|
format!(
|
||||||
"Invalid access level: {}. Valid values are: read, write, owner",
|
"Invalid access level: {}. Valid values are: read, write, owner",
|
||||||
access_level
|
other
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
self.calendar_storage
|
self.authz
|
||||||
.share_calendar(calendar_id, target_user_id, access_level)
|
.set_role(
|
||||||
.await
|
caller_user_id,
|
||||||
|
Subject::User(target_user_id),
|
||||||
|
role,
|
||||||
|
Resource::Calendar(uuid),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn remove_calendar_sharing(
|
async fn remove_calendar_sharing(
|
||||||
@@ -149,16 +271,11 @@ impl CalendarUseCase for CalendarService {
|
|||||||
target_user_id: Uuid,
|
target_user_id: Uuid,
|
||||||
caller_user_id: Uuid,
|
caller_user_id: Uuid,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
let uuid = self
|
||||||
if calendar.owner_id != caller_user_id.to_string() {
|
.require_calendar_perm(calendar_id, caller_user_id, Permission::Share)
|
||||||
return Err(DomainError::new(
|
.await?;
|
||||||
ErrorKind::AccessDenied,
|
self.authz
|
||||||
"Calendar",
|
.clear_role(Subject::User(target_user_id), Resource::Calendar(uuid))
|
||||||
"Only the calendar owner can change sharing settings",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.calendar_storage
|
|
||||||
.remove_calendar_sharing(calendar_id, target_user_id)
|
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,15 +284,36 @@ impl CalendarUseCase for CalendarService {
|
|||||||
calendar_id: &str,
|
calendar_id: &str,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<Vec<(String, String)>, DomainError> {
|
) -> Result<Vec<(String, String)>, DomainError> {
|
||||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
let uuid = self
|
||||||
if calendar.owner_id != user_id.to_string() {
|
.require_calendar_perm(calendar_id, user_id, Permission::Manage)
|
||||||
return Err(DomainError::new(
|
.await?;
|
||||||
ErrorKind::AccessDenied,
|
// Translate the engine's `Grant` view into the legacy
|
||||||
"Calendar",
|
// `(user_id, access_level_string)` tuple the handler still
|
||||||
"Only the calendar owner can view sharing settings",
|
// consumes. `Role → &str` uses the SQL discriminator so a
|
||||||
));
|
// client that expects `"read"` / `"write"` / `"owner"`
|
||||||
}
|
// keeps working through the transition.
|
||||||
self.calendar_storage.get_calendar_shares(calendar_id).await
|
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(
|
async fn create_event(
|
||||||
@@ -183,17 +321,8 @@ impl CalendarUseCase for CalendarService {
|
|||||||
event: CreateEventDto,
|
event: CreateEventDto,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<CalendarEventDto, DomainError> {
|
) -> Result<CalendarEventDto, DomainError> {
|
||||||
let has_access = self
|
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Create)
|
||||||
.calendar_storage
|
|
||||||
.check_calendar_access(&event.calendar_id, user_id)
|
|
||||||
.await?;
|
.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
|
self.calendar_storage.create_event(event).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,17 +331,8 @@ impl CalendarUseCase for CalendarService {
|
|||||||
event: CreateEventICalDto,
|
event: CreateEventICalDto,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<CalendarEventDto, DomainError> {
|
) -> Result<CalendarEventDto, DomainError> {
|
||||||
let has_access = self
|
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Create)
|
||||||
.calendar_storage
|
|
||||||
.check_calendar_access(&event.calendar_id, user_id)
|
|
||||||
.await?;
|
.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
|
self.calendar_storage.create_event_from_ical(event).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,33 +343,15 @@ impl CalendarUseCase for CalendarService {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<CalendarEventDto, DomainError> {
|
) -> Result<CalendarEventDto, DomainError> {
|
||||||
let event = self.calendar_storage.get_event(event_id).await?;
|
let event = self.calendar_storage.get_event(event_id).await?;
|
||||||
let has_access = self
|
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Update)
|
||||||
.calendar_storage
|
|
||||||
.check_calendar_access(&event.calendar_id, user_id)
|
|
||||||
.await?;
|
.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
|
self.calendar_storage.update_event(event_id, update).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_event(&self, event_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
async fn delete_event(&self, event_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
||||||
let event = self.calendar_storage.get_event(event_id).await?;
|
let event = self.calendar_storage.get_event(event_id).await?;
|
||||||
let has_access = self
|
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Delete)
|
||||||
.calendar_storage
|
|
||||||
.check_calendar_access(&event.calendar_id, user_id)
|
|
||||||
.await?;
|
.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
|
self.calendar_storage.delete_event(event_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,20 +361,17 @@ impl CalendarUseCase for CalendarService {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<CalendarEventDto, DomainError> {
|
) -> Result<CalendarEventDto, DomainError> {
|
||||||
let event = self.calendar_storage.get_event(event_id).await?;
|
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
|
let calendar = self
|
||||||
.calendar_storage
|
.calendar_storage
|
||||||
.get_calendar(&event.calendar_id)
|
.get_calendar(&event.calendar_id)
|
||||||
.await?;
|
.await?;
|
||||||
if !has_access && !calendar.is_public {
|
// Same public-calendar bypass as `get_calendar`.
|
||||||
return Err(DomainError::new(
|
let allowed = calendar.is_public
|
||||||
ErrorKind::AccessDenied,
|
|| self
|
||||||
"Calendar",
|
.has_calendar_perm(&event.calendar_id, user_id, Permission::Read)
|
||||||
"You don't have permission to view events in this calendar",
|
.await?;
|
||||||
));
|
if !allowed {
|
||||||
|
return Err(DomainError::not_found("Event", event_id));
|
||||||
}
|
}
|
||||||
Ok(event)
|
Ok(event)
|
||||||
}
|
}
|
||||||
@@ -283,17 +382,13 @@ impl CalendarUseCase for CalendarService {
|
|||||||
ical_uid: &str,
|
ical_uid: &str,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<Option<CalendarEventDto>, DomainError> {
|
) -> Result<Option<CalendarEventDto>, 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?;
|
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||||
if !has_access && !calendar.is_public {
|
let allowed = calendar.is_public
|
||||||
return Err(DomainError::new(
|
|| self
|
||||||
ErrorKind::AccessDenied,
|
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||||
"Calendar",
|
.await?;
|
||||||
"You don't have permission to view events in this calendar",
|
if !allowed {
|
||||||
));
|
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||||
}
|
}
|
||||||
self.calendar_storage
|
self.calendar_storage
|
||||||
.find_event_by_ical_uid(calendar_id, ical_uid)
|
.find_event_by_ical_uid(calendar_id, ical_uid)
|
||||||
@@ -306,17 +401,13 @@ impl CalendarUseCase for CalendarService {
|
|||||||
ical_uids: &[String],
|
ical_uids: &[String],
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
) -> Result<Vec<CalendarEventDto>, 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?;
|
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||||
if !has_access && !calendar.is_public {
|
let allowed = calendar.is_public
|
||||||
return Err(DomainError::new(
|
|| self
|
||||||
ErrorKind::AccessDenied,
|
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||||
"Calendar",
|
.await?;
|
||||||
"You don't have permission to view events in this calendar",
|
if !allowed {
|
||||||
));
|
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||||
}
|
}
|
||||||
if ical_uids.is_empty() {
|
if ical_uids.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -333,17 +424,13 @@ impl CalendarUseCase for CalendarService {
|
|||||||
offset: Option<i64>,
|
offset: Option<i64>,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
) -> Result<Vec<CalendarEventDto>, 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?;
|
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||||
if !has_access && !calendar.is_public {
|
let allowed = calendar.is_public
|
||||||
return Err(DomainError::new(
|
|| self
|
||||||
ErrorKind::AccessDenied,
|
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||||
"Calendar",
|
.await?;
|
||||||
"You don't have permission to view events in this calendar",
|
if !allowed {
|
||||||
));
|
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||||
}
|
}
|
||||||
if limit.is_some() || offset.is_some() {
|
if limit.is_some() || offset.is_some() {
|
||||||
let limit = limit.unwrap_or(100);
|
let limit = limit.unwrap_or(100);
|
||||||
@@ -365,17 +452,13 @@ impl CalendarUseCase for CalendarService {
|
|||||||
end: DateTime<Utc>,
|
end: DateTime<Utc>,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
) -> Result<Vec<CalendarEventDto>, 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?;
|
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||||
if !has_access && !calendar.is_public {
|
let allowed = calendar.is_public
|
||||||
return Err(DomainError::new(
|
|| self
|
||||||
ErrorKind::AccessDenied,
|
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||||
"Calendar",
|
.await?;
|
||||||
"You don't have permission to view events in this calendar",
|
if !allowed {
|
||||||
));
|
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||||
}
|
}
|
||||||
self.calendar_storage
|
self.calendar_storage
|
||||||
.get_events_in_time_range(calendar_id, &start, &end)
|
.get_events_in_time_range(calendar_id, &start, &end)
|
||||||
|
|||||||
@@ -10,102 +10,99 @@ use crate::application::dtos::contact_dto::{
|
|||||||
ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, CreateContactVCardDto,
|
ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, CreateContactVCardDto,
|
||||||
GroupMembershipDto, UpdateContactDto, UpdateContactGroupDto,
|
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::application::ports::storage_ports::StorageUseCase;
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
use crate::domain::entities::contact::{Address, AddressBook, Contact, ContactGroup, Email, Phone};
|
use crate::domain::entities::contact::{Address, AddressBook, Contact, ContactGroup, Email, Phone};
|
||||||
use crate::domain::repositories::address_book_repository::AddressBookRepository;
|
use crate::domain::services::authorization::{Permission, Resource, Role, Subject};
|
||||||
use crate::domain::repositories::contact_repository::{ContactGroupRepository, ContactRepository};
|
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
|
||||||
use crate::infrastructure::repositories::pg::AddressBookPgRepository;
|
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||||
use crate::infrastructure::repositories::pg::ContactGroupPgRepository;
|
|
||||||
use crate::infrastructure::repositories::pg::ContactPgRepository;
|
|
||||||
|
|
||||||
|
/// 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 {
|
pub struct ContactService {
|
||||||
address_book_repository: Arc<AddressBookPgRepository>,
|
/// Storage port — bundles the three CardDAV PG repositories
|
||||||
contact_repository: Arc<ContactPgRepository>,
|
/// (address_book, contact, contact_group) behind
|
||||||
contact_group_repository: Arc<ContactGroupPgRepository>,
|
/// `ContactStoragePort`. Symmetric with `CalendarService`'s
|
||||||
|
/// hold on `CalendarStorageAdapter`.
|
||||||
|
contact_storage: Arc<ContactStorageAdapter>,
|
||||||
|
/// 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<PgAclEngine>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ContactService {
|
impl ContactService {
|
||||||
pub fn new(
|
pub fn new(contact_storage: Arc<ContactStorageAdapter>, authz: Arc<PgAclEngine>) -> Self {
|
||||||
address_book_repository: Arc<AddressBookPgRepository>,
|
|
||||||
contact_repository: Arc<ContactPgRepository>,
|
|
||||||
contact_group_repository: Arc<ContactGroupPgRepository>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
address_book_repository,
|
contact_storage,
|
||||||
contact_repository,
|
authz,
|
||||||
contact_group_repository,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper methods
|
/// Enforce `permission` on `Resource::AddressBook(uuid)` and
|
||||||
async fn check_address_book_access(
|
/// 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,
|
&self,
|
||||||
address_book_id: &Uuid,
|
address_book_id: &Uuid,
|
||||||
user_id: &Uuid,
|
caller_id: &Uuid,
|
||||||
|
permission: Permission,
|
||||||
) -> Result<AddressBook, DomainError> {
|
) -> Result<AddressBook, DomainError> {
|
||||||
let address_book = self
|
self.authz
|
||||||
.address_book_repository
|
.require(
|
||||||
|
Subject::User(*caller_id),
|
||||||
|
permission,
|
||||||
|
Resource::AddressBook(*address_book_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
self.contact_storage
|
||||||
.get_address_book_by_id(address_book_id)
|
.get_address_book_by_id(address_book_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::not_found("Address book", "not found"))?;
|
.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",
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
&self,
|
||||||
address_book_id: &Uuid,
|
address_book_id: &Uuid,
|
||||||
user_id: &Uuid,
|
caller_id: &Uuid,
|
||||||
) -> Result<AddressBook, DomainError> {
|
) -> Result<AddressBook, DomainError> {
|
||||||
let address_book = self
|
let book = self
|
||||||
.address_book_repository
|
.contact_storage
|
||||||
.get_address_book_by_id(address_book_id)
|
.get_address_book_by_id(address_book_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::not_found("Address book", "not found"))?;
|
.ok_or_else(|| DomainError::not_found("Address book", "not found"))?;
|
||||||
|
if book.is_public() {
|
||||||
// Check if user is owner
|
return Ok(book);
|
||||||
if address_book.owner_id() == user_id.to_string() {
|
|
||||||
return Ok(address_book);
|
|
||||||
}
|
}
|
||||||
|
self.authz
|
||||||
// Check if address book is shared with user with write access
|
.require(
|
||||||
let shares = self
|
Subject::User(*caller_id),
|
||||||
.address_book_repository
|
Permission::Read,
|
||||||
.get_address_book_shares(address_book_id)
|
Resource::AddressBook(*address_book_id),
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if shares
|
Ok(book)
|
||||||
.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",
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_vcard(&self, vcard_data: &str) -> Result<Contact, DomainError> {
|
fn parse_vcard(&self, vcard_data: &str) -> Result<Contact, DomainError> {
|
||||||
@@ -271,6 +268,11 @@ impl AddressBookUseCase for ContactService {
|
|||||||
&self,
|
&self,
|
||||||
dto: CreateAddressBookDto,
|
dto: CreateAddressBookDto,
|
||||||
) -> Result<AddressBookDto, DomainError> {
|
) -> Result<AddressBookDto, DomainError> {
|
||||||
|
// 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(
|
let address_book = AddressBook::new(
|
||||||
dto.name,
|
dto.name,
|
||||||
dto.owner_id,
|
dto.owner_id,
|
||||||
@@ -280,9 +282,21 @@ impl AddressBookUseCase for ContactService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let created_address_book = self
|
let created_address_book = self
|
||||||
.address_book_repository
|
.contact_storage
|
||||||
.create_address_book(address_book)
|
.create_address_book(address_book)
|
||||||
.await?;
|
.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))
|
Ok(AddressBookDto::from(created_address_book))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,13 +308,15 @@ impl AddressBookUseCase for ContactService {
|
|||||||
let id = Uuid::parse_str(address_book_id)
|
let id = Uuid::parse_str(address_book_id)
|
||||||
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
.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
|
let address_book = self
|
||||||
.check_address_book_write_access(
|
.require_address_book_perm(&id, &caller_id, Permission::Update)
|
||||||
&id,
|
|
||||||
&Uuid::parse_str(&update.user_id)
|
|
||||||
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?,
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Apply updates
|
// Apply updates
|
||||||
@@ -322,7 +338,7 @@ impl AddressBookUseCase for ContactService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let result = self
|
let result = self
|
||||||
.address_book_repository
|
.contact_storage
|
||||||
.update_address_book(updated_address_book)
|
.update_address_book(updated_address_book)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(AddressBookDto::from(result))
|
Ok(AddressBookDto::from(result))
|
||||||
@@ -336,22 +352,22 @@ impl AddressBookUseCase for ContactService {
|
|||||||
let id = Uuid::parse_str(address_book_id)
|
let id = Uuid::parse_str(address_book_id)
|
||||||
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||||
|
|
||||||
// Verify that the user is the owner of the address book
|
// AuthZ: caller must have Delete on the address book. Only
|
||||||
let address_book = self
|
// Owner grants include Delete in their bundle today, matching
|
||||||
.address_book_repository
|
// the pre-Round-3 owner-only rule; if `Contributor` ever grows
|
||||||
.get_address_book_by_id(&id)
|
// a Delete bundle it inherits the ability here for free.
|
||||||
.await?
|
self.require_address_book_perm(&id, &user_id, Permission::Delete)
|
||||||
.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)
|
|
||||||
.await?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,7 +379,9 @@ impl AddressBookUseCase for ContactService {
|
|||||||
let id = Uuid::parse_str(address_book_id)
|
let id = Uuid::parse_str(address_book_id)
|
||||||
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
.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))
|
Ok(AddressBookDto::from(address_book))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,57 +389,55 @@ impl AddressBookUseCase for ContactService {
|
|||||||
&self,
|
&self,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<Vec<AddressBookDto>, DomainError> {
|
) -> Result<Vec<AddressBookDto>, DomainError> {
|
||||||
// Get address books owned by the user
|
// Post-Round-3: every address book the caller has any grant on
|
||||||
let owned_address_books = self
|
// (owned + shared) comes from a single role_grants lookup.
|
||||||
.address_book_repository
|
// Public address books stay a separate query — they don't
|
||||||
.get_address_books_by_owner(user_id)
|
// 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?;
|
.await?;
|
||||||
|
let book_ids: std::collections::HashSet<Uuid> = 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();
|
let mut address_book_map = std::collections::HashMap::new();
|
||||||
|
|
||||||
for address_book in owned_address_books {
|
for id in book_ids {
|
||||||
address_book_map.insert(*address_book.id(), address_book);
|
// 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 {
|
||||||
for address_book in shared_address_books {
|
address_book_map.insert(*book.id(), book);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let address_books: Vec<AddressBookDto> = address_book_map
|
// Public address books surface for every authenticated caller
|
||||||
.values()
|
// — same "internal-Read-for-everyone" semantics as
|
||||||
.cloned()
|
// `is_public` on calendars.
|
||||||
.map(AddressBookDto::from)
|
let public_address_books = self.contact_storage.get_public_address_books().await?;
|
||||||
.collect();
|
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<Vec<AddressBookDto>, DomainError> {
|
async fn list_public_address_books(&self) -> Result<Vec<AddressBookDto>, DomainError> {
|
||||||
let address_books = self
|
let address_books = self.contact_storage.get_public_address_books().await?;
|
||||||
.address_book_repository
|
|
||||||
.get_public_address_books()
|
|
||||||
.await?;
|
|
||||||
let dtos: Vec<AddressBookDto> = address_books
|
let dtos: Vec<AddressBookDto> = address_books
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(AddressBookDto::from)
|
.map(AddressBookDto::from)
|
||||||
@@ -437,20 +453,16 @@ impl AddressBookUseCase for ContactService {
|
|||||||
let id = Uuid::parse_str(&dto.address_book_id)
|
let id = Uuid::parse_str(&dto.address_book_id)
|
||||||
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||||
|
|
||||||
// Verify that the user is the owner of the address book
|
// AuthZ: caller must have Share on the address book. Only
|
||||||
let address_book = self
|
// Owner grants include Share today; matches the pre-Round-3
|
||||||
.address_book_repository
|
// owner-only rule.
|
||||||
.get_address_book_by_id(&id)
|
self.require_address_book_perm(&id, &user_id, Permission::Share)
|
||||||
.await?
|
.await?;
|
||||||
.ok_or_else(|| DomainError::not_found("Address book", "not found"))?;
|
|
||||||
|
|
||||||
if address_book.owner_id() != user_id.to_string() {
|
// Don't allow sharing with yourself. `authz.set_role` would
|
||||||
return Err(DomainError::unauthorized(
|
// silently no-op via `ON CONFLICT UPDATE` but the earlier
|
||||||
"Only the owner can share an address book",
|
// service returned a validation error to help the client
|
||||||
));
|
// catch a UX bug — preserve that behaviour.
|
||||||
}
|
|
||||||
|
|
||||||
// Don't allow sharing with yourself
|
|
||||||
if dto.user_id == user_id.to_string() {
|
if dto.user_id == user_id.to_string() {
|
||||||
return Err(DomainError::validation_error(
|
return Err(DomainError::validation_error(
|
||||||
"Cannot share an address book with yourself",
|
"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)
|
let target_user_id = Uuid::parse_str(&dto.user_id)
|
||||||
.map_err(|_| DomainError::validation_error("Invalid target user ID format"))?;
|
.map_err(|_| DomainError::validation_error("Invalid target user ID format"))?;
|
||||||
self.address_book_repository
|
let role = if dto.can_write {
|
||||||
.share_address_book(&id, target_user_id, 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?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -473,23 +496,15 @@ impl AddressBookUseCase for ContactService {
|
|||||||
let id = Uuid::parse_str(&dto.address_book_id)
|
let id = Uuid::parse_str(&dto.address_book_id)
|
||||||
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||||
|
|
||||||
// Verify that the user is the owner of the address book
|
// AuthZ: caller must have Share on the address book (same
|
||||||
let address_book = self
|
// permission that gates share creation gates removal too).
|
||||||
.address_book_repository
|
self.require_address_book_perm(&id, &user_id, Permission::Share)
|
||||||
.get_address_book_by_id(&id)
|
.await?;
|
||||||
.await?
|
|
||||||
.ok_or_else(|| DomainError::not_found("Address book", "not found"))?;
|
|
||||||
|
|
||||||
if address_book.owner_id() != user_id.to_string() {
|
|
||||||
return Err(DomainError::unauthorized(
|
|
||||||
"Only the owner can unshare an address book",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let target_user_id = Uuid::parse_str(&dto.user_id)
|
let target_user_id = Uuid::parse_str(&dto.user_id)
|
||||||
.map_err(|_| DomainError::validation_error("Invalid target user ID format"))?;
|
.map_err(|_| DomainError::validation_error("Invalid target user ID format"))?;
|
||||||
self.address_book_repository
|
self.authz
|
||||||
.unshare_address_book(&id, target_user_id)
|
.clear_role(Subject::User(target_user_id), Resource::AddressBook(id))
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -502,24 +517,31 @@ impl AddressBookUseCase for ContactService {
|
|||||||
let id = Uuid::parse_str(address_book_id)
|
let id = Uuid::parse_str(address_book_id)
|
||||||
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||||
|
|
||||||
// Verify that the user is the owner of the address book
|
// AuthZ: caller must have Manage on the address book. Only
|
||||||
let address_book = self
|
// Owner grants include Manage — matches the pre-Round-3
|
||||||
.address_book_repository
|
// owner-only rule for the shares listing.
|
||||||
.get_address_book_by_id(&id)
|
self.require_address_book_perm(&id, &user_id, Permission::Manage)
|
||||||
.await?
|
|
||||||
.ok_or_else(|| DomainError::not_found("Address book", "not found"))?;
|
|
||||||
|
|
||||||
if address_book.owner_id() != user_id.to_string() {
|
|
||||||
return Err(DomainError::unauthorized(
|
|
||||||
"Only the owner can view address book shares",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let shares = self
|
|
||||||
.address_book_repository
|
|
||||||
.get_address_book_shares(&id)
|
|
||||||
.await?;
|
.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"))?;
|
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||||
|
|
||||||
// Check if user has write access to the address book
|
// Check if user has write access to the address book
|
||||||
self.check_address_book_write_access(
|
let caller_id = Uuid::parse_str(&dto.user_id)
|
||||||
&address_book_id,
|
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?;
|
||||||
&Uuid::parse_str(&dto.user_id)
|
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Update)
|
||||||
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?,
|
.await?;
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Convert DTOs to domain entities
|
// Convert DTOs to domain entities
|
||||||
let email: Vec<Email> = dto
|
let email: Vec<Email> = dto
|
||||||
@@ -596,7 +616,7 @@ impl ContactUseCase for ContactService {
|
|||||||
|
|
||||||
// Create the contact
|
// Create the contact
|
||||||
let created_contact = self
|
let created_contact = self
|
||||||
.contact_repository
|
.contact_storage
|
||||||
.create_contact(contact_with_vcard)
|
.create_contact(contact_with_vcard)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(ContactDto::from(created_contact))
|
Ok(ContactDto::from(created_contact))
|
||||||
@@ -610,12 +630,10 @@ impl ContactUseCase for ContactService {
|
|||||||
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||||
|
|
||||||
// Check if user has write access to the address book
|
// Check if user has write access to the address book
|
||||||
self.check_address_book_write_access(
|
let caller_id = Uuid::parse_str(&dto.user_id)
|
||||||
&address_book_id,
|
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?;
|
||||||
&Uuid::parse_str(&dto.user_id)
|
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Update)
|
||||||
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?,
|
.await?;
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Parse vCard data
|
// Parse vCard data
|
||||||
let mut contact = self.parse_vcard(&dto.vcard)?;
|
let mut contact = self.parse_vcard(&dto.vcard)?;
|
||||||
@@ -629,7 +647,7 @@ impl ContactUseCase for ContactService {
|
|||||||
contact.set_updated_at(now);
|
contact.set_updated_at(now);
|
||||||
|
|
||||||
// Create the contact
|
// 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))
|
Ok(ContactDto::from(created_contact))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,7 +661,7 @@ impl ContactUseCase for ContactService {
|
|||||||
|
|
||||||
// Get the current contact
|
// Get the current contact
|
||||||
let contact = self
|
let contact = self
|
||||||
.contact_repository
|
.contact_storage
|
||||||
.get_contact_by_id(&id)
|
.get_contact_by_id(&id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
|
.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
|
// Check if user has write access to the address book
|
||||||
let update_user_id = Uuid::parse_str(&update.user_id)
|
let update_user_id = Uuid::parse_str(&update.user_id)
|
||||||
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?;
|
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?;
|
||||||
self.check_address_book_write_access(contact.address_book_id(), &update_user_id)
|
self.require_address_book_perm(
|
||||||
.await?;
|
contact.address_book_id(),
|
||||||
|
&update_user_id,
|
||||||
|
Permission::Update,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Destructure contact into owned parts for updates
|
// Destructure contact into owned parts for updates
|
||||||
let parts = contact.into_parts();
|
let parts = contact.into_parts();
|
||||||
@@ -732,7 +754,7 @@ impl ContactUseCase for ContactService {
|
|||||||
|
|
||||||
// Update the contact
|
// Update the contact
|
||||||
let result = self
|
let result = self
|
||||||
.contact_repository
|
.contact_storage
|
||||||
.update_contact(contact_with_vcard)
|
.update_contact(contact_with_vcard)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(ContactDto::from(result))
|
Ok(ContactDto::from(result))
|
||||||
@@ -744,17 +766,17 @@ impl ContactUseCase for ContactService {
|
|||||||
|
|
||||||
// Get the current contact
|
// Get the current contact
|
||||||
let contact = self
|
let contact = self
|
||||||
.contact_repository
|
.contact_storage
|
||||||
.get_contact_by_id(&id)
|
.get_contact_by_id(&id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
|
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
|
||||||
|
|
||||||
// Check if user has write access to the address book
|
// 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?;
|
.await?;
|
||||||
|
|
||||||
// Delete the contact
|
// Delete the contact
|
||||||
self.contact_repository.delete_contact(&id).await?;
|
self.contact_storage.delete_contact(&id).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,13 +790,13 @@ impl ContactUseCase for ContactService {
|
|||||||
|
|
||||||
// Get the contact
|
// Get the contact
|
||||||
let contact = self
|
let contact = self
|
||||||
.contact_repository
|
.contact_storage
|
||||||
.get_contact_by_id(&id)
|
.get_contact_by_id(&id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
|
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
|
||||||
|
|
||||||
// Check if user has access to the address book
|
// 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?;
|
.await?;
|
||||||
|
|
||||||
Ok(ContactDto::from(contact))
|
Ok(ContactDto::from(contact))
|
||||||
@@ -790,9 +812,10 @@ impl ContactUseCase for ContactService {
|
|||||||
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||||
|
|
||||||
// Check if user has access to the address book
|
// 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))
|
Ok(contact.map(ContactDto::from))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -806,16 +829,14 @@ impl ContactUseCase for ContactService {
|
|||||||
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||||
|
|
||||||
// Check if user has access to the address book
|
// 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() {
|
if uids.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let contacts = self
|
let contacts = self.contact_storage.get_contacts_by_uids(&id, uids).await?;
|
||||||
.contact_repository
|
|
||||||
.get_contacts_by_uids(&id, uids)
|
|
||||||
.await?;
|
|
||||||
Ok(contacts.into_iter().map(ContactDto::from).collect())
|
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"))?;
|
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||||
|
|
||||||
// Check if user has access to the address book
|
// 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
|
// Get contacts
|
||||||
let contacts = if limit.is_some() || offset.is_some() {
|
let contacts = if limit.is_some() || offset.is_some() {
|
||||||
let limit = limit.unwrap_or(100);
|
let limit = limit.unwrap_or(100);
|
||||||
let offset = offset.unwrap_or(0);
|
let offset = offset.unwrap_or(0);
|
||||||
self.contact_repository
|
self.contact_storage
|
||||||
.get_contacts_by_address_book_paginated(&id, limit, offset)
|
.get_contacts_by_address_book_paginated(&id, limit, offset)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
self.contact_repository
|
self.contact_storage
|
||||||
.get_contacts_by_address_book(&id)
|
.get_contacts_by_address_book(&id)
|
||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
@@ -859,10 +881,11 @@ impl ContactUseCase for ContactService {
|
|||||||
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||||
|
|
||||||
// Check if user has access to the address book
|
// 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
|
// 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();
|
let dtos = contacts.into_iter().map(ContactDto::from).collect();
|
||||||
|
|
||||||
Ok(dtos)
|
Ok(dtos)
|
||||||
@@ -876,16 +899,14 @@ impl ContactUseCase for ContactService {
|
|||||||
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||||
|
|
||||||
// Check if user has write access to the address book
|
// Check if user has write access to the address book
|
||||||
self.check_address_book_write_access(
|
let caller_id = Uuid::parse_str(&dto.user_id)
|
||||||
&address_book_id,
|
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?;
|
||||||
&Uuid::parse_str(&dto.user_id)
|
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Update)
|
||||||
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?,
|
.await?;
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let group = ContactGroup::new(address_book_id, dto.name);
|
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))
|
Ok(ContactGroupDto::from(created_group))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,18 +920,16 @@ impl ContactUseCase for ContactService {
|
|||||||
|
|
||||||
// Get the current group
|
// Get the current group
|
||||||
let group = self
|
let group = self
|
||||||
.contact_group_repository
|
.contact_storage
|
||||||
.get_group_by_id(&id)
|
.get_group_by_id(&id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
||||||
|
|
||||||
// Check if user has write access to the address book
|
// Check if user has write access to the address book
|
||||||
self.check_address_book_write_access(
|
let caller_id = Uuid::parse_str(&update.user_id)
|
||||||
group.address_book_id(),
|
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?;
|
||||||
&Uuid::parse_str(&update.user_id)
|
self.require_address_book_perm(group.address_book_id(), &caller_id, Permission::Update)
|
||||||
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?,
|
.await?;
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Update the group
|
// Update the group
|
||||||
let updated_group = ContactGroup::from_raw(
|
let updated_group = ContactGroup::from_raw(
|
||||||
@@ -921,10 +940,7 @@ impl ContactUseCase for ContactService {
|
|||||||
Utc::now(),
|
Utc::now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = self
|
let result = self.contact_storage.update_group(updated_group).await?;
|
||||||
.contact_group_repository
|
|
||||||
.update_group(updated_group)
|
|
||||||
.await?;
|
|
||||||
Ok(ContactGroupDto::from(result))
|
Ok(ContactGroupDto::from(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -934,17 +950,17 @@ impl ContactUseCase for ContactService {
|
|||||||
|
|
||||||
// Get the current group
|
// Get the current group
|
||||||
let group = self
|
let group = self
|
||||||
.contact_group_repository
|
.contact_storage
|
||||||
.get_group_by_id(&id)
|
.get_group_by_id(&id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
||||||
|
|
||||||
// Check if user has write access to the address book
|
// 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?;
|
.await?;
|
||||||
|
|
||||||
// Delete the group
|
// Delete the group
|
||||||
self.contact_group_repository.delete_group(&id).await?;
|
self.contact_storage.delete_group(&id).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -958,20 +974,17 @@ impl ContactUseCase for ContactService {
|
|||||||
|
|
||||||
// Get the group
|
// Get the group
|
||||||
let group = self
|
let group = self
|
||||||
.contact_group_repository
|
.contact_storage
|
||||||
.get_group_by_id(&id)
|
.get_group_by_id(&id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
||||||
|
|
||||||
// Check if user has access to the address book
|
// 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?;
|
.await?;
|
||||||
|
|
||||||
// Get the number of contacts in the group
|
// Get the number of contacts in the group
|
||||||
let contacts = self
|
let contacts = self.contact_storage.get_contacts_in_group(&id).await?;
|
||||||
.contact_group_repository
|
|
||||||
.get_contacts_in_group(&id)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut dto = ContactGroupDto::from(group);
|
let mut dto = ContactGroupDto::from(group);
|
||||||
dto.members_count = Some(contacts.len() as i32);
|
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"))?;
|
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||||
|
|
||||||
// Check if user has access to the address book
|
// 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
|
// Get groups
|
||||||
let groups = self
|
let groups = self.contact_storage.get_groups_by_address_book(&id).await?;
|
||||||
.contact_group_repository
|
|
||||||
.get_groups_by_address_book(&id)
|
|
||||||
.await?;
|
|
||||||
let dtos = groups.into_iter().map(ContactGroupDto::from).collect();
|
let dtos = groups.into_iter().map(ContactGroupDto::from).collect();
|
||||||
|
|
||||||
Ok(dtos)
|
Ok(dtos)
|
||||||
@@ -1013,17 +1024,17 @@ impl ContactUseCase for ContactService {
|
|||||||
|
|
||||||
// Get the group
|
// Get the group
|
||||||
let group = self
|
let group = self
|
||||||
.contact_group_repository
|
.contact_storage
|
||||||
.get_group_by_id(&group_id)
|
.get_group_by_id(&group_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
||||||
|
|
||||||
// Check if user has write access to the address book
|
// 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?;
|
.await?;
|
||||||
|
|
||||||
// Add contact to group
|
// Add contact to group
|
||||||
self.contact_group_repository
|
self.contact_storage
|
||||||
.add_contact_to_group(&group_id, &contact_id)
|
.add_contact_to_group(&group_id, &contact_id)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1042,17 +1053,17 @@ impl ContactUseCase for ContactService {
|
|||||||
|
|
||||||
// Get the group
|
// Get the group
|
||||||
let group = self
|
let group = self
|
||||||
.contact_group_repository
|
.contact_storage
|
||||||
.get_group_by_id(&group_id)
|
.get_group_by_id(&group_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
||||||
|
|
||||||
// Check if user has write access to the address book
|
// 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?;
|
.await?;
|
||||||
|
|
||||||
// Remove contact from group
|
// Remove contact from group
|
||||||
self.contact_group_repository
|
self.contact_storage
|
||||||
.remove_contact_from_group(&group_id, &contact_id)
|
.remove_contact_from_group(&group_id, &contact_id)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1068,20 +1079,17 @@ impl ContactUseCase for ContactService {
|
|||||||
|
|
||||||
// Get the group
|
// Get the group
|
||||||
let group = self
|
let group = self
|
||||||
.contact_group_repository
|
.contact_storage
|
||||||
.get_group_by_id(&id)
|
.get_group_by_id(&id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
||||||
|
|
||||||
// Check if user has access to the address book
|
// 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?;
|
.await?;
|
||||||
|
|
||||||
// Get contacts in group
|
// Get contacts in group
|
||||||
let contacts = self
|
let contacts = self.contact_storage.get_contacts_in_group(&id).await?;
|
||||||
.contact_group_repository
|
|
||||||
.get_contacts_in_group(&id)
|
|
||||||
.await?;
|
|
||||||
let dtos = contacts.into_iter().map(ContactDto::from).collect();
|
let dtos = contacts.into_iter().map(ContactDto::from).collect();
|
||||||
|
|
||||||
Ok(dtos)
|
Ok(dtos)
|
||||||
@@ -1097,20 +1105,17 @@ impl ContactUseCase for ContactService {
|
|||||||
|
|
||||||
// Get the contact
|
// Get the contact
|
||||||
let contact = self
|
let contact = self
|
||||||
.contact_repository
|
.contact_storage
|
||||||
.get_contact_by_id(&id)
|
.get_contact_by_id(&id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
|
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
|
||||||
|
|
||||||
// Check if user has access to the address book
|
// 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?;
|
.await?;
|
||||||
|
|
||||||
// Get groups for contact
|
// Get groups for contact
|
||||||
let groups = self
|
let groups = self.contact_storage.get_groups_for_contact(&id).await?;
|
||||||
.contact_group_repository
|
|
||||||
.get_groups_for_contact(&id)
|
|
||||||
.await?;
|
|
||||||
let dtos = groups.into_iter().map(ContactGroupDto::from).collect();
|
let dtos = groups.into_iter().map(ContactGroupDto::from).collect();
|
||||||
|
|
||||||
Ok(dtos)
|
Ok(dtos)
|
||||||
@@ -1126,13 +1131,13 @@ impl ContactUseCase for ContactService {
|
|||||||
|
|
||||||
// Get the contact
|
// Get the contact
|
||||||
let contact = self
|
let contact = self
|
||||||
.contact_repository
|
.contact_storage
|
||||||
.get_contact_by_id(&id)
|
.get_contact_by_id(&id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
|
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
|
||||||
|
|
||||||
// Check if user has access to the address book
|
// 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?;
|
.await?;
|
||||||
|
|
||||||
// Return the vCard data
|
// Return the vCard data
|
||||||
@@ -1148,11 +1153,12 @@ impl ContactUseCase for ContactService {
|
|||||||
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||||
|
|
||||||
// Check if user has access to the address book
|
// 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
|
// Get all contacts in the address book
|
||||||
let contacts = self
|
let contacts = self
|
||||||
.contact_repository
|
.contact_storage
|
||||||
.get_contacts_by_address_book(&id)
|
.get_contacts_by_address_book(&id)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
+15
-8
@@ -50,13 +50,13 @@ use crate::application::ports::video_frame_ports::VideoFramePort;
|
|||||||
use crate::application::services::app_password_service::AppPasswordService;
|
use crate::application::services::app_password_service::AppPasswordService;
|
||||||
use crate::application::services::blob_lifecycle_service::BlobLifecycleService;
|
use crate::application::services::blob_lifecycle_service::BlobLifecycleService;
|
||||||
use crate::application::services::calendar_service::CalendarService;
|
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::device_auth_service::DeviceAuthService;
|
||||||
use crate::application::services::file_lifecycle_service::FileLifecycleService;
|
use crate::application::services::file_lifecycle_service::FileLifecycleService;
|
||||||
use crate::application::services::music_service::MusicService;
|
use crate::application::services::music_service::MusicService;
|
||||||
use crate::application::services::storage_usage_service::StorageUsageService;
|
use crate::application::services::storage_usage_service::StorageUsageService;
|
||||||
use crate::application::services::wopi_lock_service::WopiLockService;
|
use crate::application::services::wopi_lock_service::WopiLockService;
|
||||||
use crate::application::services::wopi_token_service::WopiTokenService;
|
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::AppPasswordPgRepository;
|
||||||
use crate::infrastructure::repositories::DeviceCodePgRepository;
|
use crate::infrastructure::repositories::DeviceCodePgRepository;
|
||||||
use crate::infrastructure::repositories::pg::{
|
use crate::infrastructure::repositories::pg::{
|
||||||
@@ -1558,7 +1558,6 @@ impl AppServiceFactory {
|
|||||||
people_service,
|
people_service,
|
||||||
storage_usage_service,
|
storage_usage_service,
|
||||||
calendar_service: None,
|
calendar_service: None,
|
||||||
contact_service: None,
|
|
||||||
calendar_use_case: None,
|
calendar_use_case: None,
|
||||||
addressbook_use_case: None,
|
addressbook_use_case: None,
|
||||||
contact_use_case: None,
|
contact_use_case: None,
|
||||||
@@ -1829,6 +1828,7 @@ impl AppServiceFactory {
|
|||||||
let calendar_service = Arc::new(
|
let calendar_service = Arc::new(
|
||||||
crate::application::services::calendar_service::CalendarService::new(
|
crate::application::services::calendar_service::CalendarService::new(
|
||||||
calendar_storage,
|
calendar_storage,
|
||||||
|
authorization.clone(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
app_state.calendar_use_case = Some(calendar_service as Arc<CalendarService>);
|
app_state.calendar_use_case = Some(calendar_service as Arc<CalendarService>);
|
||||||
@@ -1845,15 +1845,23 @@ impl AppServiceFactory {
|
|||||||
pool.clone(),
|
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(
|
let contact_storage = Arc::new(
|
||||||
crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter::new(
|
crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter::new(
|
||||||
address_book_repo,
|
address_book_repo,
|
||||||
contact_repo,
|
contact_repo,
|
||||||
group_repo,
|
group_repo,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
app_state.addressbook_use_case = Some(contact_storage.clone());
|
let contact_service =
|
||||||
app_state.contact_use_case = Some(contact_storage);
|
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");
|
tracing::info!("CalDAV and CardDAV services initialized with PostgreSQL repositories");
|
||||||
}
|
}
|
||||||
@@ -2022,10 +2030,9 @@ pub struct AppState {
|
|||||||
pub people_service: Option<Arc<PeopleService>>,
|
pub people_service: Option<Arc<PeopleService>>,
|
||||||
pub storage_usage_service: Option<Arc<StorageUsageService>>,
|
pub storage_usage_service: Option<Arc<StorageUsageService>>,
|
||||||
pub calendar_service: Option<Arc<CalendarService>>,
|
pub calendar_service: Option<Arc<CalendarService>>,
|
||||||
pub contact_service: Option<Arc<ContactStorageAdapter>>,
|
|
||||||
pub calendar_use_case: Option<Arc<CalendarService>>,
|
pub calendar_use_case: Option<Arc<CalendarService>>,
|
||||||
pub addressbook_use_case: Option<Arc<ContactStorageAdapter>>,
|
pub addressbook_use_case: Option<Arc<ContactService>>,
|
||||||
pub contact_use_case: Option<Arc<ContactStorageAdapter>>,
|
pub contact_use_case: Option<Arc<ContactService>>,
|
||||||
pub music_service: Option<Arc<MusicService>>,
|
pub music_service: Option<Arc<MusicService>>,
|
||||||
pub wopi_token_service:
|
pub wopi_token_service:
|
||||||
Option<Arc<crate::application::services::wopi_token_service::WopiTokenService>>,
|
Option<Arc<crate::application::services::wopi_token_service::WopiTokenService>>,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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::address_book_dto::{CreateAddressBookDto, UpdateAddressBookDto};
|
||||||
use crate::application::dtos::contact_dto::CreateContactVCardDto;
|
use crate::application::dtos::contact_dto::CreateContactVCardDto;
|
||||||
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
|
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
|
||||||
|
use crate::application::services::contact_service::ContactService;
|
||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
|
|
||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
|
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ fn extract_user(req: &Request<Body>) -> Result<AuthUser, AppError> {
|
|||||||
.ok_or_else(|| AppError::unauthorized("Authentication required"))
|
.ok_or_else(|| AppError::unauthorized("Authentication required"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_addressbook_service(state: &AppState) -> Result<&Arc<ContactStorageAdapter>, AppError> {
|
fn get_addressbook_service(state: &AppState) -> Result<&Arc<ContactService>, AppError> {
|
||||||
state.addressbook_use_case.as_ref().ok_or_else(|| {
|
state.addressbook_use_case.as_ref().ok_or_else(|| {
|
||||||
AppError::new(
|
AppError::new(
|
||||||
StatusCode::NOT_IMPLEMENTED,
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
@@ -187,7 +187,7 @@ fn get_addressbook_service(state: &AppState) -> Result<&Arc<ContactStorageAdapte
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_contact_service(state: &AppState) -> Result<&Arc<ContactStorageAdapter>, AppError> {
|
fn get_contact_service(state: &AppState) -> Result<&Arc<ContactService>, AppError> {
|
||||||
state.contact_use_case.as_ref().ok_or_else(|| {
|
state.contact_use_case.as_ref().ok_or_else(|| {
|
||||||
AppError::new(
|
AppError::new(
|
||||||
StatusCode::NOT_IMPLEMENTED,
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ use crate::application::dtos::contact_dto::{
|
|||||||
use crate::application::dtos::user_dto::UserDto;
|
use crate::application::dtos::user_dto::UserDto;
|
||||||
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
|
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
|
||||||
use crate::application::services::auth_application_service::AuthApplicationService;
|
use crate::application::services::auth_application_service::AuthApplicationService;
|
||||||
|
use crate::application::services::contact_service::ContactService;
|
||||||
use crate::domain::errors::ErrorKind;
|
use crate::domain::errors::ErrorKind;
|
||||||
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
|
|
||||||
use crate::interfaces::middleware::auth::AuthUser;
|
use crate::interfaces::middleware::auth::AuthUser;
|
||||||
|
|
||||||
const SYSTEM_BOOK_ID: &str = "system";
|
const SYSTEM_BOOK_ID: &str = "system";
|
||||||
@@ -28,7 +28,7 @@ const SYSTEM_BOOK_ID: &str = "system";
|
|||||||
/// Combined state for the contacts REST API.
|
/// Combined state for the contacts REST API.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ContactsApiState {
|
pub struct ContactsApiState {
|
||||||
pub contact_service: Arc<ContactStorageAdapter>,
|
pub contact_service: Arc<ContactService>,
|
||||||
pub auth_service: Option<Arc<AuthApplicationService>>,
|
pub auth_service: Option<Arc<AuthApplicationService>>,
|
||||||
/// When false, the virtual "system" address book (OxiCloud users) is hidden.
|
/// When false, the virtual "system" address book (OxiCloud users) is hidden.
|
||||||
pub expose_system_users: bool,
|
pub expose_system_users: bool,
|
||||||
|
|||||||
Reference in New Issue
Block a user