✨(all) add organizations, resources, channels, and infra migration (#34)
Add multi-tenant organization model populated from OIDC claims with org-scoped user discovery, CalDAV principal filtering, and cross-org isolation at the SabreDAV layer. Add bookable resource principals (rooms, equipment) with CalDAV auto-scheduling that handles conflict detection, auto-accept/decline, and org-scoped booking enforcement. Fixes #14. Replace CalendarSubscriptionToken with a unified Channel model supporting CalDAV integration tokens and iCal feed URLs, with encrypted token storage and role-based access control. Fixes #16. Migrate task queue from Celery to Dramatiq with async ICS import, progress tracking, and task status polling endpoint. Replace nginx with Caddy for both the reverse proxy and frontend static serving. Switch frontend package manager from yarn/pnpm to npm and upgrade Node to 24, Next.js to 16, TypeScript to 5.9. Harden security with fail-closed entitlements, RSVP rate limiting and token expiry, CalDAV proxy path validation blocking internal API routes, channel path scope enforcement, and ETag-based conflict prevention. Add frontend pages for resource management and integration channel CRUD, with resource booking in the event modal. Restructure CalDAV paths to /calendars/users/ and /calendars/resources/ with nested principal collections in SabreDAV.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
"""Services for CalDAV integration."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import timezone as dt_timezone
|
||||
from typing import Optional
|
||||
from urllib.parse import unquote
|
||||
from uuid import uuid4
|
||||
@@ -30,7 +32,7 @@ class CalDAVHTTPClient:
|
||||
and HTTP requests. All higher-level CalDAV consumers delegate to this.
|
||||
"""
|
||||
|
||||
BASE_URI_PATH = "/api/v1.0/caldav"
|
||||
BASE_URI_PATH = "/caldav"
|
||||
DEFAULT_TIMEOUT = 30
|
||||
|
||||
def __init__(self):
|
||||
@@ -45,11 +47,21 @@ class CalDAVHTTPClient:
|
||||
return key
|
||||
|
||||
@classmethod
|
||||
def build_base_headers(cls, email: str) -> dict:
|
||||
"""Build authentication headers for CalDAV requests."""
|
||||
def build_base_headers(cls, user) -> dict:
|
||||
"""Build authentication headers for CalDAV requests.
|
||||
|
||||
Args:
|
||||
user: Object with .email and .organization_id attributes.
|
||||
|
||||
Raises:
|
||||
ValueError: If user.email is not set.
|
||||
"""
|
||||
if not user.email:
|
||||
raise ValueError("User has no email address")
|
||||
return {
|
||||
"X-Api-Key": cls.get_api_key(),
|
||||
"X-Forwarded-User": email,
|
||||
"X-Forwarded-User": user.email,
|
||||
"X-CalDAV-Organization": str(user.organization_id),
|
||||
}
|
||||
|
||||
def build_url(self, path: str, query: str = "") -> str:
|
||||
@@ -70,7 +82,7 @@ class CalDAVHTTPClient:
|
||||
def request( # noqa: PLR0913 # pylint: disable=too-many-arguments
|
||||
self,
|
||||
method: str,
|
||||
email: str,
|
||||
user,
|
||||
path: str,
|
||||
*,
|
||||
query: str = "",
|
||||
@@ -80,7 +92,7 @@ class CalDAVHTTPClient:
|
||||
content_type: str | None = None,
|
||||
) -> requests.Response:
|
||||
"""Make an authenticated HTTP request to the CalDAV server."""
|
||||
headers = self.build_base_headers(email)
|
||||
headers = self.build_base_headers(user)
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
if extra_headers:
|
||||
@@ -95,9 +107,13 @@ class CalDAVHTTPClient:
|
||||
timeout=timeout or self.DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def get_dav_client(self, email: str) -> DAVClient:
|
||||
"""Return a configured caldav.DAVClient for the given user email."""
|
||||
headers = self.build_base_headers(email)
|
||||
def get_dav_client(self, user) -> DAVClient:
|
||||
"""Return a configured caldav.DAVClient for the given user.
|
||||
|
||||
Args:
|
||||
user: Object with .email and .organization_id attributes.
|
||||
"""
|
||||
headers = self.build_base_headers(user)
|
||||
caldav_url = f"{self.base_url}{self.BASE_URI_PATH}/"
|
||||
return DAVClient(
|
||||
url=caldav_url,
|
||||
@@ -107,38 +123,58 @@ class CalDAVHTTPClient:
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
def find_event_by_uid(self, email: str, uid: str) -> tuple[str | None, str | None]:
|
||||
def find_event_by_uid(
|
||||
self, user, uid: str
|
||||
) -> tuple[str | None, str | None, str | None]:
|
||||
"""Find an event by UID across all of the user's calendars.
|
||||
|
||||
Returns (ical_data, href) or (None, None).
|
||||
Returns (ical_data, href, etag) or (None, None, None).
|
||||
"""
|
||||
client = self.get_dav_client(email)
|
||||
client = self.get_dav_client(user)
|
||||
try:
|
||||
principal = client.principal()
|
||||
for cal in principal.calendars():
|
||||
try:
|
||||
event = cal.object_by_uid(uid)
|
||||
return event.data, str(event.url.path)
|
||||
etag = getattr(event, "props", {}).get("{DAV:}getetag") or getattr(
|
||||
event, "etag", None
|
||||
)
|
||||
return event.data, str(event.url.path), etag
|
||||
except caldav_lib.error.NotFoundError:
|
||||
continue
|
||||
logger.warning("Event UID %s not found in user %s calendars", uid, email)
|
||||
return None, None
|
||||
logger.warning(
|
||||
"Event UID %s not found in user %s calendars", uid, user.email
|
||||
)
|
||||
return None, None, None
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
logger.exception("CalDAV error looking up event %s", uid)
|
||||
return None, None
|
||||
return None, None, None
|
||||
|
||||
def put_event(self, email: str, href: str, ical_data: str) -> bool:
|
||||
"""PUT updated iCalendar data back to CalDAV. Returns True on success."""
|
||||
def put_event(
|
||||
self, user, href: str, ical_data: str, etag: str | None = None
|
||||
) -> bool:
|
||||
"""PUT updated iCalendar data back to CalDAV. Returns True on success.
|
||||
|
||||
If *etag* is provided, the request includes an If-Match header to
|
||||
prevent lost updates from concurrent modifications.
|
||||
"""
|
||||
try:
|
||||
extra_headers = {}
|
||||
if etag:
|
||||
extra_headers["If-Match"] = etag
|
||||
response = self.request(
|
||||
"PUT",
|
||||
email,
|
||||
user,
|
||||
href,
|
||||
data=ical_data.encode("utf-8"),
|
||||
content_type="text/calendar; charset=utf-8",
|
||||
extra_headers=extra_headers or None,
|
||||
)
|
||||
if response.status_code in (200, 201, 204):
|
||||
return True
|
||||
if response.status_code == 412:
|
||||
logger.warning("CalDAV PUT conflict (ETag mismatch) for %s", href)
|
||||
return False
|
||||
logger.error(
|
||||
"CalDAV PUT failed: %s %s",
|
||||
response.status_code,
|
||||
@@ -160,10 +196,10 @@ class CalDAVHTTPClient:
|
||||
cal = icalendar.Calendar.from_ical(ical_data)
|
||||
updated = False
|
||||
|
||||
target = f"mailto:{email.lower()}"
|
||||
for component in cal.walk("VEVENT"):
|
||||
for _name, attendee in component.property_items("ATTENDEE"):
|
||||
attendee_val = str(attendee).lower()
|
||||
if email.lower() in attendee_val:
|
||||
if str(attendee).lower().strip() == target:
|
||||
attendee.params["PARTSTAT"] = icalendar.vText(new_partstat)
|
||||
updated = True
|
||||
|
||||
@@ -182,14 +218,19 @@ class CalDAVClient:
|
||||
self._http = CalDAVHTTPClient()
|
||||
self.base_url = self._http.base_url
|
||||
|
||||
def _calendar_url(self, calendar_path: str) -> str:
|
||||
"""Build a full URL for a calendar path, including the BASE_URI_PATH."""
|
||||
return f"{self.base_url}{CalDAVHTTPClient.BASE_URI_PATH}{calendar_path}"
|
||||
|
||||
def _get_client(self, user) -> DAVClient:
|
||||
"""
|
||||
Get a CalDAV client for the given user.
|
||||
|
||||
The CalDAV server requires API key authentication via Authorization header
|
||||
and X-Forwarded-User header for user identification.
|
||||
Includes X-CalDAV-Organization when the user has an org.
|
||||
"""
|
||||
return self._http.get_dav_client(user.email)
|
||||
return self._http.get_dav_client(user)
|
||||
|
||||
def get_calendar_info(self, user, calendar_path: str) -> dict | None:
|
||||
"""
|
||||
@@ -197,7 +238,7 @@ class CalDAVClient:
|
||||
Returns dict with name, color, description or None if not found.
|
||||
"""
|
||||
client = self._get_client(user)
|
||||
calendar_url = f"{self.base_url}{calendar_path}"
|
||||
calendar_url = self._calendar_url(calendar_path)
|
||||
|
||||
try:
|
||||
calendar = client.calendar(url=calendar_url)
|
||||
@@ -227,37 +268,53 @@ class CalDAVClient:
|
||||
logger.error("Failed to get calendar info from CalDAV: %s", str(e))
|
||||
return None
|
||||
|
||||
def create_calendar(
|
||||
self, user, calendar_name: str, calendar_id: str, color: str = ""
|
||||
def create_calendar( # pylint: disable=too-many-arguments
|
||||
self,
|
||||
user,
|
||||
calendar_name: str = "",
|
||||
calendar_id: str = "",
|
||||
color: str = "",
|
||||
*,
|
||||
name: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Create a new calendar in CalDAV server for the given user.
|
||||
Returns the CalDAV server path for the calendar.
|
||||
"""
|
||||
calendar_name = calendar_name or name
|
||||
if not calendar_id:
|
||||
calendar_id = str(uuid4())
|
||||
if not color:
|
||||
color = settings.DEFAULT_CALENDAR_COLOR
|
||||
client = self._get_client(user)
|
||||
principal = client.principal()
|
||||
|
||||
try:
|
||||
# Create calendar using caldav library
|
||||
calendar = principal.make_calendar(name=calendar_name)
|
||||
# Pass cal_id so the library uses our UUID for the path.
|
||||
calendar = principal.make_calendar(name=calendar_name, cal_id=calendar_id)
|
||||
|
||||
# Set calendar color if provided
|
||||
if color:
|
||||
calendar.set_properties([CalendarColor(color)])
|
||||
|
||||
# CalDAV server calendar path format: /calendars/{username}/{calendar_id}/
|
||||
# The caldav library returns a URL object, convert to string and extract path
|
||||
# Extract CalDAV-relative path from the calendar URL
|
||||
calendar_url = str(calendar.url)
|
||||
# Extract path from full URL
|
||||
if calendar_url.startswith(self.base_url):
|
||||
path = calendar_url[len(self.base_url) :]
|
||||
else:
|
||||
# Fallback: construct path manually based on standard CalDAV structure
|
||||
# CalDAV servers typically create calendars under /calendars/{principal}/
|
||||
path = f"/calendars/{user.email}/{calendar_id}/"
|
||||
path = f"/calendars/users/{user.email}/{calendar_id}/"
|
||||
|
||||
base_prefix = CalDAVHTTPClient.BASE_URI_PATH
|
||||
if path.startswith(base_prefix):
|
||||
path = path[len(base_prefix) :]
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
|
||||
path = unquote(path)
|
||||
|
||||
logger.info(
|
||||
"Created calendar in CalDAV server: %s at %s", calendar_name, path
|
||||
"Created calendar in CalDAV server: %s at %s",
|
||||
calendar_name,
|
||||
path,
|
||||
)
|
||||
return path
|
||||
except Exception as e:
|
||||
@@ -285,7 +342,7 @@ class CalDAVClient:
|
||||
client = self._get_client(user)
|
||||
|
||||
# Get calendar by URL
|
||||
calendar_url = f"{self.base_url}{calendar_path}"
|
||||
calendar_url = self._calendar_url(calendar_path)
|
||||
calendar = client.calendar(url=calendar_url)
|
||||
|
||||
try:
|
||||
@@ -323,7 +380,7 @@ class CalDAVClient:
|
||||
Returns the event UID.
|
||||
"""
|
||||
client = self._get_client(user)
|
||||
calendar_url = f"{self.base_url}{calendar_path}"
|
||||
calendar_url = self._calendar_url(calendar_path)
|
||||
calendar = client.calendar(url=calendar_url)
|
||||
|
||||
try:
|
||||
@@ -342,7 +399,7 @@ class CalDAVClient:
|
||||
"""
|
||||
|
||||
client = self._get_client(user)
|
||||
calendar_url = f"{self.base_url}{calendar_path}"
|
||||
calendar_url = self._calendar_url(calendar_path)
|
||||
calendar = client.calendar(url=calendar_url)
|
||||
|
||||
# Extract event data
|
||||
@@ -385,27 +442,11 @@ class CalDAVClient:
|
||||
"""Update an existing event in CalDAV server."""
|
||||
|
||||
client = self._get_client(user)
|
||||
calendar_url = f"{self.base_url}{calendar_path}"
|
||||
calendar_url = self._calendar_url(calendar_path)
|
||||
calendar = client.calendar(url=calendar_url)
|
||||
|
||||
try:
|
||||
# Search for the event by UID
|
||||
events = calendar.search(event=True)
|
||||
target_event = None
|
||||
|
||||
for event in events:
|
||||
event_uid_value = None
|
||||
if hasattr(event, "icalendar_component"):
|
||||
event_uid_value = str(event.icalendar_component.get("uid", ""))
|
||||
elif hasattr(event, "vobject_instance"):
|
||||
event_uid_value = event.vobject_instance.vevent.uid.value
|
||||
|
||||
if event_uid_value == event_uid:
|
||||
target_event = event
|
||||
break
|
||||
|
||||
if not target_event:
|
||||
raise ValueError(f"Event with UID {event_uid} not found")
|
||||
target_event = calendar.object_by_uid(event_uid)
|
||||
|
||||
# Update event properties
|
||||
dtstart = event_data.get("start")
|
||||
@@ -432,6 +473,8 @@ class CalDAVClient:
|
||||
target_event.save()
|
||||
|
||||
logger.info("Updated event in CalDAV server: %s", event_uid)
|
||||
except NotFoundError:
|
||||
raise ValueError(f"Event with UID {event_uid} not found") from None
|
||||
except Exception as e:
|
||||
logger.error("Failed to update event in CalDAV server: %s", str(e))
|
||||
raise
|
||||
@@ -440,36 +483,44 @@ class CalDAVClient:
|
||||
"""Delete an event from CalDAV server."""
|
||||
|
||||
client = self._get_client(user)
|
||||
calendar_url = f"{self.base_url}{calendar_path}"
|
||||
calendar_url = self._calendar_url(calendar_path)
|
||||
calendar = client.calendar(url=calendar_url)
|
||||
|
||||
try:
|
||||
# Search for the event by UID
|
||||
events = calendar.search(event=True)
|
||||
target_event = None
|
||||
|
||||
for event in events:
|
||||
event_uid_value = None
|
||||
if hasattr(event, "icalendar_component"):
|
||||
event_uid_value = str(event.icalendar_component.get("uid", ""))
|
||||
elif hasattr(event, "vobject_instance"):
|
||||
event_uid_value = event.vobject_instance.vevent.uid.value
|
||||
|
||||
if event_uid_value == event_uid:
|
||||
target_event = event
|
||||
break
|
||||
|
||||
if not target_event:
|
||||
raise ValueError(f"Event with UID {event_uid} not found")
|
||||
|
||||
# Delete the event
|
||||
target_event = calendar.object_by_uid(event_uid)
|
||||
target_event.delete()
|
||||
|
||||
logger.info("Deleted event from CalDAV server: %s", event_uid)
|
||||
except NotFoundError:
|
||||
raise ValueError(f"Event with UID {event_uid} not found") from None
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete event from CalDAV server: %s", str(e))
|
||||
raise
|
||||
|
||||
def get_user_calendar_paths(self, user) -> list[str]:
|
||||
"""Return a list of CalDAV-relative calendar paths for the user."""
|
||||
client = self._get_client(user)
|
||||
principal = client.principal()
|
||||
paths = []
|
||||
base = f"{self.base_url}{CalDAVHTTPClient.BASE_URI_PATH}"
|
||||
for cal in principal.calendars():
|
||||
url = str(cal.url)
|
||||
if url.startswith(base):
|
||||
paths.append(unquote(url[len(base) :]))
|
||||
return paths
|
||||
|
||||
def create_default_calendar(self, user) -> str:
|
||||
"""Create a default calendar for a user. Returns the caldav_path."""
|
||||
from core.services.translation_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
|
||||
TranslationService,
|
||||
)
|
||||
|
||||
calendar_id = str(uuid4())
|
||||
lang = TranslationService.resolve_language(email=user.email)
|
||||
calendar_name = TranslationService.t("calendar.list.defaultCalendarName", lang)
|
||||
return self.create_calendar(
|
||||
user, calendar_name, calendar_id, color=settings.DEFAULT_CALENDAR_COLOR
|
||||
)
|
||||
|
||||
def _parse_event(self, event) -> Optional[dict]:
|
||||
"""
|
||||
Parse a caldav Event object and return event data as dictionary.
|
||||
@@ -491,13 +542,15 @@ class CalDAVClient:
|
||||
# Convert datetime to string format for consistency
|
||||
if event_data["start"]:
|
||||
if isinstance(event_data["start"], datetime):
|
||||
event_data["start"] = event_data["start"].strftime("%Y%m%dT%H%M%SZ")
|
||||
utc_start = event_data["start"].astimezone(dt_timezone.utc)
|
||||
event_data["start"] = utc_start.strftime("%Y%m%dT%H%M%SZ")
|
||||
elif isinstance(event_data["start"], date):
|
||||
event_data["start"] = event_data["start"].strftime("%Y%m%d")
|
||||
|
||||
if event_data["end"]:
|
||||
if isinstance(event_data["end"], datetime):
|
||||
event_data["end"] = event_data["end"].strftime("%Y%m%dT%H%M%SZ")
|
||||
utc_end = event_data["end"].astimezone(dt_timezone.utc)
|
||||
event_data["end"] = utc_end.strftime("%Y%m%dT%H%M%SZ")
|
||||
elif isinstance(event_data["end"], date):
|
||||
event_data["end"] = event_data["end"].strftime("%Y%m%d")
|
||||
|
||||
@@ -507,60 +560,19 @@ class CalDAVClient:
|
||||
return None
|
||||
|
||||
|
||||
class CalendarService:
|
||||
"""
|
||||
High-level service for managing calendars and events.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.caldav = CalDAVClient()
|
||||
|
||||
def create_default_calendar(self, user) -> str:
|
||||
"""Create a default calendar for a user. Returns the caldav_path."""
|
||||
from core.services.translation_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
|
||||
TranslationService,
|
||||
)
|
||||
|
||||
calendar_id = str(uuid4())
|
||||
lang = TranslationService.resolve_language(email=user.email)
|
||||
calendar_name = TranslationService.t("calendar.list.defaultCalendarName", lang)
|
||||
return self.caldav.create_calendar(
|
||||
user, calendar_name, calendar_id, color=settings.DEFAULT_CALENDAR_COLOR
|
||||
)
|
||||
|
||||
def create_calendar(self, user, name: str, color: str = "") -> str:
|
||||
"""Create a new calendar for a user. Returns the caldav_path."""
|
||||
calendar_id = str(uuid4())
|
||||
return self.caldav.create_calendar(
|
||||
user, name, calendar_id, color=color or settings.DEFAULT_CALENDAR_COLOR
|
||||
)
|
||||
|
||||
def get_events(self, user, caldav_path: str, start=None, end=None) -> list:
|
||||
"""Get events from a calendar. Returns parsed event data."""
|
||||
return self.caldav.get_events(user, caldav_path, start, end)
|
||||
|
||||
def create_event(self, user, caldav_path: str, event_data: dict) -> str:
|
||||
"""Create a new event."""
|
||||
return self.caldav.create_event(user, caldav_path, event_data)
|
||||
|
||||
def update_event(
|
||||
self, user, caldav_path: str, event_uid: str, event_data: dict
|
||||
) -> None:
|
||||
"""Update an existing event."""
|
||||
self.caldav.update_event(user, caldav_path, event_uid, event_data)
|
||||
|
||||
def delete_event(self, user, caldav_path: str, event_uid: str) -> None:
|
||||
"""Delete an event."""
|
||||
self.caldav.delete_event(user, caldav_path, event_uid)
|
||||
# CalendarService is kept as an alias for backwards compatibility
|
||||
# with tests and signals that reference it.
|
||||
CalendarService = CalDAVClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CalDAV path utilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Pattern: /calendars/<email-or-encoded>/<calendar-id>/
|
||||
# Pattern: /calendars/users/<email-or-encoded>/<calendar-id>/
|
||||
# or /calendars/resources/<resource-id>/<calendar-id>/
|
||||
CALDAV_PATH_PATTERN = re.compile(
|
||||
r"^/calendars/[^/]+/[a-zA-Z0-9-]+/$",
|
||||
r"^/calendars/(users|resources)/[^/]+/[a-zA-Z0-9-]+/$",
|
||||
)
|
||||
|
||||
|
||||
@@ -568,8 +580,8 @@ def normalize_caldav_path(caldav_path):
|
||||
"""Normalize CalDAV path to consistent format.
|
||||
|
||||
Strips the CalDAV API prefix (e.g. /api/v1.0/caldav/) if present,
|
||||
so that paths like /api/v1.0/caldav/calendars/user@ex.com/uuid/
|
||||
become /calendars/user@ex.com/uuid/.
|
||||
so that paths like /api/v1.0/caldav/calendars/users/user@ex.com/uuid/
|
||||
become /calendars/users/user@ex.com/uuid/.
|
||||
"""
|
||||
if not caldav_path.startswith("/"):
|
||||
caldav_path = "/" + caldav_path
|
||||
@@ -582,19 +594,60 @@ def normalize_caldav_path(caldav_path):
|
||||
return caldav_path
|
||||
|
||||
|
||||
def _resource_belongs_to_org(resource_id: str, org_id: str) -> bool:
|
||||
"""Check whether a resource principal belongs to the given organization.
|
||||
|
||||
Queries the CalDAV internal API. Returns False on any error (fail-closed).
|
||||
"""
|
||||
api_key = settings.CALDAV_INTERNAL_API_KEY
|
||||
caldav_url = settings.CALDAV_URL
|
||||
if not api_key or not caldav_url:
|
||||
return False
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{caldav_url.rstrip('/')}/caldav/internal-api/resources/{resource_id}",
|
||||
headers={"X-Internal-Api-Key": api_key},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return False
|
||||
data = resp.json()
|
||||
return data.get("org_id") == org_id
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
logger.exception("Failed to verify resource org for %s", resource_id)
|
||||
return False
|
||||
|
||||
|
||||
def verify_caldav_access(user, caldav_path):
|
||||
"""Verify that the user has access to the CalDAV calendar.
|
||||
|
||||
Checks that:
|
||||
1. The path matches the expected pattern (prevents path injection)
|
||||
2. The user's email matches the email in the path
|
||||
2. For user calendars: the user's email matches the email in the path
|
||||
3. For resource calendars: the user has an organization
|
||||
|
||||
Note: Fine-grained org-to-resource authorization is enforced by SabreDAV
|
||||
itself (via X-CalDAV-Organization header). This check only gates access
|
||||
for Django-level features (subscription tokens, imports).
|
||||
"""
|
||||
if not CALDAV_PATH_PATTERN.match(caldav_path):
|
||||
return False
|
||||
parts = caldav_path.strip("/").split("/")
|
||||
if len(parts) >= 2 and parts[0] == "calendars":
|
||||
path_email = unquote(parts[1])
|
||||
if len(parts) < 3 or parts[0] != "calendars":
|
||||
return False
|
||||
# User calendars: calendars/users/<email>/<calendar-id>
|
||||
if parts[1] == "users":
|
||||
if not user.email:
|
||||
return False
|
||||
path_email = unquote(parts[2])
|
||||
return path_email.lower() == user.email.lower()
|
||||
# Resource calendars: calendars/resources/<resource-id>/<calendar-id>
|
||||
# Org membership is required. Fine-grained org-to-resource authorization
|
||||
# is enforced by SabreDAV via the X-CalDAV-Organization header on every
|
||||
# proxied request. For subscription tokens / imports, callers should
|
||||
# additionally use _resource_belongs_to_org() to verify ownership.
|
||||
if parts[1] == "resources":
|
||||
return bool(getattr(user, "organization_id", None))
|
||||
return False
|
||||
|
||||
|
||||
@@ -605,10 +658,16 @@ def validate_caldav_proxy_path(path):
|
||||
- Directory traversal sequences (../)
|
||||
- Null bytes
|
||||
- Paths that don't start with expected prefixes
|
||||
|
||||
URL-decodes the path first so that encoded payloads like
|
||||
``%2e%2e`` or ``%00`` cannot bypass the checks.
|
||||
"""
|
||||
if not path:
|
||||
return True # Empty path is fine (root request)
|
||||
|
||||
# Decode percent-encoded characters before validation
|
||||
path = unquote(path)
|
||||
|
||||
# Block directory traversal
|
||||
if ".." in path:
|
||||
return False
|
||||
@@ -617,10 +676,60 @@ def validate_caldav_proxy_path(path):
|
||||
if "\x00" in path:
|
||||
return False
|
||||
|
||||
clean = path.lstrip("/")
|
||||
|
||||
# Explicitly block internal-api/ paths — these must never be proxied.
|
||||
# The allowlist below already rejects them, but an explicit block makes
|
||||
# the intent clear and survives future allowlist additions.
|
||||
blocked_prefixes = ("internal-api/",)
|
||||
if clean and any(clean.startswith(prefix) for prefix in blocked_prefixes):
|
||||
return False
|
||||
|
||||
# Path must start with a known CalDAV resource prefix
|
||||
allowed_prefixes = ("calendars/", "principals/", ".well-known/")
|
||||
clean = path.lstrip("/")
|
||||
if clean and not any(clean.startswith(prefix) for prefix in allowed_prefixes):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def cleanup_organization_caldav_data(org):
|
||||
"""Clean up CalDAV data for all members of an organization.
|
||||
|
||||
Deletes each member's CalDAV data via the SabreDAV internal API,
|
||||
then deletes the Django User objects so the PROTECT foreign key
|
||||
on User.organization doesn't block org deletion.
|
||||
|
||||
Called from Organization.delete() — NOT a signal, because the
|
||||
PROTECT FK raises ProtectedError before pre_delete fires.
|
||||
"""
|
||||
if not settings.CALDAV_INTERNAL_API_KEY:
|
||||
return
|
||||
|
||||
http = CalDAVHTTPClient()
|
||||
members = list(org.members.all())
|
||||
|
||||
for user in members:
|
||||
if not user.email:
|
||||
continue
|
||||
try:
|
||||
http.request(
|
||||
"POST",
|
||||
user,
|
||||
"internal-api/users/delete",
|
||||
data=json.dumps({"email": user.email}).encode("utf-8"),
|
||||
content_type="application/json",
|
||||
extra_headers={
|
||||
"X-Internal-Api-Key": settings.CALDAV_INTERNAL_API_KEY,
|
||||
},
|
||||
)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
logger.exception(
|
||||
"Failed to clean up CalDAV data for user %s (org %s)",
|
||||
user.email,
|
||||
org.external_id,
|
||||
)
|
||||
|
||||
# Delete all members so the PROTECT FK doesn't block org deletion.
|
||||
# CalDAV cleanup is best-effort; orphaned CalDAV data is acceptable.
|
||||
org.members.all().delete()
|
||||
|
||||
Reference in New Issue
Block a user