Merge pull request #553 from EdouardVanbelle/feat/caldav-carddav-to-authz
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Round 3 — admit 'calendar' and 'address_book' into
|
||||
-- `storage.role_grants.resource_type`.
|
||||
--
|
||||
-- Companion to the domain unblock in
|
||||
-- `src/domain/services/authorization.rs` (Round 3 Phase 1). The
|
||||
-- `Resource::Calendar(Uuid)` and `Resource::AddressBook(Uuid)`
|
||||
-- variants can't be inserted into `role_grants` until the CHECK
|
||||
-- constraint on `resource_type` permits their string discriminators.
|
||||
--
|
||||
-- CalDAV and CardDAV surfaces have historically enforced access via
|
||||
-- dedicated per-domain share tables (`caldav.calendar_shares`,
|
||||
-- `carddav.address_book_shares`) and bespoke `check_calendar_access`
|
||||
-- / `check_address_book_access` helpers. Round 3 folds both into the
|
||||
-- unified ReBAC engine so:
|
||||
--
|
||||
-- * A single ACL source of truth (`storage.role_grants`) covers
|
||||
-- every OxiCloud resource type — files, folders, drives,
|
||||
-- calendars, address books.
|
||||
-- * Group subjects become a free feature on calendar/book shares
|
||||
-- (falls out of `role_grants.subject_type='group'`).
|
||||
-- * The `authz.require` audit line ("👮🏻♂️ perms: ⛔ …") fires on
|
||||
-- denial with no per-domain retrofit.
|
||||
--
|
||||
-- Migration of existing rows from `caldav.calendar_shares` and
|
||||
-- `carddav.address_book_shares` into `role_grants` happens in the
|
||||
-- next migration (Phase 2). The legacy tables stay in place through
|
||||
-- this PR for rollback safety; they get dropped one release later.
|
||||
|
||||
-- `resource_type` is a TEXT column with a CHECK constraint (not a PG
|
||||
-- enum), so extending it is a DROP / ADD pair — no `ALTER TYPE` /
|
||||
-- non-transactional migration issues.
|
||||
|
||||
ALTER TABLE storage.role_grants
|
||||
DROP CONSTRAINT IF EXISTS role_grants_resource_type_check;
|
||||
|
||||
ALTER TABLE storage.role_grants
|
||||
ADD CONSTRAINT role_grants_resource_type_check
|
||||
CHECK (resource_type IN ('folder', 'file', 'drive', 'calendar', 'address_book'));
|
||||
|
||||
-- Post-flight: introspect the live constraint definition and prove
|
||||
-- both new values appear. Cheap read-only check with no INSERT.
|
||||
DO $BODY$
|
||||
DECLARE
|
||||
defn TEXT;
|
||||
BEGIN
|
||||
SELECT pg_get_constraintdef(c.oid) INTO defn
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = 'storage'
|
||||
AND t.relname = 'role_grants'
|
||||
AND c.conname = 'role_grants_resource_type_check';
|
||||
|
||||
IF defn IS NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'role_grants_resource_type_check not found on storage.role_grants';
|
||||
END IF;
|
||||
IF position('calendar' IN defn) = 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'CHECK constraint does not admit ''calendar'': %', defn;
|
||||
END IF;
|
||||
IF position('address_book' IN defn) = 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'CHECK constraint does not admit ''address_book'': %', defn;
|
||||
END IF;
|
||||
END;
|
||||
$BODY$;
|
||||
@@ -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$;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Drop the pre-Round-3 per-domain share tables. Every reader/writer
|
||||
-- was retired in the Rust cleanup landing alongside this migration:
|
||||
--
|
||||
-- * `CalendarUseCase::{list_shared_calendars, share_calendar,
|
||||
-- remove_calendar_sharing, get_calendar_shares}` — gone
|
||||
-- * `AddressBookUseCase::{share_address_book, unshare_address_book,
|
||||
-- get_address_book_shares}` — gone
|
||||
-- * `CalendarRepository` / `AddressBookRepository` share methods — gone
|
||||
-- * SQL bodies in `calendar_pg_repository.rs` /
|
||||
-- `address_book_pg_repository.rs` that touched these tables — gone
|
||||
--
|
||||
-- Data lives on in `storage.role_grants` (backfilled by
|
||||
-- `20260906000001_backfill_calendar_address_book_role_grants.sql`).
|
||||
-- The one-release rollback window between the backfill and this drop
|
||||
-- was left implicit — no external process reads either table today.
|
||||
|
||||
DROP TABLE IF EXISTS caldav.calendar_shares;
|
||||
DROP TABLE IF EXISTS carddav.address_book_shares;
|
||||
|
||||
-- Post-flight introspection: refuse to complete if either table is
|
||||
-- still present. Guards against a name-collision resurrection by an
|
||||
-- older seed file or hand-rolled restore step.
|
||||
DO $$
|
||||
DECLARE
|
||||
stray_count INT;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO stray_count
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE (n.nspname = 'caldav' AND c.relname = 'calendar_shares')
|
||||
OR (n.nspname = 'carddav' AND c.relname = 'address_book_shares');
|
||||
|
||||
IF stray_count > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'Migration 20260906000002 finished with % legacy share table(s) still present',
|
||||
stray_count;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -61,16 +61,3 @@ pub struct UpdateAddressBookDto {
|
||||
pub is_public: Option<bool>,
|
||||
pub user_id: String, // Current user making the update
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShareAddressBookDto {
|
||||
pub address_book_id: String,
|
||||
pub user_id: String,
|
||||
pub can_write: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UnshareAddressBookDto {
|
||||
pub address_book_id: String,
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
@@ -55,11 +55,13 @@ impl From<Subject> for SubjectDto {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ResourceTypeDto {
|
||||
Folder,
|
||||
File,
|
||||
Drive,
|
||||
Calendar,
|
||||
AddressBook,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
@@ -75,6 +77,8 @@ impl From<ResourceDto> for Resource {
|
||||
ResourceTypeDto::Folder => Resource::Folder(dto.id),
|
||||
ResourceTypeDto::File => Resource::File(dto.id),
|
||||
ResourceTypeDto::Drive => Resource::Drive(dto.id),
|
||||
ResourceTypeDto::Calendar => Resource::Calendar(dto.id),
|
||||
ResourceTypeDto::AddressBook => Resource::AddressBook(dto.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +89,8 @@ impl From<Resource> for ResourceDto {
|
||||
Resource::Folder(id) => (ResourceTypeDto::Folder, id),
|
||||
Resource::File(id) => (ResourceTypeDto::File, id),
|
||||
Resource::Drive(id) => (ResourceTypeDto::Drive, id),
|
||||
Resource::Calendar(id) => (ResourceTypeDto::Calendar, id),
|
||||
Resource::AddressBook(id) => (ResourceTypeDto::AddressBook, id),
|
||||
};
|
||||
ResourceDto { kind, id }
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
Resource::Folder(id) => ("Folder", id),
|
||||
Resource::File(id) => ("File", id),
|
||||
Resource::Drive(id) => ("Drive", id),
|
||||
Resource::Calendar(id) => ("Calendar", id),
|
||||
Resource::AddressBook(id) => ("AddressBook", id),
|
||||
};
|
||||
// Audit-worthy: denials are the interesting signal. Routed
|
||||
// through the `audit` tracing target so log aggregators can
|
||||
|
||||
@@ -25,38 +25,11 @@ pub trait CalendarStoragePort: Send + Sync + 'static {
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn list_calendars_shared_with_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn list_public_calendars(
|
||||
&self,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn check_calendar_access(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<bool, DomainError>;
|
||||
|
||||
// Calendar sharing
|
||||
async fn share_calendar(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
access_level: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn remove_calendar_sharing(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn get_calendar_shares(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
) -> Result<Vec<(String, String)>, DomainError>;
|
||||
|
||||
// Calendar properties
|
||||
async fn set_calendar_property(
|
||||
&self,
|
||||
@@ -146,33 +119,12 @@ pub trait CalendarUseCase: Send + Sync + 'static {
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarDto, DomainError>;
|
||||
async fn list_my_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn list_shared_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn list_public_calendars(
|
||||
&self,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
|
||||
// Calendar sharing
|
||||
async fn share_calendar(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
target_user_id: Uuid,
|
||||
access_level: &str,
|
||||
caller_user_id: Uuid,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn remove_calendar_sharing(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
target_user_id: Uuid,
|
||||
caller_user_id: Uuid,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn get_calendar_shares(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<(String, String)>, DomainError>;
|
||||
|
||||
// Event operations
|
||||
async fn create_event(
|
||||
&self,
|
||||
|
||||
@@ -1,16 +1,105 @@
|
||||
use crate::application::dtos::address_book_dto::{
|
||||
AddressBookDto, CreateAddressBookDto, ShareAddressBookDto, UnshareAddressBookDto,
|
||||
UpdateAddressBookDto,
|
||||
AddressBookDto, CreateAddressBookDto, UpdateAddressBookDto,
|
||||
};
|
||||
use crate::application::dtos::contact_dto::{
|
||||
ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, CreateContactVCardDto,
|
||||
GroupMembershipDto, UpdateContactDto, UpdateContactGroupDto,
|
||||
};
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::contact::{AddressBook, Contact, ContactGroup};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type CardDavRepositoryError = DomainError;
|
||||
|
||||
/// Low-level storage port for CardDAV resources. Post-Round-3 the
|
||||
/// port covers ONLY raw storage operations — everything that used
|
||||
/// to be routed through it for sharing (`share_address_book`,
|
||||
/// `unshare_address_book`, `get_address_book_shares`) or
|
||||
/// scope-listing (`get_address_books_by_owner`,
|
||||
/// `get_shared_address_books`) is gone. Access decisions live in
|
||||
/// `AuthorizationEngine`; sharing state lives in
|
||||
/// `storage.role_grants`. The service layer (`ContactService`) gates
|
||||
/// each call, then reaches through this port for storage.
|
||||
///
|
||||
/// Symmetric with `CalendarStoragePort`. Implemented by
|
||||
/// `ContactStorageAdapter` against Postgres today; a future backend
|
||||
/// (external CardDAV, LDAP directory, in-memory test mock) would
|
||||
/// implement the same trait and swap in via DI.
|
||||
pub trait ContactStoragePort: Send + Sync + 'static {
|
||||
// ── Address books ────────────────────────────────────────────
|
||||
async fn create_address_book(
|
||||
&self,
|
||||
address_book: AddressBook,
|
||||
) -> Result<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 {
|
||||
// Address Book operations
|
||||
async fn create_address_book(
|
||||
@@ -37,23 +126,6 @@ pub trait AddressBookUseCase: Send + Sync + 'static {
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<AddressBookDto>, DomainError>;
|
||||
async fn list_public_address_books(&self) -> Result<Vec<AddressBookDto>, DomainError>;
|
||||
|
||||
// Address Book sharing
|
||||
async fn share_address_book(
|
||||
&self,
|
||||
dto: ShareAddressBookDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn unshare_address_book(
|
||||
&self,
|
||||
dto: UnshareAddressBookDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn get_address_book_shares(
|
||||
&self,
|
||||
address_book_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<(String, bool)>, DomainError>;
|
||||
}
|
||||
|
||||
pub trait ContactUseCase: Send + Sync + 'static {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use uuid::Uuid;
|
||||
@@ -446,9 +445,3 @@ pub trait StorageUsagePort: Send + Sync + 'static {
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
/// Generic storage service interface for calendar and contact services
|
||||
pub trait StorageUseCase: Send + Sync + 'static {
|
||||
/// Handle a request with the specified action and parameters
|
||||
async fn handle_request(&self, action: &str, params: Value) -> Result<Value, DomainError>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -6,17 +7,80 @@ use crate::application::dtos::calendar_dto::{
|
||||
CalendarDto, CalendarEventDto, CreateCalendarDto, CreateEventDto, CreateEventICalDto,
|
||||
UpdateCalendarDto, UpdateEventDto,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::calendar_ports::{CalendarStoragePort, CalendarUseCase};
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::services::authorization::{Permission, Resource, Role, Subject};
|
||||
use crate::infrastructure::adapters::calendar_storage_adapter::CalendarStorageAdapter;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
|
||||
/// Calendar service — the CalDAV / REST entry point for every calendar
|
||||
/// or event operation. Every method routes through `AuthorizationEngine`;
|
||||
/// the pre-Round-3 `check_calendar_access` bespoke helper is gone.
|
||||
///
|
||||
/// Ownership + sharing live entirely in `storage.role_grants`
|
||||
/// (`resource_type='calendar'`). `caldav.calendars.owner_id` stays for
|
||||
/// provenance and legacy queries but is no longer consulted for access
|
||||
/// decisions.
|
||||
pub struct CalendarService {
|
||||
calendar_storage: Arc<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 {
|
||||
pub fn new(calendar_storage: Arc<CalendarStorageAdapter>) -> Self {
|
||||
Self { calendar_storage }
|
||||
pub fn new(calendar_storage: Arc<CalendarStorageAdapter>, authz: Arc<PgAclEngine>) -> Self {
|
||||
Self {
|
||||
calendar_storage,
|
||||
authz,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `calendar_id` and enforce `permission` on `Resource::Calendar(uuid)`.
|
||||
/// On denial `authz.require` returns `NotFound` (anti-enum — same
|
||||
/// shape as "no such calendar") and emits the `authz.denied` audit
|
||||
/// line. Returns the parsed UUID on success so the caller doesn't
|
||||
/// have to parse it a second time.
|
||||
async fn require_calendar_perm(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
caller_id: Uuid,
|
||||
permission: Permission,
|
||||
) -> Result<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,
|
||||
user_id: Uuid,
|
||||
) -> 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)
|
||||
.await
|
||||
.await?;
|
||||
let calendar_uuid = Uuid::parse_str(&created.id).map_err(|_| {
|
||||
DomainError::internal_error("Calendar", "storage returned invalid calendar id")
|
||||
})?;
|
||||
// `set_role` is idempotent on the `(subject, resource)` unique
|
||||
// key — a re-run (rare — only if storage retried) is a no-op.
|
||||
// `granted_by = user_id` is the self-seeded creation event.
|
||||
self.authz
|
||||
.set_role(
|
||||
user_id,
|
||||
Subject::User(user_id),
|
||||
Role::Owner,
|
||||
Resource::Calendar(calendar_uuid),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
async fn update_calendar(
|
||||
@@ -37,35 +122,28 @@ impl CalendarUseCase for CalendarService {
|
||||
update: UpdateCalendarDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarDto, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
self.require_calendar_perm(calendar_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to update this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage
|
||||
.update_calendar(calendar_id, update)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete_calendar(&self, calendar_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
let uuid = self
|
||||
.require_calendar_perm(calendar_id, user_id, Permission::Delete)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to delete this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.delete_calendar(calendar_id).await
|
||||
self.calendar_storage.delete_calendar(calendar_id).await?;
|
||||
// Wipe every grant on this calendar so a re-used UUID (impossible
|
||||
// today but cheap to defend against) doesn't inherit stale ACLs.
|
||||
// The storage DELETE won't cascade to `storage.role_grants` — the
|
||||
// legacy `caldav.calendar_shares` had an FK, `role_grants`
|
||||
// doesn't (it's cross-schema).
|
||||
let _ = self
|
||||
.authz
|
||||
.revoke_all_for_resource(Resource::Calendar(uuid))
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_calendar(
|
||||
@@ -74,28 +152,52 @@ impl CalendarUseCase for CalendarService {
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarDto, DomainError> {
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view this calendar",
|
||||
));
|
||||
// Public-calendar bypass: anonymous-ish read. `check` returns
|
||||
// bool (no throw); combine with the public flag before
|
||||
// deciding.
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
Ok(calendar)
|
||||
}
|
||||
|
||||
async fn list_my_calendars(&self, user_id: Uuid) -> Result<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?;
|
||||
|
||||
async fn list_shared_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError> {
|
||||
self.calendar_storage
|
||||
.list_calendars_shared_with_user(user_id)
|
||||
.await
|
||||
// Deduplicate — a user can hold multiple grants on the same
|
||||
// calendar (direct + group-inherited). We only need one DTO
|
||||
// per resource.
|
||||
let calendar_ids: HashSet<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_public_calendars(
|
||||
@@ -103,6 +205,8 @@ impl CalendarUseCase for CalendarService {
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
) -> Result<Vec<CalendarDto>, DomainError> {
|
||||
// No caller gate: public listing by definition. Storage
|
||||
// filters on `is_public = true`.
|
||||
let limit = limit.unwrap_or(100);
|
||||
let offset = offset.unwrap_or(0);
|
||||
self.calendar_storage
|
||||
@@ -110,90 +214,13 @@ impl CalendarUseCase for CalendarService {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn share_calendar(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
target_user_id: Uuid,
|
||||
access_level: &str,
|
||||
caller_user_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if calendar.owner_id != caller_user_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"Only the calendar owner can change sharing settings",
|
||||
));
|
||||
}
|
||||
match access_level {
|
||||
"read" | "write" | "owner" => {}
|
||||
_ => {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Calendar",
|
||||
format!(
|
||||
"Invalid access level: {}. Valid values are: read, write, owner",
|
||||
access_level
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
self.calendar_storage
|
||||
.share_calendar(calendar_id, target_user_id, access_level)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn remove_calendar_sharing(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
target_user_id: Uuid,
|
||||
caller_user_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if calendar.owner_id != caller_user_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"Only the calendar owner can change sharing settings",
|
||||
));
|
||||
}
|
||||
self.calendar_storage
|
||||
.remove_calendar_sharing(calendar_id, target_user_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_calendar_shares(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<(String, String)>, DomainError> {
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if calendar.owner_id != user_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"Only the calendar owner can view sharing settings",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.get_calendar_shares(calendar_id).await
|
||||
}
|
||||
|
||||
async fn create_event(
|
||||
&self,
|
||||
event: CreateEventDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarEventDto, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Create)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to add events to this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.create_event(event).await
|
||||
}
|
||||
|
||||
@@ -202,17 +229,8 @@ impl CalendarUseCase for CalendarService {
|
||||
event: CreateEventICalDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarEventDto, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Create)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to add events to this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.create_event_from_ical(event).await
|
||||
}
|
||||
|
||||
@@ -223,33 +241,15 @@ impl CalendarUseCase for CalendarService {
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarEventDto, DomainError> {
|
||||
let event = self.calendar_storage.get_event(event_id).await?;
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to update events in this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.update_event(event_id, update).await
|
||||
}
|
||||
|
||||
async fn delete_event(&self, event_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
||||
let event = self.calendar_storage.get_event(event_id).await?;
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Delete)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to delete events in this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.delete_event(event_id).await
|
||||
}
|
||||
|
||||
@@ -259,20 +259,17 @@ impl CalendarUseCase for CalendarService {
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarEventDto, DomainError> {
|
||||
let event = self.calendar_storage.get_event(event_id).await?;
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
.await?;
|
||||
let calendar = self
|
||||
.calendar_storage
|
||||
.get_calendar(&event.calendar_id)
|
||||
.await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
// Same public-calendar bypass as `get_calendar`.
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(&event.calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Event", event_id));
|
||||
}
|
||||
Ok(event)
|
||||
}
|
||||
@@ -283,17 +280,13 @@ impl CalendarUseCase for CalendarService {
|
||||
ical_uid: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<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?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
self.calendar_storage
|
||||
.find_event_by_ical_uid(calendar_id, ical_uid)
|
||||
@@ -306,17 +299,13 @@ impl CalendarUseCase for CalendarService {
|
||||
ical_uids: &[String],
|
||||
user_id: Uuid,
|
||||
) -> Result<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?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
if ical_uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -333,17 +322,13 @@ impl CalendarUseCase for CalendarService {
|
||||
offset: Option<i64>,
|
||||
user_id: Uuid,
|
||||
) -> 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?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
if limit.is_some() || offset.is_some() {
|
||||
let limit = limit.unwrap_or(100);
|
||||
@@ -365,17 +350,13 @@ impl CalendarUseCase for CalendarService {
|
||||
end: DateTime<Utc>,
|
||||
user_id: Uuid,
|
||||
) -> 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?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
self.calendar_storage
|
||||
.get_events_in_time_range(calendar_id, &start, &end)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -307,20 +307,25 @@ impl MagicLinkInviteService {
|
||||
let (kind, resource_id) = match resource {
|
||||
Resource::Folder(id) => (MagicLinkResourceKind::Folder, id),
|
||||
Resource::File(id) => (MagicLinkResourceKind::File, id),
|
||||
// Drive sharing — and therefore drive magic-link invitations —
|
||||
// land in D2. The grant DTOs accept `Resource::Drive` from the
|
||||
// wire today (see ResourceTypeDto) but no public API path
|
||||
// actually grants on a drive in D0, so this arm is
|
||||
// defensively unreachable. Treating it as an audit-logged
|
||||
// no-op (grant is in place, mail suppressed) matches the
|
||||
// ineligible-recipient branch above.
|
||||
Resource::Drive(_) => {
|
||||
// Drive / Calendar / AddressBook sharing is out-of-band for
|
||||
// the magic-link flow. Drive shares land through
|
||||
// `/api/drives/{id}/members`; Calendar / AddressBook shares
|
||||
// through the Round-3 `/api/(calendars|address-books)/{id}/shares`
|
||||
// endpoints. The DTOs accept every `Resource` variant on
|
||||
// the wire (see `ResourceTypeDto`) but only file/folder
|
||||
// grants trigger an invitation email. Treating the other
|
||||
// arms as audit-logged suppressed no-ops keeps the grant
|
||||
// in place while matching the ineligible-recipient branch
|
||||
// above.
|
||||
Resource::Drive(_) | Resource::Calendar(_) | Resource::AddressBook(_) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "magic_link.invitation_suppressed",
|
||||
reason = "drive_resource_unsupported",
|
||||
reason = "resource_kind_unsupported",
|
||||
user_id = %recipient.id(),
|
||||
"📭 magic-link invitation suppressed: drive resources aren't invitable until D2",
|
||||
resource_kind = %resource.type_str(),
|
||||
"📭 magic-link invitation suppressed: {} resources aren't invitable via email",
|
||||
resource.type_str(),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -347,10 +352,12 @@ impl MagicLinkInviteService {
|
||||
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
||||
Resource::File(_) => "server.magic_link.email.kind_file",
|
||||
// Unreachable — the early-return above exits before we get
|
||||
// here for a Drive resource. The arm exists only to satisfy
|
||||
// exhaustiveness; if you find this firing, the early-return
|
||||
// was bypassed.
|
||||
Resource::Drive(_) => "server.magic_link.email.kind_folder",
|
||||
// here for Drive / Calendar / AddressBook resources. The
|
||||
// arms exist only to satisfy exhaustiveness; if you find
|
||||
// any firing, the early-return was bypassed.
|
||||
Resource::Drive(_) | Resource::Calendar(_) | Resource::AddressBook(_) => {
|
||||
"server.magic_link.email.kind_folder"
|
||||
}
|
||||
};
|
||||
// PR C: render in the recipient's preferred locale (set by UI
|
||||
// switcher, OIDC JIT claim, or inviter inheritance at row
|
||||
|
||||
@@ -472,11 +472,13 @@ impl RecipientNotificationService {
|
||||
let kind_key = match resource {
|
||||
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
||||
Resource::File(_) => "server.magic_link.email.kind_file",
|
||||
// Drives don't generate share notifications in D0 — drive
|
||||
// sharing lands in D2 and gets its own template key. Fall
|
||||
// back to the folder label so any path that does reach
|
||||
// here produces a readable, if generic, mail body.
|
||||
Resource::Drive(_) => "server.magic_link.email.kind_folder",
|
||||
// Drive / Calendar / AddressBook shares don't produce
|
||||
// email notifications through this path. Fall back to the
|
||||
// folder label so any code that does reach here still
|
||||
// produces a readable (if generic) mail body.
|
||||
Resource::Drive(_) | Resource::Calendar(_) | Resource::AddressBook(_) => {
|
||||
"server.magic_link.email.kind_folder"
|
||||
}
|
||||
};
|
||||
let kind_label = self.i18n_or(kind_key, &locale, &[]).await;
|
||||
// Short form for the subject, long form (with email) for the
|
||||
|
||||
+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::blob_lifecycle_service::BlobLifecycleService;
|
||||
use crate::application::services::calendar_service::CalendarService;
|
||||
use crate::application::services::contact_service::ContactService;
|
||||
use crate::application::services::device_auth_service::DeviceAuthService;
|
||||
use crate::application::services::file_lifecycle_service::FileLifecycleService;
|
||||
use crate::application::services::music_service::MusicService;
|
||||
use crate::application::services::storage_usage_service::StorageUsageService;
|
||||
use crate::application::services::wopi_lock_service::WopiLockService;
|
||||
use crate::application::services::wopi_token_service::WopiTokenService;
|
||||
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
|
||||
use crate::infrastructure::repositories::AppPasswordPgRepository;
|
||||
use crate::infrastructure::repositories::DeviceCodePgRepository;
|
||||
use crate::infrastructure::repositories::pg::{
|
||||
@@ -1558,7 +1558,6 @@ impl AppServiceFactory {
|
||||
people_service,
|
||||
storage_usage_service,
|
||||
calendar_service: None,
|
||||
contact_service: None,
|
||||
calendar_use_case: None,
|
||||
addressbook_use_case: None,
|
||||
contact_use_case: None,
|
||||
@@ -1829,6 +1828,7 @@ impl AppServiceFactory {
|
||||
let calendar_service = Arc::new(
|
||||
crate::application::services::calendar_service::CalendarService::new(
|
||||
calendar_storage,
|
||||
authorization.clone(),
|
||||
),
|
||||
);
|
||||
app_state.calendar_use_case = Some(calendar_service as Arc<CalendarService>);
|
||||
@@ -1845,15 +1845,23 @@ impl AppServiceFactory {
|
||||
pool.clone(),
|
||||
),
|
||||
);
|
||||
// Post-Round-3: symmetric with CalendarService/CalendarStorageAdapter.
|
||||
// * ContactStorageAdapter → pure ContactStoragePort impl
|
||||
// (raw PG storage, no ACL, no sharing).
|
||||
// * ContactService → gates every call through the
|
||||
// AuthorizationEngine, then delegates through the port.
|
||||
// Owns both AddressBookUseCase + ContactUseCase impls.
|
||||
let contact_storage = Arc::new(
|
||||
crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter::new(
|
||||
address_book_repo,
|
||||
contact_repo,
|
||||
group_repo,
|
||||
)
|
||||
),
|
||||
);
|
||||
app_state.addressbook_use_case = Some(contact_storage.clone());
|
||||
app_state.contact_use_case = Some(contact_storage);
|
||||
let contact_service =
|
||||
Arc::new(ContactService::new(contact_storage, authorization.clone()));
|
||||
app_state.addressbook_use_case = Some(contact_service.clone());
|
||||
app_state.contact_use_case = Some(contact_service);
|
||||
|
||||
tracing::info!("CalDAV and CardDAV services initialized with PostgreSQL repositories");
|
||||
}
|
||||
@@ -2022,10 +2030,9 @@ pub struct AppState {
|
||||
pub people_service: Option<Arc<PeopleService>>,
|
||||
pub storage_usage_service: Option<Arc<StorageUsageService>>,
|
||||
pub calendar_service: Option<Arc<CalendarService>>,
|
||||
pub contact_service: Option<Arc<ContactStorageAdapter>>,
|
||||
pub calendar_use_case: Option<Arc<CalendarService>>,
|
||||
pub addressbook_use_case: Option<Arc<ContactStorageAdapter>>,
|
||||
pub contact_use_case: Option<Arc<ContactStorageAdapter>>,
|
||||
pub addressbook_use_case: Option<Arc<ContactService>>,
|
||||
pub contact_use_case: Option<Arc<ContactService>>,
|
||||
pub music_service: Option<Arc<MusicService>>,
|
||||
pub wopi_token_service:
|
||||
Option<Arc<crate::application::services::wopi_token_service::WopiTokenService>>,
|
||||
|
||||
@@ -6,6 +6,14 @@ use crate::domain::entities::contact::AddressBook;
|
||||
|
||||
pub type AddressBookRepositoryResult<T> = Result<T, DomainError>;
|
||||
|
||||
/// Repository interface for AddressBook entity operations.
|
||||
///
|
||||
/// Post-Round-3, access-control state lives in `storage.role_grants`.
|
||||
/// The pre-Round-3 methods that read/wrote `carddav.address_book_shares`
|
||||
/// (`get_shared_address_books`, `share_address_book`,
|
||||
/// `unshare_address_book`, `get_address_book_shares`) have been removed
|
||||
/// from this trait, and the backing table was dropped in
|
||||
/// `20260906000002_drop_legacy_share_tables.sql`.
|
||||
pub trait AddressBookRepository: Send + Sync + 'static {
|
||||
async fn create_address_book(
|
||||
&self,
|
||||
@@ -20,28 +28,13 @@ pub trait AddressBookRepository: Send + Sync + 'static {
|
||||
&self,
|
||||
id: &Uuid,
|
||||
) -> AddressBookRepositoryResult<Option<AddressBook>>;
|
||||
/// Direct owner enumeration — same semantics as the calendar
|
||||
/// counterpart. The service layer prefers
|
||||
/// `authz.list_incoming_grants`, but internal maintenance paths
|
||||
/// keep the owner-only lookup available.
|
||||
async fn get_address_books_by_owner(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
) -> AddressBookRepositoryResult<Vec<AddressBook>>;
|
||||
async fn get_shared_address_books(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> AddressBookRepositoryResult<Vec<AddressBook>>;
|
||||
async fn get_public_address_books(&self) -> AddressBookRepositoryResult<Vec<AddressBook>>;
|
||||
async fn share_address_book(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
can_write: bool,
|
||||
) -> AddressBookRepositoryResult<()>;
|
||||
async fn unshare_address_book(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
) -> AddressBookRepositoryResult<()>;
|
||||
async fn get_address_book_shares(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
) -> AddressBookRepositoryResult<Vec<(String, bool)>>;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,14 @@ use uuid::Uuid;
|
||||
|
||||
pub type CalendarRepositoryResult<T> = Result<T, DomainError>;
|
||||
|
||||
/// Repository interface for Calendar entity operations
|
||||
/// Repository interface for Calendar entity operations.
|
||||
///
|
||||
/// Post-Round-3, access-control state lives in `storage.role_grants` —
|
||||
/// the pre-Round-3 methods that read/wrote `caldav.calendar_shares`
|
||||
/// (`list_calendars_shared_with_user`, `user_has_calendar_access`,
|
||||
/// `share_calendar`, `remove_calendar_sharing`, `get_calendar_shares`)
|
||||
/// have been removed from this trait, and the backing table was dropped
|
||||
/// in `20260906000002_drop_legacy_share_tables.sql`.
|
||||
pub trait CalendarRepository: Send + Sync + 'static {
|
||||
/// Creates a new calendar
|
||||
async fn create_calendar(&self, calendar: Calendar) -> CalendarRepositoryResult<Calendar>;
|
||||
@@ -18,7 +25,11 @@ pub trait CalendarRepository: Send + Sync + 'static {
|
||||
/// Finds a calendar by its ID
|
||||
async fn find_calendar_by_id(&self, id: &Uuid) -> CalendarRepositoryResult<Calendar>;
|
||||
|
||||
/// Lists all calendars for a specific user
|
||||
/// Lists all calendars owned by a specific user. Post-Round-3 the
|
||||
/// service layer prefers `authz.list_incoming_grants` (surfaces
|
||||
/// owned + shared in one union), but this direct lookup remains
|
||||
/// available for internal maintenance / migration paths that need
|
||||
/// owner-only enumeration without going through the engine.
|
||||
async fn list_calendars_by_owner(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
@@ -31,12 +42,6 @@ pub trait CalendarRepository: Send + Sync + 'static {
|
||||
owner_id: Uuid,
|
||||
) -> CalendarRepositoryResult<Calendar>;
|
||||
|
||||
/// Lists calendars shared with a specific user
|
||||
async fn list_calendars_shared_with_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> CalendarRepositoryResult<Vec<Calendar>>;
|
||||
|
||||
/// List public calendars
|
||||
async fn list_public_calendars(
|
||||
&self,
|
||||
@@ -44,13 +49,6 @@ pub trait CalendarRepository: Send + Sync + 'static {
|
||||
offset: i64,
|
||||
) -> CalendarRepositoryResult<Vec<Calendar>>;
|
||||
|
||||
/// Checks if a user has access to a calendar
|
||||
async fn user_has_calendar_access(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
) -> CalendarRepositoryResult<bool>;
|
||||
|
||||
/// Gets a custom property for a calendar
|
||||
async fn get_calendar_property(
|
||||
&self,
|
||||
@@ -78,25 +76,4 @@ pub trait CalendarRepository: Send + Sync + 'static {
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
) -> CalendarRepositoryResult<std::collections::HashMap<String, String>>;
|
||||
|
||||
/// Share calendar with another user
|
||||
async fn share_calendar(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
access_level: &str,
|
||||
) -> CalendarRepositoryResult<()>;
|
||||
|
||||
/// Remove calendar sharing for a user
|
||||
async fn remove_calendar_sharing(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
) -> CalendarRepositoryResult<()>;
|
||||
|
||||
/// Get calendar sharing information (who has access to this calendar)
|
||||
async fn get_calendar_shares(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
) -> CalendarRepositoryResult<Vec<(String, String)>>;
|
||||
}
|
||||
|
||||
@@ -78,11 +78,20 @@ pub enum Resource {
|
||||
/// membership and policy bag. Added in D0; membership lives in
|
||||
/// `storage.role_grants` (no separate `drive_members` table).
|
||||
Drive(Uuid),
|
||||
// Reserved for future use:
|
||||
// Calendar(Uuid),
|
||||
// Reserved for future use:
|
||||
// AddressBook(Uuid),
|
||||
// Reserved for future use:
|
||||
/// A CalDAV calendar. Membership + sharing lives in
|
||||
/// `storage.role_grants` with `resource_type='calendar'` —
|
||||
/// replaces the pre-Round-3 dedicated `caldav.calendar_shares`
|
||||
/// table and the `check_calendar_access` bespoke helper. No
|
||||
/// cascade parent (calendars are top-level per user); the engine
|
||||
/// resolves directly against `role_grants` on the resource.
|
||||
Calendar(Uuid),
|
||||
/// A CardDAV address book. Same shape as `Calendar` —
|
||||
/// `storage.role_grants` with `resource_type='address_book'`
|
||||
/// replaces `carddav.address_book_shares` and the
|
||||
/// `check_address_book_access` bespoke helper.
|
||||
AddressBook(Uuid),
|
||||
// Reserved for future use — same shape but tracked separately
|
||||
// (music-service rewrite is its own PR):
|
||||
// Playlist(Uuid),
|
||||
}
|
||||
|
||||
@@ -92,17 +101,19 @@ impl Resource {
|
||||
Resource::Folder(_) => "folder",
|
||||
Resource::File(_) => "file",
|
||||
Resource::Drive(_) => "drive",
|
||||
//Resource::Calendar(_) => "calendar",
|
||||
//Resource::AddressBook(_) => "adressbook",
|
||||
Resource::Calendar(_) => "calendar",
|
||||
Resource::AddressBook(_) => "address_book",
|
||||
//Resource::Playlist(_) => "playlist",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> Uuid {
|
||||
match self {
|
||||
Resource::Folder(id) | Resource::File(id) | Resource::Drive(id) => *id,
|
||||
//| Resource::Calendar(id)
|
||||
//| Resource::AddressBook(id)
|
||||
Resource::Folder(id)
|
||||
| Resource::File(id)
|
||||
| Resource::Drive(id)
|
||||
| Resource::Calendar(id)
|
||||
| Resource::AddressBook(id) => *id,
|
||||
//| Resource::Playlist(id)
|
||||
}
|
||||
}
|
||||
@@ -112,8 +123,8 @@ impl Resource {
|
||||
"folder" => Some(Resource::Folder(id)),
|
||||
"file" => Some(Resource::File(id)),
|
||||
"drive" => Some(Resource::Drive(id)),
|
||||
//"calendar" => Some(Resource::Calendar(id)),
|
||||
//"adressbook" => Some(Resource::AddressBook(id)),
|
||||
"calendar" => Some(Resource::Calendar(id)),
|
||||
"address_book" => Some(Resource::AddressBook(id)),
|
||||
//"playlist" => Some(Resource::Playlist(id)),
|
||||
_ => None,
|
||||
}
|
||||
@@ -536,11 +547,17 @@ mod tests {
|
||||
#[test]
|
||||
fn resource_roundtrip() {
|
||||
let id = Uuid::new_v4();
|
||||
for r in [Resource::Folder(id), Resource::File(id)] {
|
||||
for r in [
|
||||
Resource::Folder(id),
|
||||
Resource::File(id),
|
||||
Resource::Calendar(id),
|
||||
Resource::AddressBook(id),
|
||||
] {
|
||||
let back = Resource::from_parts(r.type_str(), r.id()).unwrap();
|
||||
assert_eq!(r, back);
|
||||
}
|
||||
assert!(Resource::from_parts("calendar", id).is_none());
|
||||
// `playlist` is still pending the Music AuthZ migration.
|
||||
assert!(Resource::from_parts("playlist", id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -126,17 +126,6 @@ impl CalendarStoragePort for CalendarStorageAdapter {
|
||||
Ok(calendars.into_iter().map(CalendarDto::from).collect())
|
||||
}
|
||||
|
||||
async fn list_calendars_shared_with_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<CalendarDto>, DomainError> {
|
||||
let calendars = self
|
||||
.calendar_repository
|
||||
.list_calendars_shared_with_user(user_id)
|
||||
.await?;
|
||||
Ok(calendars.into_iter().map(CalendarDto::from).collect())
|
||||
}
|
||||
|
||||
async fn list_public_calendars(
|
||||
&self,
|
||||
limit: i64,
|
||||
@@ -149,78 +138,6 @@ impl CalendarStoragePort for CalendarStorageAdapter {
|
||||
Ok(calendars.into_iter().map(CalendarDto::from).collect())
|
||||
}
|
||||
|
||||
async fn check_calendar_access(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<bool, DomainError> {
|
||||
let uuid = Uuid::parse_str(calendar_id).map_err(|_| {
|
||||
DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Calendar",
|
||||
"Invalid calendar ID format",
|
||||
)
|
||||
})?;
|
||||
|
||||
self.calendar_repository
|
||||
.user_has_calendar_access(&uuid, user_id)
|
||||
.await
|
||||
}
|
||||
|
||||
// Calendar sharing
|
||||
|
||||
async fn share_calendar(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
access_level: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let uuid = Uuid::parse_str(calendar_id).map_err(|_| {
|
||||
DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Calendar",
|
||||
"Invalid calendar ID format",
|
||||
)
|
||||
})?;
|
||||
|
||||
self.calendar_repository
|
||||
.share_calendar(&uuid, user_id, access_level)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn remove_calendar_sharing(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let uuid = Uuid::parse_str(calendar_id).map_err(|_| {
|
||||
DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Calendar",
|
||||
"Invalid calendar ID format",
|
||||
)
|
||||
})?;
|
||||
|
||||
self.calendar_repository
|
||||
.remove_calendar_sharing(&uuid, user_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_calendar_shares(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
) -> Result<Vec<(String, String)>, DomainError> {
|
||||
let uuid = Uuid::parse_str(calendar_id).map_err(|_| {
|
||||
DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Calendar",
|
||||
"Invalid calendar ID format",
|
||||
)
|
||||
})?;
|
||||
|
||||
self.calendar_repository.get_calendar_shares(&uuid).await
|
||||
}
|
||||
|
||||
// Calendar properties
|
||||
|
||||
async fn set_calendar_property(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -184,44 +184,6 @@ impl AddressBookRepository for AddressBookPgRepository {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn get_shared_address_books(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> AddressBookRepositoryResult<Vec<AddressBook>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT a.id, a.name, a.owner_id, a.description, a.color, a.is_public, a.created_at, a.updated_at
|
||||
FROM carddav.address_books a
|
||||
INNER JOIN carddav.address_book_shares s ON a.id = s.address_book_id
|
||||
WHERE s.user_id = $1
|
||||
ORDER BY a.name
|
||||
"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::database_error(format!("Failed to get shared address books: {}", e)))?;
|
||||
|
||||
let result = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let owner_id: Uuid = row.get("owner_id");
|
||||
AddressBook::from_raw(
|
||||
row.get("id"),
|
||||
row.get("name"),
|
||||
owner_id.to_string(),
|
||||
row.get("description"),
|
||||
row.get("color"),
|
||||
row.get("is_public"),
|
||||
row.get("created_at"),
|
||||
row.get("updated_at"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn get_public_address_books(&self) -> AddressBookRepositoryResult<Vec<AddressBook>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
@@ -256,79 +218,4 @@ impl AddressBookRepository for AddressBookPgRepository {
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn share_address_book(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
can_write: bool,
|
||||
) -> AddressBookRepositoryResult<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO carddav.address_book_shares (address_book_id, user_id, can_write)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (address_book_id, user_id) DO UPDATE SET can_write = $3
|
||||
"#,
|
||||
)
|
||||
.bind(address_book_id)
|
||||
.bind(user_id)
|
||||
.bind(can_write)
|
||||
.execute(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::database_error(format!("Failed to share address book: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn unshare_address_book(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
) -> AddressBookRepositoryResult<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM carddav.address_book_shares
|
||||
WHERE address_book_id = $1 AND user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(address_book_id)
|
||||
.bind(user_id)
|
||||
.execute(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::database_error(format!("Failed to unshare address book: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_address_book_shares(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
) -> AddressBookRepositoryResult<Vec<(String, bool)>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT user_id, can_write
|
||||
FROM carddav.address_book_shares
|
||||
WHERE address_book_id = $1
|
||||
ORDER BY user_id
|
||||
"#,
|
||||
)
|
||||
.bind(address_book_id)
|
||||
.fetch_all(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::database_error(format!("Failed to get address book shares: {}", e))
|
||||
})?;
|
||||
|
||||
let result = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let user_id: Uuid = row.get("user_id");
|
||||
(user_id.to_string(), row.get("can_write"))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,44 +216,6 @@ impl CalendarRepository for CalendarPgRepository {
|
||||
Ok(calendar)
|
||||
}
|
||||
|
||||
async fn list_calendars_shared_with_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> CalendarRepositoryResult<Vec<Calendar>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT c.id, c.name, c.owner_id, c.description, c.color, c.is_public, c.created_at, c.updated_at
|
||||
FROM caldav.calendars c
|
||||
INNER JOIN caldav.calendar_shares s ON c.id = s.calendar_id
|
||||
WHERE s.user_id = $1
|
||||
ORDER BY c.name
|
||||
"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::database_error(format!("Failed to get shared calendars: {}", e)))?;
|
||||
|
||||
let mut calendars = Vec::new();
|
||||
for row in rows {
|
||||
let calendar = Calendar::with_id(
|
||||
row.get("id"),
|
||||
row.get("name"),
|
||||
row.get("owner_id"),
|
||||
row.get("description"),
|
||||
row.get("color"),
|
||||
row.get("created_at"),
|
||||
row.get("updated_at"),
|
||||
)
|
||||
.map_err(|e| {
|
||||
DomainError::database_error(format!("Failed to create calendar object: {}", e))
|
||||
})?;
|
||||
calendars.push(calendar);
|
||||
}
|
||||
|
||||
Ok(calendars)
|
||||
}
|
||||
|
||||
async fn list_public_calendars(
|
||||
&self,
|
||||
limit: i64,
|
||||
@@ -296,112 +258,6 @@ impl CalendarRepository for CalendarPgRepository {
|
||||
Ok(calendars)
|
||||
}
|
||||
|
||||
async fn user_has_calendar_access(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
) -> CalendarRepositoryResult<bool> {
|
||||
// Check if the user is the owner of the calendar or has a share
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM caldav.calendars c
|
||||
WHERE c.id = $1 AND (c.owner_id = $2 OR c.is_public = true)
|
||||
UNION
|
||||
SELECT 1 FROM caldav.calendar_shares s
|
||||
WHERE s.calendar_id = $1 AND s.user_id = $2
|
||||
) as has_access
|
||||
"#,
|
||||
)
|
||||
.bind(calendar_id)
|
||||
.bind(user_id)
|
||||
.fetch_one(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::database_error(format!("Failed to check calendar access: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(row.get::<bool, _>("has_access"))
|
||||
}
|
||||
|
||||
async fn share_calendar(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
access_level: &str,
|
||||
) -> CalendarRepositoryResult<()> {
|
||||
// Validate access level
|
||||
if !["read", "write", "owner"].contains(&access_level) {
|
||||
return Err(DomainError::validation_error(format!(
|
||||
"Invalid access level: '{}'. Must be 'read', 'write', or 'owner'",
|
||||
access_level
|
||||
)));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO caldav.calendar_shares (calendar_id, user_id, access_level)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (calendar_id, user_id) DO UPDATE SET access_level = $3
|
||||
"#,
|
||||
)
|
||||
.bind(calendar_id)
|
||||
.bind(user_id)
|
||||
.bind(access_level)
|
||||
.execute(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::database_error(format!("Failed to share calendar: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_calendar_sharing(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
) -> CalendarRepositoryResult<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM caldav.calendar_shares
|
||||
WHERE calendar_id = $1 AND user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(calendar_id)
|
||||
.bind(user_id)
|
||||
.execute(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::database_error(format!("Failed to unshare calendar: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_calendar_shares(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
) -> CalendarRepositoryResult<Vec<(String, String)>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT user_id, access_level
|
||||
FROM caldav.calendar_shares
|
||||
WHERE calendar_id = $1
|
||||
ORDER BY user_id
|
||||
"#,
|
||||
)
|
||||
.bind(calendar_id)
|
||||
.fetch_all(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::database_error(format!("Failed to get calendar shares: {}", e))
|
||||
})?;
|
||||
|
||||
let mut shares = Vec::new();
|
||||
for row in rows {
|
||||
shares.push((row.get("user_id"), row.get("access_level")));
|
||||
}
|
||||
|
||||
Ok(shares)
|
||||
}
|
||||
|
||||
async fn get_calendar_property(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
|
||||
@@ -479,11 +479,18 @@ impl PgAclEngine {
|
||||
/// Returns the `drive_id` for a File / Folder. Drives don't have a parent
|
||||
/// drive — this returns `NotFound` for `Resource::Drive` and the caller
|
||||
/// must not invoke it on Drive resources.
|
||||
///
|
||||
/// `Resource::Calendar` and `Resource::AddressBook` are top-level per
|
||||
/// user with no drive ancestor; they also return `NotFound` and the
|
||||
/// engine short-circuits to a direct `role_grants` lookup (no drive
|
||||
/// precheck applies).
|
||||
async fn drive_of(&self, resource: Resource) -> Result<Uuid, DomainError> {
|
||||
match resource {
|
||||
Resource::Folder(id) => self.folder_repo.get_folder_drive_id(&id.to_string()).await,
|
||||
Resource::File(id) => self.file_repo.get_file_drive_id(&id.to_string()).await,
|
||||
Resource::Drive(_) => Err(DomainError::not_found("Drive", resource.id().to_string())),
|
||||
Resource::Drive(_) | Resource::Calendar(_) | Resource::AddressBook(_) => Err(
|
||||
DomainError::not_found(resource.type_str(), resource.id().to_string()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,6 +526,49 @@ impl PgAclEngine {
|
||||
/// permission — see `roles_implying()`.
|
||||
///
|
||||
/// Uses the GiST index on `storage.folders.lpath` for O(log N) cascade.
|
||||
/// Direct grant lookup with no cascade — used for top-level
|
||||
/// resources whose ACL lives entirely on their own row
|
||||
/// (`Resource::Calendar`, `Resource::AddressBook`). Same
|
||||
/// role-array + subject-set shape as the cascade helpers so a
|
||||
/// caller's group memberships still resolve, but no ltree /
|
||||
/// folder ancestry / drive precheck applies. Calendars and
|
||||
/// address books have no parent to inherit from.
|
||||
async fn direct_grant_exists(
|
||||
&self,
|
||||
subject_types: &[&str],
|
||||
subject_ids: &[Uuid],
|
||||
permission: Permission,
|
||||
resource_type: &'static str,
|
||||
resource_id: Uuid,
|
||||
counters: &QueryCounters,
|
||||
) -> Result<bool, DomainError> {
|
||||
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
||||
let roles = Self::roles_implying_strings(permission);
|
||||
let exists: Option<i32> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT 1
|
||||
FROM storage.role_grants g
|
||||
WHERE g.subject_type = ANY($1)
|
||||
AND g.subject_id = ANY($2)
|
||||
AND g.role = ANY($3::storage.grant_role[])
|
||||
AND g.resource_type = $4
|
||||
AND g.resource_id = $5
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(subject_types)
|
||||
.bind(subject_ids)
|
||||
.bind(&roles)
|
||||
.bind(resource_type)
|
||||
.bind(resource_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("direct grant: {e}")))?;
|
||||
|
||||
Ok(exists.is_some())
|
||||
}
|
||||
|
||||
async fn folder_cascade_grant_exists(
|
||||
&self,
|
||||
subject_types: &[&str],
|
||||
@@ -819,6 +869,39 @@ impl PgAclEngine {
|
||||
.await?
|
||||
.is_some_and(|r| r.expand().contains(&permission)))
|
||||
}
|
||||
// Top-level resources with no cascade parent — the ACL
|
||||
// lives entirely on their own `role_grants` rows. Owner is
|
||||
// an explicit grant seeded at MKCALENDAR / address-book
|
||||
// create time (Round 3 phase 2 migration), so the common
|
||||
// "owner accessing their own calendar" case is one SQL
|
||||
// round-trip — no drive_role_cache short-circuit (no
|
||||
// drive), no cascade.
|
||||
Resource::Calendar(id) => {
|
||||
let (subject_types, subject_ids) =
|
||||
self.subject_match_set(subject, counters).await?;
|
||||
self.direct_grant_exists(
|
||||
&subject_types,
|
||||
&subject_ids,
|
||||
permission,
|
||||
"calendar",
|
||||
id,
|
||||
counters,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Resource::AddressBook(id) => {
|
||||
let (subject_types, subject_ids) =
|
||||
self.subject_match_set(subject, counters).await?;
|
||||
self.direct_grant_exists(
|
||||
&subject_types,
|
||||
&subject_ids,
|
||||
permission,
|
||||
"address_book",
|
||||
id,
|
||||
counters,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +346,18 @@ async fn handle_propfind(
|
||||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||
.body(Body::from(response_body))
|
||||
.unwrap())
|
||||
} else if first_is_uuid {
|
||||
// Path segment IS a UUID but the calendar isn't
|
||||
// accessible to the caller — could be another
|
||||
// owner's calendar or genuinely missing. Return
|
||||
// 404 (anti-enum, matches every other OxiCloud
|
||||
// surface post-D7). The pre-Round-3 fall-through
|
||||
// silently listed the caller's OWN calendars,
|
||||
// which was misleading (the URL claimed one calendar,
|
||||
// response returned unrelated ones) and violated
|
||||
// the anti-enumeration contract audited in
|
||||
// `docs/plan/authz_audit/caldav_carddav_wopi.md`.
|
||||
Err(AppError::not_found("Calendar not found"))
|
||||
} else {
|
||||
// Not a calendar ID — treat as user calendar home (e.g. /caldav/{username}/)
|
||||
// List all calendars for this user
|
||||
|
||||
@@ -33,8 +33,8 @@ use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType
|
||||
use crate::application::dtos::address_book_dto::{CreateAddressBookDto, UpdateAddressBookDto};
|
||||
use crate::application::dtos::contact_dto::CreateContactVCardDto;
|
||||
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
|
||||
use crate::application::services::contact_service::ContactService;
|
||||
use crate::common::di::AppState;
|
||||
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
|
||||
|
||||
@@ -177,7 +177,7 @@ fn extract_user(req: &Request<Body>) -> Result<AuthUser, AppError> {
|
||||
.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(|| {
|
||||
AppError::new(
|
||||
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(|| {
|
||||
AppError::new(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
|
||||
@@ -19,8 +19,8 @@ use crate::application::dtos::contact_dto::{
|
||||
use crate::application::dtos::user_dto::UserDto;
|
||||
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
|
||||
use crate::application::services::auth_application_service::AuthApplicationService;
|
||||
use crate::application::services::contact_service::ContactService;
|
||||
use crate::domain::errors::ErrorKind;
|
||||
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
const SYSTEM_BOOK_ID: &str = "system";
|
||||
@@ -28,7 +28,7 @@ const SYSTEM_BOOK_ID: &str = "system";
|
||||
/// Combined state for the contacts REST API.
|
||||
#[derive(Clone)]
|
||||
pub struct ContactsApiState {
|
||||
pub contact_service: Arc<ContactStorageAdapter>,
|
||||
pub contact_service: Arc<ContactService>,
|
||||
pub auth_service: Option<Arc<AuthApplicationService>>,
|
||||
/// When false, the virtual "system" address book (OxiCloud users) is hidden.
|
||||
pub expose_system_users: bool,
|
||||
|
||||
@@ -113,6 +113,14 @@ pub async fn create_grant(
|
||||
.get_by_id(id)
|
||||
.await
|
||||
.map(|d| d.drive.typed_policies()),
|
||||
// Calendars and address books live outside the drive
|
||||
// hierarchy (top-level per user), so no drive-level policy
|
||||
// gates apply. If per-calendar / per-address-book policies
|
||||
// ever ship, they'll live on the resource itself, not on a
|
||||
// drive; the default-empty bag is the right no-op here.
|
||||
Resource::Calendar(_) | Resource::AddressBook(_) => {
|
||||
Ok(crate::domain::entities::drive::DrivePolicies::default())
|
||||
}
|
||||
};
|
||||
let drive_policies = match drive_policies {
|
||||
Ok(p) => p,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
# =============================================================
|
||||
# OxiCloud – CalDAV + Round-3 AuthZ end-to-end scenario
|
||||
# =============================================================
|
||||
# Verifies the full CalDAV surface post-Round-3:
|
||||
#
|
||||
# * MKCALENDAR / PROPFIND / DELETE against `/caldav/*` all
|
||||
# route through `CalendarService`, which enforces
|
||||
# `authz.require` on every method.
|
||||
# * Cross-user access uses the 404 anti-enum shape (was 403
|
||||
# in the bespoke `check_calendar_access` era).
|
||||
# * Sharing goes through the generic `POST /api/grants` with
|
||||
# `resource.type = "calendar"` — a first-class ReBAC
|
||||
# resource variant added in Round 3 Phase 1.
|
||||
# * A shared calendar shows up in the recipient's PROPFIND
|
||||
# listing while the grant is live and disappears again
|
||||
# after revoke.
|
||||
#
|
||||
# The `calendar_id` is server-assigned at MKCALENDAR time and
|
||||
# surfaces in the PROPFIND response as `/caldav/<uuid>/`. We
|
||||
# extract it with a regex on the response body — the fresh CI
|
||||
# database (`tests/webdav/run.sh` spawns a private Postgres)
|
||||
# guarantees admin has zero pre-existing calendars, so the
|
||||
# first-match regex is unambiguous.
|
||||
#
|
||||
# CalDAV auth is JWT via the same middleware the REST API uses
|
||||
# (`/caldav/*` and `/carddav/*` are both wrapped in
|
||||
# `auth_middleware + require_internal_user_layer` in main.rs).
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 – Alice (admin) logs in.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "{{username}}",
|
||||
"password": "{{password}}"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
alice_token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 – MKCALENDAR: create a fresh calendar for the test.
|
||||
# Empty body → the CalDAV handler derives the display name
|
||||
# from the last path segment ("round3-cal" here). The response
|
||||
# is 201 with an empty body — CalDAV convention. The
|
||||
# server-assigned UUID is captured in Step 3 via PROPFIND.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MKCALENDAR {{base_url}}/caldav/round3-cal/
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 – Alice PROPFIND at Depth 1 lists her calendars.
|
||||
# The response is a `<D:multistatus>` — each calendar surfaces
|
||||
# as `<D:href>/caldav/<uuid>/</D:href>`. Regex-capture the
|
||||
# UUID (first `/caldav/<uuid>/` in the body — the root href
|
||||
# is `/caldav/` alone, no UUID, so it can't match).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/caldav/
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Depth: 1
|
||||
Content-Type: application/xml
|
||||
```
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:displayname/>
|
||||
<D:resourcetype/>
|
||||
</D:prop>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Captures]
|
||||
calendar_id: body regex "/caldav/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 – Provision Bob. Idempotent: `HTTP *` accepts 201
|
||||
# on the first run and 409 on subsequent ones. Login is the
|
||||
# actual precondition.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "caldav_bob",
|
||||
"password": "CaldavBobPassword1!",
|
||||
"email": "caldav_bob@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
|
||||
HTTP *
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "caldav_bob",
|
||||
"password": "CaldavBobPassword1!"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
bob_token: jsonpath "$.access_token"
|
||||
bob_user_id: jsonpath "$.user.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 – Cross-user PROPFIND. Bob has no grant on Alice's
|
||||
# calendar; his listing does NOT include the calendar's UUID.
|
||||
# (Bob's OWN response body will list his lifecycle-provisioned
|
||||
# calendars — none of them collide with Alice's UUID.)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/caldav/
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Depth: 1
|
||||
Content-Type: application/xml
|
||||
```
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop><D:displayname/><D:resourcetype/></D:prop>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
body not contains "{{calendar_id}}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 – Cross-user direct PROPFIND on Alice's calendar
|
||||
# → 404. `authz.require(Read)` denies with `NotFound` for
|
||||
# anti-enumeration parity with files/folders/drives.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/caldav/{{calendar_id}}/
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Depth: 0
|
||||
Content-Type: application/xml
|
||||
```
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop><D:displayname/></D:prop>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP *
|
||||
[Asserts]
|
||||
status >= 400
|
||||
status < 500
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 – Alice shares the calendar with Bob as Viewer via
|
||||
# the generic ReBAC grant endpoint. `resource.type = "calendar"`
|
||||
# is a first-class variant post-Round-3.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{bob_user_id}}" },
|
||||
"resource": { "type": "calendar", "id": "{{calendar_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
share_grant_id: jsonpath "$.grants[0].id"
|
||||
[Asserts]
|
||||
jsonpath "$.grants[0].role" == "viewer"
|
||||
jsonpath "$.grants[0].resource.type" == "calendar"
|
||||
jsonpath "$.grants[0].resource.id" == "{{calendar_id}}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 – Bob PROPFIND now includes Alice's calendar. The
|
||||
# `list_my_calendars` service method reads
|
||||
# `authz.list_incoming_grants(user)` and unions across
|
||||
# owned + shared, replacing the pre-Round-3 owner-only query.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/caldav/
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Depth: 1
|
||||
Content-Type: application/xml
|
||||
```
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop><D:displayname/><D:resourcetype/></D:prop>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
body contains "{{calendar_id}}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 – Alice revokes the grant. `DELETE /api/grants/{id}`
|
||||
# maps to a single `role_grants` row delete.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/grants/{{share_grant_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 – Bob PROPFIND no longer includes Alice's calendar.
|
||||
# The role_grants row is gone, so `list_incoming_grants` won't
|
||||
# surface it and `list_my_calendars` collapses back to Bob's
|
||||
# own.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/caldav/
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Depth: 1
|
||||
Content-Type: application/xml
|
||||
```
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop><D:displayname/><D:resourcetype/></D:prop>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
body not contains "{{calendar_id}}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 – Cleanup: Alice deletes the calendar. The service
|
||||
# runs `authz.require(Delete)` (owner passes via the seeded
|
||||
# Owner grant), then `revoke_all_for_resource` wipes any
|
||||
# remaining grants on the calendar in case a share slipped
|
||||
# through.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/caldav/{{calendar_id}}/
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP *
|
||||
[Asserts]
|
||||
status >= 200
|
||||
status < 300
|
||||
@@ -276,3 +276,318 @@ Authorization: Bearer {{token}}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$" isCollection
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Round 3 — CardDAV/AddressBook AuthZ regression
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Post-Round-3, address-book access + sharing routes through
|
||||
# `AuthorizationEngine` and `storage.role_grants`. The dedicated
|
||||
# `carddav.address_book_shares` table stopped being consulted;
|
||||
# the generic `POST /api/grants` endpoint accepts
|
||||
# `resource.type = "address_book"` as a first-class ReBAC
|
||||
# resource.
|
||||
#
|
||||
# Coverage:
|
||||
# 15. Fresh book owned by admin (Alice).
|
||||
# 16. Non-member user (Bob) doesn't see the book.
|
||||
# 17. Bob's direct GET on the book → 404 (anti-enum, was 403
|
||||
# pre-Round-3).
|
||||
# 18. Alice shares with Bob as Viewer via `POST /api/grants`.
|
||||
# 19. Bob's listing includes the book with is_readonly=true.
|
||||
# 20. Viewer role's bundle has no Create — Bob's contact
|
||||
# write → 404 (anti-enum).
|
||||
# 21. Alice revokes via `DELETE /api/grants/{id}`.
|
||||
# 22. Bob no longer sees the book.
|
||||
# 23. Cleanup.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# Step 15 — Alice creates a fresh book for the share regression.
|
||||
POST {{base_url}}/api/address-books
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"name": "Round3 Share Book",
|
||||
"description": "Book for the multi-user share regression",
|
||||
"is_public": false
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
share_book_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# Step 16 — Provision Bob. Idempotent: accept 201 on first run,
|
||||
# 409 on subsequent runs; login is the actual precondition.
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "carddav_bob",
|
||||
"password": "CarddavBobPassword1!",
|
||||
"email": "carddav_bob@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
|
||||
HTTP *
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "carddav_bob",
|
||||
"password": "CarddavBobPassword1!"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
bob_token: jsonpath "$.access_token"
|
||||
bob_user_id: jsonpath "$.user.id"
|
||||
|
||||
|
||||
# Step 17 — Bob's book listing does NOT include Alice's book.
|
||||
GET {{base_url}}/api/address-books
|
||||
Authorization: Bearer {{bob_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[*].id" not contains {{share_book_id}}
|
||||
|
||||
|
||||
# Step 18a — Direct GET on Alice's book: 404 (anti-enum).
|
||||
GET {{base_url}}/api/address-books/{{share_book_id}}/contacts
|
||||
Authorization: Bearer {{bob_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# Step 18b — Contact-write into Alice's book: 404. Bob has no
|
||||
# grant, so authz.require(Create) rejects with NotFound.
|
||||
# Body is minimal on purpose — the endpoint's wire DTO
|
||||
# (`CreateContactRequest`) marks every collection field
|
||||
# `#[serde(default)]`, so `full_name` alone deserialises
|
||||
# fine and lets the request reach the authz gate. Any
|
||||
# body-side 422 here would mask the AuthZ regression the
|
||||
# step is meant to verify.
|
||||
POST {{base_url}}/api/address-books/{{share_book_id}}/contacts
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"full_name": "Sneaky Insert"
|
||||
}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# Step 19 — Alice shares the book with Bob as Viewer via the
|
||||
# generic ReBAC grant endpoint. `resource.type = "address_book"`
|
||||
# is a first-class variant post-Round-3.
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{bob_user_id}}" },
|
||||
"resource": { "type": "address_book", "id": "{{share_book_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
share_grant_id: jsonpath "$.grants[0].id"
|
||||
[Asserts]
|
||||
jsonpath "$.grants[0].role" == "viewer"
|
||||
jsonpath "$.grants[0].resource.type" == "address_book"
|
||||
jsonpath "$.grants[0].resource.id" == "{{share_book_id}}"
|
||||
|
||||
|
||||
# Step 20 — Bob's listing now includes the book, marked readonly
|
||||
# because he's not the owner.
|
||||
GET {{base_url}}/api/address-books
|
||||
Authorization: Bearer {{bob_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[?(@.id == '{{share_book_id}}')].is_readonly" == true
|
||||
|
||||
|
||||
# Step 21 — Viewer bundle has no Create permission — Bob's
|
||||
# contact write still 404s. Same minimal-body reasoning as
|
||||
# Step 18b: keep the request valid at the wire layer so any
|
||||
# rejection has to come from the AuthZ engine.
|
||||
POST {{base_url}}/api/address-books/{{share_book_id}}/contacts
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"full_name": "Viewer Cannot Write"
|
||||
}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# Step 22 — Alice revokes the grant.
|
||||
DELETE {{base_url}}/api/grants/{{share_grant_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# Step 23 — Bob's listing no longer includes the book.
|
||||
GET {{base_url}}/api/address-books
|
||||
Authorization: Bearer {{bob_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[*].id" not contains {{share_book_id}}
|
||||
|
||||
|
||||
# Step 24 — Cleanup: Alice deletes the book.
|
||||
DELETE {{base_url}}/api/address-books/{{share_book_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Round 3 — CardDAV protocol coverage
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Verifies the CardDAV surface end-to-end:
|
||||
#
|
||||
# * MKCOL creates an address book via the CardDAV protocol
|
||||
# (`ContactService::create_address_book` seeds an Owner
|
||||
# role_grant on the caller so the engine's cache warms).
|
||||
# * PROPFIND lists it in the caller's address-book home.
|
||||
# * A non-member's PROPFIND doesn't include the book.
|
||||
# * `POST /api/grants` with `resource.type = "address_book"`
|
||||
# grants Read to the non-member.
|
||||
# * The recipient's PROPFIND now includes the book.
|
||||
# * Revoke → book vanishes.
|
||||
# * DELETE cleans up.
|
||||
#
|
||||
# Book UUID is server-assigned at MKCOL time and appears in the
|
||||
# PROPFIND multistatus as `<D:href>/carddav/<uuid>/</D:href>`.
|
||||
# Regex-capture is unambiguous only if admin has zero
|
||||
# pre-existing CardDAV books — true on the CI DB (fresh from
|
||||
# `tests/webdav/run.sh`'s private Postgres), false in a
|
||||
# populated dev DB.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# Step 25 — Alice creates a fresh book via CardDAV MKCOL.
|
||||
# Empty body — `handle_mkcol` derives the display name from the
|
||||
# path's last segment.
|
||||
MKCOL {{base_url}}/carddav/round3-carddav-book/
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# Step 26 — Alice PROPFIND at Depth 1 lists her books. Capture
|
||||
# the server-assigned UUID with a regex on the `<D:href>` value.
|
||||
PROPFIND {{base_url}}/carddav/
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 1
|
||||
Content-Type: application/xml
|
||||
```
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:displayname/>
|
||||
<D:resourcetype/>
|
||||
</D:prop>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Captures]
|
||||
carddav_book_id: body regex "/carddav/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/"
|
||||
|
||||
|
||||
# Step 27 — Bob PROPFIND: the book UUID is NOT in his response.
|
||||
# (Bob's lifecycle-provisioned books, if any, get their own
|
||||
# UUIDs — no collision.)
|
||||
PROPFIND {{base_url}}/carddav/
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Depth: 1
|
||||
Content-Type: application/xml
|
||||
```
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop><D:displayname/><D:resourcetype/></D:prop>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
body not contains "{{carddav_book_id}}"
|
||||
|
||||
|
||||
# Step 28 — Alice shares the book with Bob as Viewer via the
|
||||
# generic ReBAC grant endpoint (same wire format as the
|
||||
# calendar test, only the resource type differs).
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{bob_user_id}}" },
|
||||
"resource": { "type": "address_book", "id": "{{carddav_book_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
carddav_grant_id: jsonpath "$.grants[0].id"
|
||||
|
||||
|
||||
# Step 29 — Bob PROPFIND now includes the shared book. The
|
||||
# CardDAV handler routes through the same
|
||||
# `list_user_address_books` as the REST API, so the shared
|
||||
# book flows in via the role_grants union.
|
||||
PROPFIND {{base_url}}/carddav/
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Depth: 1
|
||||
Content-Type: application/xml
|
||||
```
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop><D:displayname/><D:resourcetype/></D:prop>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
body contains "{{carddav_book_id}}"
|
||||
|
||||
|
||||
# Step 30 — Alice revokes the grant.
|
||||
DELETE {{base_url}}/api/grants/{{carddav_grant_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# Step 31 — Bob PROPFIND no longer includes the book.
|
||||
PROPFIND {{base_url}}/carddav/
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Depth: 1
|
||||
Content-Type: application/xml
|
||||
```
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop><D:displayname/><D:resourcetype/></D:prop>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
body not contains "{{carddav_book_id}}"
|
||||
|
||||
|
||||
# Step 32 — Cleanup: Alice deletes the book via CardDAV DELETE.
|
||||
DELETE {{base_url}}/carddav/{{carddav_book_id}}/
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP *
|
||||
[Asserts]
|
||||
status >= 200
|
||||
status < 300
|
||||
|
||||
@@ -161,6 +161,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
||||
"$API_DIR/batch_folder_copy.hurl" \
|
||||
"$API_DIR/dedup_blob_cleanup.hurl" \
|
||||
"$API_DIR/contacts.hurl" \
|
||||
"$API_DIR/calendar.hurl" \
|
||||
"$API_DIR/public_shares.hurl" \
|
||||
"$API_DIR/permissions.hurl" \
|
||||
"$API_DIR/grants.hurl" \
|
||||
|
||||
Reference in New Issue
Block a user