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 is_public: Option<bool>,
|
||||||
pub user_id: String, // Current user making the update
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ResourceTypeDto {
|
pub enum ResourceTypeDto {
|
||||||
Folder,
|
Folder,
|
||||||
File,
|
File,
|
||||||
Drive,
|
Drive,
|
||||||
|
Calendar,
|
||||||
|
AddressBook,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
@@ -75,6 +77,8 @@ impl From<ResourceDto> for Resource {
|
|||||||
ResourceTypeDto::Folder => Resource::Folder(dto.id),
|
ResourceTypeDto::Folder => Resource::Folder(dto.id),
|
||||||
ResourceTypeDto::File => Resource::File(dto.id),
|
ResourceTypeDto::File => Resource::File(dto.id),
|
||||||
ResourceTypeDto::Drive => Resource::Drive(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::Folder(id) => (ResourceTypeDto::Folder, id),
|
||||||
Resource::File(id) => (ResourceTypeDto::File, id),
|
Resource::File(id) => (ResourceTypeDto::File, id),
|
||||||
Resource::Drive(id) => (ResourceTypeDto::Drive, id),
|
Resource::Drive(id) => (ResourceTypeDto::Drive, id),
|
||||||
|
Resource::Calendar(id) => (ResourceTypeDto::Calendar, id),
|
||||||
|
Resource::AddressBook(id) => (ResourceTypeDto::AddressBook, id),
|
||||||
};
|
};
|
||||||
ResourceDto { kind, id }
|
ResourceDto { kind, id }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
|||||||
Resource::Folder(id) => ("Folder", id),
|
Resource::Folder(id) => ("Folder", id),
|
||||||
Resource::File(id) => ("File", id),
|
Resource::File(id) => ("File", id),
|
||||||
Resource::Drive(id) => ("Drive", id),
|
Resource::Drive(id) => ("Drive", id),
|
||||||
|
Resource::Calendar(id) => ("Calendar", id),
|
||||||
|
Resource::AddressBook(id) => ("AddressBook", id),
|
||||||
};
|
};
|
||||||
// Audit-worthy: denials are the interesting signal. Routed
|
// Audit-worthy: denials are the interesting signal. Routed
|
||||||
// through the `audit` tracing target so log aggregators can
|
// through the `audit` tracing target so log aggregators can
|
||||||
|
|||||||
@@ -25,38 +25,11 @@ pub trait CalendarStoragePort: Send + Sync + 'static {
|
|||||||
&self,
|
&self,
|
||||||
owner_id: Uuid,
|
owner_id: Uuid,
|
||||||
) -> Result<Vec<CalendarDto>, DomainError>;
|
) -> Result<Vec<CalendarDto>, DomainError>;
|
||||||
async fn list_calendars_shared_with_user(
|
|
||||||
&self,
|
|
||||||
user_id: Uuid,
|
|
||||||
) -> Result<Vec<CalendarDto>, DomainError>;
|
|
||||||
async fn list_public_calendars(
|
async fn list_public_calendars(
|
||||||
&self,
|
&self,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<CalendarDto>, DomainError>;
|
) -> 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
|
// Calendar properties
|
||||||
async fn set_calendar_property(
|
async fn set_calendar_property(
|
||||||
&self,
|
&self,
|
||||||
@@ -146,33 +119,12 @@ pub trait CalendarUseCase: Send + Sync + 'static {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<CalendarDto, DomainError>;
|
) -> Result<CalendarDto, DomainError>;
|
||||||
async fn list_my_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError>;
|
async fn list_my_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError>;
|
||||||
async fn list_shared_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError>;
|
|
||||||
async fn list_public_calendars(
|
async fn list_public_calendars(
|
||||||
&self,
|
&self,
|
||||||
limit: Option<i64>,
|
limit: Option<i64>,
|
||||||
offset: Option<i64>,
|
offset: Option<i64>,
|
||||||
) -> Result<Vec<CalendarDto>, DomainError>;
|
) -> 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
|
// Event operations
|
||||||
async fn create_event(
|
async fn create_event(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -1,16 +1,105 @@
|
|||||||
use crate::application::dtos::address_book_dto::{
|
use crate::application::dtos::address_book_dto::{
|
||||||
AddressBookDto, CreateAddressBookDto, ShareAddressBookDto, UnshareAddressBookDto,
|
AddressBookDto, CreateAddressBookDto, UpdateAddressBookDto,
|
||||||
UpdateAddressBookDto,
|
|
||||||
};
|
};
|
||||||
use crate::application::dtos::contact_dto::{
|
use crate::application::dtos::contact_dto::{
|
||||||
ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, CreateContactVCardDto,
|
ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, CreateContactVCardDto,
|
||||||
GroupMembershipDto, UpdateContactDto, UpdateContactGroupDto,
|
GroupMembershipDto, UpdateContactDto, UpdateContactGroupDto,
|
||||||
};
|
};
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
|
use crate::domain::entities::contact::{AddressBook, Contact, ContactGroup};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub type CardDavRepositoryError = DomainError;
|
pub type CardDavRepositoryError = DomainError;
|
||||||
|
|
||||||
|
/// Low-level storage port for CardDAV resources. Post-Round-3 the
|
||||||
|
/// port covers ONLY raw storage operations — everything that used
|
||||||
|
/// to be routed through it for sharing (`share_address_book`,
|
||||||
|
/// `unshare_address_book`, `get_address_book_shares`) or
|
||||||
|
/// scope-listing (`get_address_books_by_owner`,
|
||||||
|
/// `get_shared_address_books`) is gone. Access decisions live in
|
||||||
|
/// `AuthorizationEngine`; sharing state lives in
|
||||||
|
/// `storage.role_grants`. The service layer (`ContactService`) gates
|
||||||
|
/// each call, then reaches through this port for storage.
|
||||||
|
///
|
||||||
|
/// Symmetric with `CalendarStoragePort`. Implemented by
|
||||||
|
/// `ContactStorageAdapter` against Postgres today; a future backend
|
||||||
|
/// (external CardDAV, LDAP directory, in-memory test mock) would
|
||||||
|
/// implement the same trait and swap in via DI.
|
||||||
|
pub trait ContactStoragePort: Send + Sync + 'static {
|
||||||
|
// ── Address books ────────────────────────────────────────────
|
||||||
|
async fn create_address_book(
|
||||||
|
&self,
|
||||||
|
address_book: AddressBook,
|
||||||
|
) -> Result<AddressBook, DomainError>;
|
||||||
|
async fn update_address_book(
|
||||||
|
&self,
|
||||||
|
address_book: AddressBook,
|
||||||
|
) -> Result<AddressBook, DomainError>;
|
||||||
|
async fn delete_address_book(&self, id: &Uuid) -> Result<(), DomainError>;
|
||||||
|
async fn get_address_book_by_id(&self, id: &Uuid) -> Result<Option<AddressBook>, DomainError>;
|
||||||
|
async fn get_public_address_books(&self) -> Result<Vec<AddressBook>, DomainError>;
|
||||||
|
|
||||||
|
// ── Contacts ─────────────────────────────────────────────────
|
||||||
|
async fn create_contact(&self, contact: Contact) -> Result<Contact, DomainError>;
|
||||||
|
async fn update_contact(&self, contact: Contact) -> Result<Contact, DomainError>;
|
||||||
|
async fn delete_contact(&self, id: &Uuid) -> Result<(), DomainError>;
|
||||||
|
async fn get_contact_by_id(&self, id: &Uuid) -> Result<Option<Contact>, DomainError>;
|
||||||
|
/// Indexed single-row lookup by vCard UID within a specific book.
|
||||||
|
async fn get_contact_by_uid(
|
||||||
|
&self,
|
||||||
|
address_book_id: &Uuid,
|
||||||
|
uid: &str,
|
||||||
|
) -> Result<Option<Contact>, DomainError>;
|
||||||
|
/// Indexed batch lookup by vCard UID within a specific book.
|
||||||
|
async fn get_contacts_by_uids(
|
||||||
|
&self,
|
||||||
|
address_book_id: &Uuid,
|
||||||
|
uids: &[String],
|
||||||
|
) -> Result<Vec<Contact>, DomainError>;
|
||||||
|
async fn get_contacts_by_address_book(
|
||||||
|
&self,
|
||||||
|
address_book_id: &Uuid,
|
||||||
|
) -> Result<Vec<Contact>, DomainError>;
|
||||||
|
async fn get_contacts_by_address_book_paginated(
|
||||||
|
&self,
|
||||||
|
address_book_id: &Uuid,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<Contact>, DomainError>;
|
||||||
|
async fn search_contacts(
|
||||||
|
&self,
|
||||||
|
address_book_id: &Uuid,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<Vec<Contact>, DomainError>;
|
||||||
|
|
||||||
|
// ── Contact groups ───────────────────────────────────────────
|
||||||
|
async fn create_group(&self, group: ContactGroup) -> Result<ContactGroup, DomainError>;
|
||||||
|
async fn update_group(&self, group: ContactGroup) -> Result<ContactGroup, DomainError>;
|
||||||
|
async fn delete_group(&self, id: &Uuid) -> Result<(), DomainError>;
|
||||||
|
async fn get_group_by_id(&self, id: &Uuid) -> Result<Option<ContactGroup>, DomainError>;
|
||||||
|
async fn get_groups_by_address_book(
|
||||||
|
&self,
|
||||||
|
address_book_id: &Uuid,
|
||||||
|
) -> Result<Vec<ContactGroup>, DomainError>;
|
||||||
|
|
||||||
|
// ── Group membership ─────────────────────────────────────────
|
||||||
|
async fn add_contact_to_group(
|
||||||
|
&self,
|
||||||
|
group_id: &Uuid,
|
||||||
|
contact_id: &Uuid,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
async fn remove_contact_from_group(
|
||||||
|
&self,
|
||||||
|
group_id: &Uuid,
|
||||||
|
contact_id: &Uuid,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
async fn get_contacts_in_group(&self, group_id: &Uuid) -> Result<Vec<Contact>, DomainError>;
|
||||||
|
async fn get_groups_for_contact(
|
||||||
|
&self,
|
||||||
|
contact_id: &Uuid,
|
||||||
|
) -> Result<Vec<ContactGroup>, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
pub trait AddressBookUseCase: Send + Sync + 'static {
|
pub trait AddressBookUseCase: Send + Sync + 'static {
|
||||||
// Address Book operations
|
// Address Book operations
|
||||||
async fn create_address_book(
|
async fn create_address_book(
|
||||||
@@ -37,23 +126,6 @@ pub trait AddressBookUseCase: Send + Sync + 'static {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<Vec<AddressBookDto>, DomainError>;
|
) -> Result<Vec<AddressBookDto>, DomainError>;
|
||||||
async fn list_public_address_books(&self) -> 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 {
|
pub trait ContactUseCase: Send + Sync + 'static {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures::Stream;
|
use futures::Stream;
|
||||||
use serde_json::Value;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -446,9 +445,3 @@ pub trait StorageUsagePort: Send + Sync + 'static {
|
|||||||
additional_bytes: u64,
|
additional_bytes: u64,
|
||||||
) -> Result<(), DomainError>;
|
) -> 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 chrono::{DateTime, Utc};
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -6,17 +7,80 @@ use crate::application::dtos::calendar_dto::{
|
|||||||
CalendarDto, CalendarEventDto, CreateCalendarDto, CreateEventDto, CreateEventICalDto,
|
CalendarDto, CalendarEventDto, CreateCalendarDto, CreateEventDto, CreateEventICalDto,
|
||||||
UpdateCalendarDto, UpdateEventDto,
|
UpdateCalendarDto, UpdateEventDto,
|
||||||
};
|
};
|
||||||
|
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||||
use crate::application::ports::calendar_ports::{CalendarStoragePort, CalendarUseCase};
|
use crate::application::ports::calendar_ports::{CalendarStoragePort, CalendarUseCase};
|
||||||
use crate::common::errors::{DomainError, ErrorKind};
|
use crate::common::errors::{DomainError, ErrorKind};
|
||||||
|
use crate::domain::services::authorization::{Permission, Resource, Role, Subject};
|
||||||
use crate::infrastructure::adapters::calendar_storage_adapter::CalendarStorageAdapter;
|
use crate::infrastructure::adapters::calendar_storage_adapter::CalendarStorageAdapter;
|
||||||
|
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||||
|
|
||||||
|
/// Calendar service — the CalDAV / REST entry point for every calendar
|
||||||
|
/// or event operation. Every method routes through `AuthorizationEngine`;
|
||||||
|
/// the pre-Round-3 `check_calendar_access` bespoke helper is gone.
|
||||||
|
///
|
||||||
|
/// Ownership + sharing live entirely in `storage.role_grants`
|
||||||
|
/// (`resource_type='calendar'`). `caldav.calendars.owner_id` stays for
|
||||||
|
/// provenance and legacy queries but is no longer consulted for access
|
||||||
|
/// decisions.
|
||||||
pub struct CalendarService {
|
pub struct CalendarService {
|
||||||
calendar_storage: Arc<CalendarStorageAdapter>,
|
calendar_storage: Arc<CalendarStorageAdapter>,
|
||||||
|
/// ReBAC engine — every user-facing method calls `authz.require`
|
||||||
|
/// with the appropriate `Permission`. `create_calendar` also
|
||||||
|
/// uses it to seed an Owner grant for the caller so the common
|
||||||
|
/// "owning my own calendar" case takes a single indexed
|
||||||
|
/// role_grants lookup.
|
||||||
|
authz: Arc<PgAclEngine>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CalendarService {
|
impl CalendarService {
|
||||||
pub fn new(calendar_storage: Arc<CalendarStorageAdapter>) -> Self {
|
pub fn new(calendar_storage: Arc<CalendarStorageAdapter>, authz: Arc<PgAclEngine>) -> Self {
|
||||||
Self { calendar_storage }
|
Self {
|
||||||
|
calendar_storage,
|
||||||
|
authz,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `calendar_id` and enforce `permission` on `Resource::Calendar(uuid)`.
|
||||||
|
/// On denial `authz.require` returns `NotFound` (anti-enum — same
|
||||||
|
/// shape as "no such calendar") and emits the `authz.denied` audit
|
||||||
|
/// line. Returns the parsed UUID on success so the caller doesn't
|
||||||
|
/// have to parse it a second time.
|
||||||
|
async fn require_calendar_perm(
|
||||||
|
&self,
|
||||||
|
calendar_id: &str,
|
||||||
|
caller_id: Uuid,
|
||||||
|
permission: Permission,
|
||||||
|
) -> Result<Uuid, DomainError> {
|
||||||
|
let uuid = Uuid::parse_str(calendar_id)
|
||||||
|
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid ID"))?;
|
||||||
|
self.authz
|
||||||
|
.require(
|
||||||
|
Subject::User(caller_id),
|
||||||
|
permission,
|
||||||
|
Resource::Calendar(uuid),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check `permission` on a calendar without throwing. Used by the
|
||||||
|
/// read paths that also allow a public-calendar bypass — they need
|
||||||
|
/// a bool, not a `Result<(), NotFound>`.
|
||||||
|
async fn has_calendar_perm(
|
||||||
|
&self,
|
||||||
|
calendar_id: &str,
|
||||||
|
caller_id: Uuid,
|
||||||
|
permission: Permission,
|
||||||
|
) -> Result<bool, DomainError> {
|
||||||
|
let uuid = Uuid::parse_str(calendar_id)
|
||||||
|
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid ID"))?;
|
||||||
|
self.authz
|
||||||
|
.check(
|
||||||
|
Subject::User(caller_id),
|
||||||
|
permission,
|
||||||
|
Resource::Calendar(uuid),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,9 +90,30 @@ impl CalendarUseCase for CalendarService {
|
|||||||
calendar: CreateCalendarDto,
|
calendar: CreateCalendarDto,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<CalendarDto, DomainError> {
|
) -> Result<CalendarDto, DomainError> {
|
||||||
self.calendar_storage
|
// No pre-write gate: creating a calendar is a personal act
|
||||||
|
// (like creating a folder in your own drive). Storage stamps
|
||||||
|
// `owner_id = user_id`; we then seed an Owner role_grant so
|
||||||
|
// the engine's cache warms on first-read.
|
||||||
|
let created = self
|
||||||
|
.calendar_storage
|
||||||
.create_calendar(calendar, user_id)
|
.create_calendar(calendar, user_id)
|
||||||
.await
|
.await?;
|
||||||
|
let calendar_uuid = Uuid::parse_str(&created.id).map_err(|_| {
|
||||||
|
DomainError::internal_error("Calendar", "storage returned invalid calendar id")
|
||||||
|
})?;
|
||||||
|
// `set_role` is idempotent on the `(subject, resource)` unique
|
||||||
|
// key — a re-run (rare — only if storage retried) is a no-op.
|
||||||
|
// `granted_by = user_id` is the self-seeded creation event.
|
||||||
|
self.authz
|
||||||
|
.set_role(
|
||||||
|
user_id,
|
||||||
|
Subject::User(user_id),
|
||||||
|
Role::Owner,
|
||||||
|
Resource::Calendar(calendar_uuid),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(created)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_calendar(
|
async fn update_calendar(
|
||||||
@@ -37,35 +122,28 @@ impl CalendarUseCase for CalendarService {
|
|||||||
update: UpdateCalendarDto,
|
update: UpdateCalendarDto,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<CalendarDto, DomainError> {
|
) -> Result<CalendarDto, DomainError> {
|
||||||
let has_access = self
|
self.require_calendar_perm(calendar_id, user_id, Permission::Update)
|
||||||
.calendar_storage
|
|
||||||
.check_calendar_access(calendar_id, user_id)
|
|
||||||
.await?;
|
.await?;
|
||||||
if !has_access {
|
|
||||||
return Err(DomainError::new(
|
|
||||||
ErrorKind::AccessDenied,
|
|
||||||
"Calendar",
|
|
||||||
"You don't have permission to update this calendar",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.calendar_storage
|
self.calendar_storage
|
||||||
.update_calendar(calendar_id, update)
|
.update_calendar(calendar_id, update)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_calendar(&self, calendar_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
async fn delete_calendar(&self, calendar_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
||||||
let has_access = self
|
let uuid = self
|
||||||
.calendar_storage
|
.require_calendar_perm(calendar_id, user_id, Permission::Delete)
|
||||||
.check_calendar_access(calendar_id, user_id)
|
|
||||||
.await?;
|
.await?;
|
||||||
if !has_access {
|
self.calendar_storage.delete_calendar(calendar_id).await?;
|
||||||
return Err(DomainError::new(
|
// Wipe every grant on this calendar so a re-used UUID (impossible
|
||||||
ErrorKind::AccessDenied,
|
// today but cheap to defend against) doesn't inherit stale ACLs.
|
||||||
"Calendar",
|
// The storage DELETE won't cascade to `storage.role_grants` — the
|
||||||
"You don't have permission to delete this calendar",
|
// legacy `caldav.calendar_shares` had an FK, `role_grants`
|
||||||
));
|
// doesn't (it's cross-schema).
|
||||||
}
|
let _ = self
|
||||||
self.calendar_storage.delete_calendar(calendar_id).await
|
.authz
|
||||||
|
.revoke_all_for_resource(Resource::Calendar(uuid))
|
||||||
|
.await;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_calendar(
|
async fn get_calendar(
|
||||||
@@ -74,28 +152,52 @@ impl CalendarUseCase for CalendarService {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<CalendarDto, DomainError> {
|
) -> Result<CalendarDto, DomainError> {
|
||||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||||
let has_access = self
|
// Public-calendar bypass: anonymous-ish read. `check` returns
|
||||||
.calendar_storage
|
// bool (no throw); combine with the public flag before
|
||||||
.check_calendar_access(calendar_id, user_id)
|
// deciding.
|
||||||
.await?;
|
let allowed = calendar.is_public
|
||||||
if !has_access && !calendar.is_public {
|
|| self
|
||||||
return Err(DomainError::new(
|
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||||
ErrorKind::AccessDenied,
|
.await?;
|
||||||
"Calendar",
|
if !allowed {
|
||||||
"You don't have permission to view this calendar",
|
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Ok(calendar)
|
Ok(calendar)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_my_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError> {
|
async fn list_my_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError> {
|
||||||
self.calendar_storage.list_calendars_by_owner(user_id).await
|
// Post-Round-3 semantics: every calendar the caller has any
|
||||||
}
|
// grant on — owned + shared, one union. The pre-Round-3
|
||||||
|
// `list_calendars_by_owner` returned owner-only; shared
|
||||||
|
// calendars never surfaced through this method. See
|
||||||
|
// `docs/plan/caldav-carddav-migration-to-authz.md`.
|
||||||
|
let grants = self
|
||||||
|
.authz
|
||||||
|
.list_incoming_grants(Subject::User(user_id))
|
||||||
|
.await?;
|
||||||
|
|
||||||
async fn list_shared_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError> {
|
// Deduplicate — a user can hold multiple grants on the same
|
||||||
self.calendar_storage
|
// calendar (direct + group-inherited). We only need one DTO
|
||||||
.list_calendars_shared_with_user(user_id)
|
// per resource.
|
||||||
.await
|
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(
|
async fn list_public_calendars(
|
||||||
@@ -103,6 +205,8 @@ impl CalendarUseCase for CalendarService {
|
|||||||
limit: Option<i64>,
|
limit: Option<i64>,
|
||||||
offset: Option<i64>,
|
offset: Option<i64>,
|
||||||
) -> Result<Vec<CalendarDto>, DomainError> {
|
) -> Result<Vec<CalendarDto>, DomainError> {
|
||||||
|
// No caller gate: public listing by definition. Storage
|
||||||
|
// filters on `is_public = true`.
|
||||||
let limit = limit.unwrap_or(100);
|
let limit = limit.unwrap_or(100);
|
||||||
let offset = offset.unwrap_or(0);
|
let offset = offset.unwrap_or(0);
|
||||||
self.calendar_storage
|
self.calendar_storage
|
||||||
@@ -110,90 +214,13 @@ impl CalendarUseCase for CalendarService {
|
|||||||
.await
|
.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(
|
async fn create_event(
|
||||||
&self,
|
&self,
|
||||||
event: CreateEventDto,
|
event: CreateEventDto,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<CalendarEventDto, DomainError> {
|
) -> Result<CalendarEventDto, DomainError> {
|
||||||
let has_access = self
|
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Create)
|
||||||
.calendar_storage
|
|
||||||
.check_calendar_access(&event.calendar_id, user_id)
|
|
||||||
.await?;
|
.await?;
|
||||||
if !has_access {
|
|
||||||
return Err(DomainError::new(
|
|
||||||
ErrorKind::AccessDenied,
|
|
||||||
"Calendar",
|
|
||||||
"You don't have permission to add events to this calendar",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.calendar_storage.create_event(event).await
|
self.calendar_storage.create_event(event).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,17 +229,8 @@ impl CalendarUseCase for CalendarService {
|
|||||||
event: CreateEventICalDto,
|
event: CreateEventICalDto,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<CalendarEventDto, DomainError> {
|
) -> Result<CalendarEventDto, DomainError> {
|
||||||
let has_access = self
|
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Create)
|
||||||
.calendar_storage
|
|
||||||
.check_calendar_access(&event.calendar_id, user_id)
|
|
||||||
.await?;
|
.await?;
|
||||||
if !has_access {
|
|
||||||
return Err(DomainError::new(
|
|
||||||
ErrorKind::AccessDenied,
|
|
||||||
"Calendar",
|
|
||||||
"You don't have permission to add events to this calendar",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.calendar_storage.create_event_from_ical(event).await
|
self.calendar_storage.create_event_from_ical(event).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,33 +241,15 @@ impl CalendarUseCase for CalendarService {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<CalendarEventDto, DomainError> {
|
) -> Result<CalendarEventDto, DomainError> {
|
||||||
let event = self.calendar_storage.get_event(event_id).await?;
|
let event = self.calendar_storage.get_event(event_id).await?;
|
||||||
let has_access = self
|
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Update)
|
||||||
.calendar_storage
|
|
||||||
.check_calendar_access(&event.calendar_id, user_id)
|
|
||||||
.await?;
|
.await?;
|
||||||
if !has_access {
|
|
||||||
return Err(DomainError::new(
|
|
||||||
ErrorKind::AccessDenied,
|
|
||||||
"Calendar",
|
|
||||||
"You don't have permission to update events in this calendar",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.calendar_storage.update_event(event_id, update).await
|
self.calendar_storage.update_event(event_id, update).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_event(&self, event_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
async fn delete_event(&self, event_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
||||||
let event = self.calendar_storage.get_event(event_id).await?;
|
let event = self.calendar_storage.get_event(event_id).await?;
|
||||||
let has_access = self
|
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Delete)
|
||||||
.calendar_storage
|
|
||||||
.check_calendar_access(&event.calendar_id, user_id)
|
|
||||||
.await?;
|
.await?;
|
||||||
if !has_access {
|
|
||||||
return Err(DomainError::new(
|
|
||||||
ErrorKind::AccessDenied,
|
|
||||||
"Calendar",
|
|
||||||
"You don't have permission to delete events in this calendar",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.calendar_storage.delete_event(event_id).await
|
self.calendar_storage.delete_event(event_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,20 +259,17 @@ impl CalendarUseCase for CalendarService {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<CalendarEventDto, DomainError> {
|
) -> Result<CalendarEventDto, DomainError> {
|
||||||
let event = self.calendar_storage.get_event(event_id).await?;
|
let event = self.calendar_storage.get_event(event_id).await?;
|
||||||
let has_access = self
|
|
||||||
.calendar_storage
|
|
||||||
.check_calendar_access(&event.calendar_id, user_id)
|
|
||||||
.await?;
|
|
||||||
let calendar = self
|
let calendar = self
|
||||||
.calendar_storage
|
.calendar_storage
|
||||||
.get_calendar(&event.calendar_id)
|
.get_calendar(&event.calendar_id)
|
||||||
.await?;
|
.await?;
|
||||||
if !has_access && !calendar.is_public {
|
// Same public-calendar bypass as `get_calendar`.
|
||||||
return Err(DomainError::new(
|
let allowed = calendar.is_public
|
||||||
ErrorKind::AccessDenied,
|
|| self
|
||||||
"Calendar",
|
.has_calendar_perm(&event.calendar_id, user_id, Permission::Read)
|
||||||
"You don't have permission to view events in this calendar",
|
.await?;
|
||||||
));
|
if !allowed {
|
||||||
|
return Err(DomainError::not_found("Event", event_id));
|
||||||
}
|
}
|
||||||
Ok(event)
|
Ok(event)
|
||||||
}
|
}
|
||||||
@@ -283,17 +280,13 @@ impl CalendarUseCase for CalendarService {
|
|||||||
ical_uid: &str,
|
ical_uid: &str,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<Option<CalendarEventDto>, DomainError> {
|
) -> Result<Option<CalendarEventDto>, DomainError> {
|
||||||
let has_access = self
|
|
||||||
.calendar_storage
|
|
||||||
.check_calendar_access(calendar_id, user_id)
|
|
||||||
.await?;
|
|
||||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||||
if !has_access && !calendar.is_public {
|
let allowed = calendar.is_public
|
||||||
return Err(DomainError::new(
|
|| self
|
||||||
ErrorKind::AccessDenied,
|
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||||
"Calendar",
|
.await?;
|
||||||
"You don't have permission to view events in this calendar",
|
if !allowed {
|
||||||
));
|
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||||
}
|
}
|
||||||
self.calendar_storage
|
self.calendar_storage
|
||||||
.find_event_by_ical_uid(calendar_id, ical_uid)
|
.find_event_by_ical_uid(calendar_id, ical_uid)
|
||||||
@@ -306,17 +299,13 @@ impl CalendarUseCase for CalendarService {
|
|||||||
ical_uids: &[String],
|
ical_uids: &[String],
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
||||||
let has_access = self
|
|
||||||
.calendar_storage
|
|
||||||
.check_calendar_access(calendar_id, user_id)
|
|
||||||
.await?;
|
|
||||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||||
if !has_access && !calendar.is_public {
|
let allowed = calendar.is_public
|
||||||
return Err(DomainError::new(
|
|| self
|
||||||
ErrorKind::AccessDenied,
|
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||||
"Calendar",
|
.await?;
|
||||||
"You don't have permission to view events in this calendar",
|
if !allowed {
|
||||||
));
|
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||||
}
|
}
|
||||||
if ical_uids.is_empty() {
|
if ical_uids.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -333,17 +322,13 @@ impl CalendarUseCase for CalendarService {
|
|||||||
offset: Option<i64>,
|
offset: Option<i64>,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
||||||
let has_access = self
|
|
||||||
.calendar_storage
|
|
||||||
.check_calendar_access(calendar_id, user_id)
|
|
||||||
.await?;
|
|
||||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||||
if !has_access && !calendar.is_public {
|
let allowed = calendar.is_public
|
||||||
return Err(DomainError::new(
|
|| self
|
||||||
ErrorKind::AccessDenied,
|
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||||
"Calendar",
|
.await?;
|
||||||
"You don't have permission to view events in this calendar",
|
if !allowed {
|
||||||
));
|
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||||
}
|
}
|
||||||
if limit.is_some() || offset.is_some() {
|
if limit.is_some() || offset.is_some() {
|
||||||
let limit = limit.unwrap_or(100);
|
let limit = limit.unwrap_or(100);
|
||||||
@@ -365,17 +350,13 @@ impl CalendarUseCase for CalendarService {
|
|||||||
end: DateTime<Utc>,
|
end: DateTime<Utc>,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
||||||
let has_access = self
|
|
||||||
.calendar_storage
|
|
||||||
.check_calendar_access(calendar_id, user_id)
|
|
||||||
.await?;
|
|
||||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||||
if !has_access && !calendar.is_public {
|
let allowed = calendar.is_public
|
||||||
return Err(DomainError::new(
|
|| self
|
||||||
ErrorKind::AccessDenied,
|
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||||
"Calendar",
|
.await?;
|
||||||
"You don't have permission to view events in this calendar",
|
if !allowed {
|
||||||
));
|
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||||
}
|
}
|
||||||
self.calendar_storage
|
self.calendar_storage
|
||||||
.get_events_in_time_range(calendar_id, &start, &end)
|
.get_events_in_time_range(calendar_id, &start, &end)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -307,20 +307,25 @@ impl MagicLinkInviteService {
|
|||||||
let (kind, resource_id) = match resource {
|
let (kind, resource_id) = match resource {
|
||||||
Resource::Folder(id) => (MagicLinkResourceKind::Folder, id),
|
Resource::Folder(id) => (MagicLinkResourceKind::Folder, id),
|
||||||
Resource::File(id) => (MagicLinkResourceKind::File, id),
|
Resource::File(id) => (MagicLinkResourceKind::File, id),
|
||||||
// Drive sharing — and therefore drive magic-link invitations —
|
// Drive / Calendar / AddressBook sharing is out-of-band for
|
||||||
// land in D2. The grant DTOs accept `Resource::Drive` from the
|
// the magic-link flow. Drive shares land through
|
||||||
// wire today (see ResourceTypeDto) but no public API path
|
// `/api/drives/{id}/members`; Calendar / AddressBook shares
|
||||||
// actually grants on a drive in D0, so this arm is
|
// through the Round-3 `/api/(calendars|address-books)/{id}/shares`
|
||||||
// defensively unreachable. Treating it as an audit-logged
|
// endpoints. The DTOs accept every `Resource` variant on
|
||||||
// no-op (grant is in place, mail suppressed) matches the
|
// the wire (see `ResourceTypeDto`) but only file/folder
|
||||||
// ineligible-recipient branch above.
|
// grants trigger an invitation email. Treating the other
|
||||||
Resource::Drive(_) => {
|
// 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!(
|
tracing::info!(
|
||||||
target: "audit",
|
target: "audit",
|
||||||
event = "magic_link.invitation_suppressed",
|
event = "magic_link.invitation_suppressed",
|
||||||
reason = "drive_resource_unsupported",
|
reason = "resource_kind_unsupported",
|
||||||
user_id = %recipient.id(),
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -347,10 +352,12 @@ impl MagicLinkInviteService {
|
|||||||
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
||||||
Resource::File(_) => "server.magic_link.email.kind_file",
|
Resource::File(_) => "server.magic_link.email.kind_file",
|
||||||
// Unreachable — the early-return above exits before we get
|
// Unreachable — the early-return above exits before we get
|
||||||
// here for a Drive resource. The arm exists only to satisfy
|
// here for Drive / Calendar / AddressBook resources. The
|
||||||
// exhaustiveness; if you find this firing, the early-return
|
// arms exist only to satisfy exhaustiveness; if you find
|
||||||
// was bypassed.
|
// any firing, the early-return was bypassed.
|
||||||
Resource::Drive(_) => "server.magic_link.email.kind_folder",
|
Resource::Drive(_) | Resource::Calendar(_) | Resource::AddressBook(_) => {
|
||||||
|
"server.magic_link.email.kind_folder"
|
||||||
|
}
|
||||||
};
|
};
|
||||||
// PR C: render in the recipient's preferred locale (set by UI
|
// PR C: render in the recipient's preferred locale (set by UI
|
||||||
// switcher, OIDC JIT claim, or inviter inheritance at row
|
// switcher, OIDC JIT claim, or inviter inheritance at row
|
||||||
|
|||||||
@@ -472,11 +472,13 @@ impl RecipientNotificationService {
|
|||||||
let kind_key = match resource {
|
let kind_key = match resource {
|
||||||
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
||||||
Resource::File(_) => "server.magic_link.email.kind_file",
|
Resource::File(_) => "server.magic_link.email.kind_file",
|
||||||
// Drives don't generate share notifications in D0 — drive
|
// Drive / Calendar / AddressBook shares don't produce
|
||||||
// sharing lands in D2 and gets its own template key. Fall
|
// email notifications through this path. Fall back to the
|
||||||
// back to the folder label so any path that does reach
|
// folder label so any code that does reach here still
|
||||||
// here produces a readable, if generic, mail body.
|
// produces a readable (if generic) mail body.
|
||||||
Resource::Drive(_) => "server.magic_link.email.kind_folder",
|
Resource::Drive(_) | Resource::Calendar(_) | Resource::AddressBook(_) => {
|
||||||
|
"server.magic_link.email.kind_folder"
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let kind_label = self.i18n_or(kind_key, &locale, &[]).await;
|
let kind_label = self.i18n_or(kind_key, &locale, &[]).await;
|
||||||
// Short form for the subject, long form (with email) for the
|
// 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::app_password_service::AppPasswordService;
|
||||||
use crate::application::services::blob_lifecycle_service::BlobLifecycleService;
|
use crate::application::services::blob_lifecycle_service::BlobLifecycleService;
|
||||||
use crate::application::services::calendar_service::CalendarService;
|
use crate::application::services::calendar_service::CalendarService;
|
||||||
|
use crate::application::services::contact_service::ContactService;
|
||||||
use crate::application::services::device_auth_service::DeviceAuthService;
|
use crate::application::services::device_auth_service::DeviceAuthService;
|
||||||
use crate::application::services::file_lifecycle_service::FileLifecycleService;
|
use crate::application::services::file_lifecycle_service::FileLifecycleService;
|
||||||
use crate::application::services::music_service::MusicService;
|
use crate::application::services::music_service::MusicService;
|
||||||
use crate::application::services::storage_usage_service::StorageUsageService;
|
use crate::application::services::storage_usage_service::StorageUsageService;
|
||||||
use crate::application::services::wopi_lock_service::WopiLockService;
|
use crate::application::services::wopi_lock_service::WopiLockService;
|
||||||
use crate::application::services::wopi_token_service::WopiTokenService;
|
use crate::application::services::wopi_token_service::WopiTokenService;
|
||||||
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
|
|
||||||
use crate::infrastructure::repositories::AppPasswordPgRepository;
|
use crate::infrastructure::repositories::AppPasswordPgRepository;
|
||||||
use crate::infrastructure::repositories::DeviceCodePgRepository;
|
use crate::infrastructure::repositories::DeviceCodePgRepository;
|
||||||
use crate::infrastructure::repositories::pg::{
|
use crate::infrastructure::repositories::pg::{
|
||||||
@@ -1558,7 +1558,6 @@ impl AppServiceFactory {
|
|||||||
people_service,
|
people_service,
|
||||||
storage_usage_service,
|
storage_usage_service,
|
||||||
calendar_service: None,
|
calendar_service: None,
|
||||||
contact_service: None,
|
|
||||||
calendar_use_case: None,
|
calendar_use_case: None,
|
||||||
addressbook_use_case: None,
|
addressbook_use_case: None,
|
||||||
contact_use_case: None,
|
contact_use_case: None,
|
||||||
@@ -1829,6 +1828,7 @@ impl AppServiceFactory {
|
|||||||
let calendar_service = Arc::new(
|
let calendar_service = Arc::new(
|
||||||
crate::application::services::calendar_service::CalendarService::new(
|
crate::application::services::calendar_service::CalendarService::new(
|
||||||
calendar_storage,
|
calendar_storage,
|
||||||
|
authorization.clone(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
app_state.calendar_use_case = Some(calendar_service as Arc<CalendarService>);
|
app_state.calendar_use_case = Some(calendar_service as Arc<CalendarService>);
|
||||||
@@ -1845,15 +1845,23 @@ impl AppServiceFactory {
|
|||||||
pool.clone(),
|
pool.clone(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// Post-Round-3: symmetric with CalendarService/CalendarStorageAdapter.
|
||||||
|
// * ContactStorageAdapter → pure ContactStoragePort impl
|
||||||
|
// (raw PG storage, no ACL, no sharing).
|
||||||
|
// * ContactService → gates every call through the
|
||||||
|
// AuthorizationEngine, then delegates through the port.
|
||||||
|
// Owns both AddressBookUseCase + ContactUseCase impls.
|
||||||
let contact_storage = Arc::new(
|
let contact_storage = Arc::new(
|
||||||
crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter::new(
|
crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter::new(
|
||||||
address_book_repo,
|
address_book_repo,
|
||||||
contact_repo,
|
contact_repo,
|
||||||
group_repo,
|
group_repo,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
app_state.addressbook_use_case = Some(contact_storage.clone());
|
let contact_service =
|
||||||
app_state.contact_use_case = Some(contact_storage);
|
Arc::new(ContactService::new(contact_storage, authorization.clone()));
|
||||||
|
app_state.addressbook_use_case = Some(contact_service.clone());
|
||||||
|
app_state.contact_use_case = Some(contact_service);
|
||||||
|
|
||||||
tracing::info!("CalDAV and CardDAV services initialized with PostgreSQL repositories");
|
tracing::info!("CalDAV and CardDAV services initialized with PostgreSQL repositories");
|
||||||
}
|
}
|
||||||
@@ -2022,10 +2030,9 @@ pub struct AppState {
|
|||||||
pub people_service: Option<Arc<PeopleService>>,
|
pub people_service: Option<Arc<PeopleService>>,
|
||||||
pub storage_usage_service: Option<Arc<StorageUsageService>>,
|
pub storage_usage_service: Option<Arc<StorageUsageService>>,
|
||||||
pub calendar_service: Option<Arc<CalendarService>>,
|
pub calendar_service: Option<Arc<CalendarService>>,
|
||||||
pub contact_service: Option<Arc<ContactStorageAdapter>>,
|
|
||||||
pub calendar_use_case: Option<Arc<CalendarService>>,
|
pub calendar_use_case: Option<Arc<CalendarService>>,
|
||||||
pub addressbook_use_case: Option<Arc<ContactStorageAdapter>>,
|
pub addressbook_use_case: Option<Arc<ContactService>>,
|
||||||
pub contact_use_case: Option<Arc<ContactStorageAdapter>>,
|
pub contact_use_case: Option<Arc<ContactService>>,
|
||||||
pub music_service: Option<Arc<MusicService>>,
|
pub music_service: Option<Arc<MusicService>>,
|
||||||
pub wopi_token_service:
|
pub wopi_token_service:
|
||||||
Option<Arc<crate::application::services::wopi_token_service::WopiTokenService>>,
|
Option<Arc<crate::application::services::wopi_token_service::WopiTokenService>>,
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ use crate::domain::entities::contact::AddressBook;
|
|||||||
|
|
||||||
pub type AddressBookRepositoryResult<T> = Result<T, DomainError>;
|
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 {
|
pub trait AddressBookRepository: Send + Sync + 'static {
|
||||||
async fn create_address_book(
|
async fn create_address_book(
|
||||||
&self,
|
&self,
|
||||||
@@ -20,28 +28,13 @@ pub trait AddressBookRepository: Send + Sync + 'static {
|
|||||||
&self,
|
&self,
|
||||||
id: &Uuid,
|
id: &Uuid,
|
||||||
) -> AddressBookRepositoryResult<Option<AddressBook>>;
|
) -> 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(
|
async fn get_address_books_by_owner(
|
||||||
&self,
|
&self,
|
||||||
owner_id: Uuid,
|
owner_id: Uuid,
|
||||||
) -> AddressBookRepositoryResult<Vec<AddressBook>>;
|
) -> 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 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>;
|
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 {
|
pub trait CalendarRepository: Send + Sync + 'static {
|
||||||
/// Creates a new calendar
|
/// Creates a new calendar
|
||||||
async fn create_calendar(&self, calendar: Calendar) -> CalendarRepositoryResult<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
|
/// Finds a calendar by its ID
|
||||||
async fn find_calendar_by_id(&self, id: &Uuid) -> CalendarRepositoryResult<Calendar>;
|
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(
|
async fn list_calendars_by_owner(
|
||||||
&self,
|
&self,
|
||||||
owner_id: Uuid,
|
owner_id: Uuid,
|
||||||
@@ -31,12 +42,6 @@ pub trait CalendarRepository: Send + Sync + 'static {
|
|||||||
owner_id: Uuid,
|
owner_id: Uuid,
|
||||||
) -> CalendarRepositoryResult<Calendar>;
|
) -> 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
|
/// List public calendars
|
||||||
async fn list_public_calendars(
|
async fn list_public_calendars(
|
||||||
&self,
|
&self,
|
||||||
@@ -44,13 +49,6 @@ pub trait CalendarRepository: Send + Sync + 'static {
|
|||||||
offset: i64,
|
offset: i64,
|
||||||
) -> CalendarRepositoryResult<Vec<Calendar>>;
|
) -> 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
|
/// Gets a custom property for a calendar
|
||||||
async fn get_calendar_property(
|
async fn get_calendar_property(
|
||||||
&self,
|
&self,
|
||||||
@@ -78,25 +76,4 @@ pub trait CalendarRepository: Send + Sync + 'static {
|
|||||||
&self,
|
&self,
|
||||||
calendar_id: &Uuid,
|
calendar_id: &Uuid,
|
||||||
) -> CalendarRepositoryResult<std::collections::HashMap<String, String>>;
|
) -> 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
|
/// membership and policy bag. Added in D0; membership lives in
|
||||||
/// `storage.role_grants` (no separate `drive_members` table).
|
/// `storage.role_grants` (no separate `drive_members` table).
|
||||||
Drive(Uuid),
|
Drive(Uuid),
|
||||||
// Reserved for future use:
|
/// A CalDAV calendar. Membership + sharing lives in
|
||||||
// Calendar(Uuid),
|
/// `storage.role_grants` with `resource_type='calendar'` —
|
||||||
// Reserved for future use:
|
/// replaces the pre-Round-3 dedicated `caldav.calendar_shares`
|
||||||
// AddressBook(Uuid),
|
/// table and the `check_calendar_access` bespoke helper. No
|
||||||
// Reserved for future use:
|
/// 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),
|
// Playlist(Uuid),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,17 +101,19 @@ impl Resource {
|
|||||||
Resource::Folder(_) => "folder",
|
Resource::Folder(_) => "folder",
|
||||||
Resource::File(_) => "file",
|
Resource::File(_) => "file",
|
||||||
Resource::Drive(_) => "drive",
|
Resource::Drive(_) => "drive",
|
||||||
//Resource::Calendar(_) => "calendar",
|
Resource::Calendar(_) => "calendar",
|
||||||
//Resource::AddressBook(_) => "adressbook",
|
Resource::AddressBook(_) => "address_book",
|
||||||
//Resource::Playlist(_) => "playlist",
|
//Resource::Playlist(_) => "playlist",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn id(&self) -> Uuid {
|
pub fn id(&self) -> Uuid {
|
||||||
match self {
|
match self {
|
||||||
Resource::Folder(id) | Resource::File(id) | Resource::Drive(id) => *id,
|
Resource::Folder(id)
|
||||||
//| Resource::Calendar(id)
|
| Resource::File(id)
|
||||||
//| Resource::AddressBook(id)
|
| Resource::Drive(id)
|
||||||
|
| Resource::Calendar(id)
|
||||||
|
| Resource::AddressBook(id) => *id,
|
||||||
//| Resource::Playlist(id)
|
//| Resource::Playlist(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,8 +123,8 @@ impl Resource {
|
|||||||
"folder" => Some(Resource::Folder(id)),
|
"folder" => Some(Resource::Folder(id)),
|
||||||
"file" => Some(Resource::File(id)),
|
"file" => Some(Resource::File(id)),
|
||||||
"drive" => Some(Resource::Drive(id)),
|
"drive" => Some(Resource::Drive(id)),
|
||||||
//"calendar" => Some(Resource::Calendar(id)),
|
"calendar" => Some(Resource::Calendar(id)),
|
||||||
//"adressbook" => Some(Resource::AddressBook(id)),
|
"address_book" => Some(Resource::AddressBook(id)),
|
||||||
//"playlist" => Some(Resource::Playlist(id)),
|
//"playlist" => Some(Resource::Playlist(id)),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -536,11 +547,17 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn resource_roundtrip() {
|
fn resource_roundtrip() {
|
||||||
let id = Uuid::new_v4();
|
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();
|
let back = Resource::from_parts(r.type_str(), r.id()).unwrap();
|
||||||
assert_eq!(r, back);
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -126,17 +126,6 @@ impl CalendarStoragePort for CalendarStorageAdapter {
|
|||||||
Ok(calendars.into_iter().map(CalendarDto::from).collect())
|
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(
|
async fn list_public_calendars(
|
||||||
&self,
|
&self,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
@@ -149,78 +138,6 @@ impl CalendarStoragePort for CalendarStorageAdapter {
|
|||||||
Ok(calendars.into_iter().map(CalendarDto::from).collect())
|
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
|
// Calendar properties
|
||||||
|
|
||||||
async fn set_calendar_property(
|
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)
|
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>> {
|
async fn get_public_address_books(&self) -> AddressBookRepositoryResult<Vec<AddressBook>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -256,79 +218,4 @@ impl AddressBookRepository for AddressBookPgRepository {
|
|||||||
|
|
||||||
Ok(result)
|
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)
|
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(
|
async fn list_public_calendars(
|
||||||
&self,
|
&self,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
@@ -296,112 +258,6 @@ impl CalendarRepository for CalendarPgRepository {
|
|||||||
Ok(calendars)
|
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(
|
async fn get_calendar_property(
|
||||||
&self,
|
&self,
|
||||||
calendar_id: &Uuid,
|
calendar_id: &Uuid,
|
||||||
|
|||||||
@@ -479,11 +479,18 @@ impl PgAclEngine {
|
|||||||
/// Returns the `drive_id` for a File / Folder. Drives don't have a parent
|
/// Returns the `drive_id` for a File / Folder. Drives don't have a parent
|
||||||
/// drive — this returns `NotFound` for `Resource::Drive` and the caller
|
/// drive — this returns `NotFound` for `Resource::Drive` and the caller
|
||||||
/// must not invoke it on Drive resources.
|
/// 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> {
|
async fn drive_of(&self, resource: Resource) -> Result<Uuid, DomainError> {
|
||||||
match resource {
|
match resource {
|
||||||
Resource::Folder(id) => self.folder_repo.get_folder_drive_id(&id.to_string()).await,
|
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::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()`.
|
/// permission — see `roles_implying()`.
|
||||||
///
|
///
|
||||||
/// Uses the GiST index on `storage.folders.lpath` for O(log N) cascade.
|
/// 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(
|
async fn folder_cascade_grant_exists(
|
||||||
&self,
|
&self,
|
||||||
subject_types: &[&str],
|
subject_types: &[&str],
|
||||||
@@ -819,6 +869,39 @@ impl PgAclEngine {
|
|||||||
.await?
|
.await?
|
||||||
.is_some_and(|r| r.expand().contains(&permission)))
|
.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")
|
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||||
.body(Body::from(response_body))
|
.body(Body::from(response_body))
|
||||||
.unwrap())
|
.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 {
|
} else {
|
||||||
// Not a calendar ID — treat as user calendar home (e.g. /caldav/{username}/)
|
// Not a calendar ID — treat as user calendar home (e.g. /caldav/{username}/)
|
||||||
// List all calendars for this user
|
// 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::address_book_dto::{CreateAddressBookDto, UpdateAddressBookDto};
|
||||||
use crate::application::dtos::contact_dto::CreateContactVCardDto;
|
use crate::application::dtos::contact_dto::CreateContactVCardDto;
|
||||||
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
|
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
|
||||||
|
use crate::application::services::contact_service::ContactService;
|
||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
|
|
||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
|
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ fn extract_user(req: &Request<Body>) -> Result<AuthUser, AppError> {
|
|||||||
.ok_or_else(|| AppError::unauthorized("Authentication required"))
|
.ok_or_else(|| AppError::unauthorized("Authentication required"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_addressbook_service(state: &AppState) -> Result<&Arc<ContactStorageAdapter>, AppError> {
|
fn get_addressbook_service(state: &AppState) -> Result<&Arc<ContactService>, AppError> {
|
||||||
state.addressbook_use_case.as_ref().ok_or_else(|| {
|
state.addressbook_use_case.as_ref().ok_or_else(|| {
|
||||||
AppError::new(
|
AppError::new(
|
||||||
StatusCode::NOT_IMPLEMENTED,
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
@@ -187,7 +187,7 @@ fn get_addressbook_service(state: &AppState) -> Result<&Arc<ContactStorageAdapte
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_contact_service(state: &AppState) -> Result<&Arc<ContactStorageAdapter>, AppError> {
|
fn get_contact_service(state: &AppState) -> Result<&Arc<ContactService>, AppError> {
|
||||||
state.contact_use_case.as_ref().ok_or_else(|| {
|
state.contact_use_case.as_ref().ok_or_else(|| {
|
||||||
AppError::new(
|
AppError::new(
|
||||||
StatusCode::NOT_IMPLEMENTED,
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ use crate::application::dtos::contact_dto::{
|
|||||||
use crate::application::dtos::user_dto::UserDto;
|
use crate::application::dtos::user_dto::UserDto;
|
||||||
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
|
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
|
||||||
use crate::application::services::auth_application_service::AuthApplicationService;
|
use crate::application::services::auth_application_service::AuthApplicationService;
|
||||||
|
use crate::application::services::contact_service::ContactService;
|
||||||
use crate::domain::errors::ErrorKind;
|
use crate::domain::errors::ErrorKind;
|
||||||
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
|
|
||||||
use crate::interfaces::middleware::auth::AuthUser;
|
use crate::interfaces::middleware::auth::AuthUser;
|
||||||
|
|
||||||
const SYSTEM_BOOK_ID: &str = "system";
|
const SYSTEM_BOOK_ID: &str = "system";
|
||||||
@@ -28,7 +28,7 @@ const SYSTEM_BOOK_ID: &str = "system";
|
|||||||
/// Combined state for the contacts REST API.
|
/// Combined state for the contacts REST API.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ContactsApiState {
|
pub struct ContactsApiState {
|
||||||
pub contact_service: Arc<ContactStorageAdapter>,
|
pub contact_service: Arc<ContactService>,
|
||||||
pub auth_service: Option<Arc<AuthApplicationService>>,
|
pub auth_service: Option<Arc<AuthApplicationService>>,
|
||||||
/// When false, the virtual "system" address book (OxiCloud users) is hidden.
|
/// When false, the virtual "system" address book (OxiCloud users) is hidden.
|
||||||
pub expose_system_users: bool,
|
pub expose_system_users: bool,
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ pub async fn create_grant(
|
|||||||
.get_by_id(id)
|
.get_by_id(id)
|
||||||
.await
|
.await
|
||||||
.map(|d| d.drive.typed_policies()),
|
.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 {
|
let drive_policies = match drive_policies {
|
||||||
Ok(p) => p,
|
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
|
HTTP 200
|
||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$" isCollection
|
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/batch_folder_copy.hurl" \
|
||||||
"$API_DIR/dedup_blob_cleanup.hurl" \
|
"$API_DIR/dedup_blob_cleanup.hurl" \
|
||||||
"$API_DIR/contacts.hurl" \
|
"$API_DIR/contacts.hurl" \
|
||||||
|
"$API_DIR/calendar.hurl" \
|
||||||
"$API_DIR/public_shares.hurl" \
|
"$API_DIR/public_shares.hurl" \
|
||||||
"$API_DIR/permissions.hurl" \
|
"$API_DIR/permissions.hurl" \
|
||||||
"$API_DIR/grants.hurl" \
|
"$API_DIR/grants.hurl" \
|
||||||
|
|||||||
Reference in New Issue
Block a user