Merge pull request #553 from EdouardVanbelle/feat/caldav-carddav-to-authz

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