✨(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,11 +1,14 @@
|
||||
"""CalDAV proxy views for forwarding requests to CalDAV server."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.validators import validate_email
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
@@ -13,6 +16,7 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
import requests
|
||||
|
||||
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||
from core.models import Channel
|
||||
from core.services.caldav_service import CalDAVHTTPClient, validate_caldav_proxy_path
|
||||
from core.services.calendar_invitation_service import calendar_invitation_service
|
||||
|
||||
@@ -30,6 +34,73 @@ class CalDAVProxyView(View):
|
||||
Authentication is handled via session cookies instead.
|
||||
"""
|
||||
|
||||
# HTTP methods allowed per Channel role
|
||||
READER_METHODS = frozenset({"GET", "PROPFIND", "REPORT", "OPTIONS"})
|
||||
EDITOR_METHODS = READER_METHODS | frozenset({"PUT", "POST", "DELETE", "PROPPATCH"})
|
||||
ADMIN_METHODS = EDITOR_METHODS | frozenset({"MKCALENDAR", "MKCOL"})
|
||||
|
||||
ROLE_METHODS = {
|
||||
Channel.ROLE_READER: READER_METHODS,
|
||||
Channel.ROLE_EDITOR: EDITOR_METHODS,
|
||||
Channel.ROLE_ADMIN: ADMIN_METHODS,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _authenticate_channel_token(request):
|
||||
"""Try to authenticate via X-Channel-Id + X-Channel-Token headers.
|
||||
|
||||
Returns (channel, user) on success, (None, None) on failure.
|
||||
"""
|
||||
channel_id = request.headers.get("X-Channel-Id", "").strip()
|
||||
token = request.headers.get("X-Channel-Token", "").strip()
|
||||
if not channel_id or not token:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
channel = Channel.objects.get(pk=channel_id, is_active=True, type="caldav")
|
||||
except (ValueError, Channel.DoesNotExist):
|
||||
return None, None
|
||||
|
||||
if not channel.verify_token(token):
|
||||
return None, None
|
||||
|
||||
user = channel.user
|
||||
if not user:
|
||||
logger.warning("Channel %s has no user", channel.id)
|
||||
return None, None
|
||||
|
||||
# Update last_used_at (fire-and-forget, no extra query on critical path)
|
||||
Channel.objects.filter(pk=channel.pk).update(last_used_at=timezone.now())
|
||||
|
||||
return channel, user
|
||||
|
||||
@staticmethod
|
||||
def _check_channel_path_access(channel, path):
|
||||
"""Check that the CalDAV path is within the channel's scope.
|
||||
|
||||
Returns True if allowed, False if denied.
|
||||
"""
|
||||
# Ensure path starts with /
|
||||
full_path = "/" + path.lstrip("/") if path else "/"
|
||||
|
||||
# caldav_path scope: request must be within the scoped calendar
|
||||
# The trailing slash on caldav_path (enforced by serializer) ensures
|
||||
# /cal1/ won't match /cal1-secret/
|
||||
if channel.caldav_path:
|
||||
if not channel.caldav_path.endswith("/"):
|
||||
logger.error(
|
||||
"caldav_path %r missing trailing slash", channel.caldav_path
|
||||
)
|
||||
return False
|
||||
return full_path.startswith(channel.caldav_path)
|
||||
|
||||
# user scope: request must be under the user's calendars
|
||||
if channel.user:
|
||||
user_prefix = f"/calendars/users/{channel.user.email}/"
|
||||
return full_path.startswith(user_prefix)
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _check_entitlements_for_creation(user):
|
||||
"""Check if user is entitled to create calendars.
|
||||
@@ -39,7 +110,7 @@ class CalDAVProxyView(View):
|
||||
"""
|
||||
try:
|
||||
entitlements = get_user_entitlements(user.sub, user.email)
|
||||
if not entitlements.get("can_access", True):
|
||||
if not entitlements.get("can_access", False):
|
||||
return HttpResponse(
|
||||
status=403,
|
||||
content="Calendar creation not allowed",
|
||||
@@ -51,27 +122,42 @@ class CalDAVProxyView(View):
|
||||
)
|
||||
return None
|
||||
|
||||
def dispatch(self, request, *args, **kwargs): # noqa: PLR0912, PLR0911, PLR0915 # pylint: disable=too-many-branches,too-many-return-statements,too-many-statements
|
||||
def dispatch(self, request, *args, **kwargs): # noqa: PLR0912, PLR0911, PLR0915 # pylint: disable=too-many-branches,too-many-return-statements,too-many-statements,too-many-locals
|
||||
"""Forward all HTTP methods to CalDAV server."""
|
||||
# Handle CORS preflight requests
|
||||
if request.method == "OPTIONS":
|
||||
response = HttpResponse(status=200)
|
||||
response["Access-Control-Allow-Methods"] = (
|
||||
"GET, OPTIONS, PROPFIND, PROPPATCH, REPORT, MKCOL, MKCALENDAR, PUT, DELETE, POST"
|
||||
"GET, OPTIONS, PROPFIND, PROPPATCH, REPORT,"
|
||||
" MKCOL, MKCALENDAR, PUT, DELETE, POST"
|
||||
)
|
||||
response["Access-Control-Allow-Headers"] = (
|
||||
"Content-Type, depth, authorization, if-match, if-none-match, prefer"
|
||||
"Content-Type, depth, x-channel-id, x-channel-token,"
|
||||
" if-match, if-none-match, prefer"
|
||||
)
|
||||
return response
|
||||
|
||||
# Try channel token auth first (for external services like Messages)
|
||||
channel = None
|
||||
effective_user = None
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponse(status=401)
|
||||
channel, effective_user = self._authenticate_channel_token(request)
|
||||
if not channel:
|
||||
return HttpResponse(status=401)
|
||||
else:
|
||||
effective_user = request.user
|
||||
|
||||
# Block calendar creation (MKCALENDAR/MKCOL) for non-entitled users.
|
||||
# Other methods (GET, PROPFIND, REPORT, PUT, DELETE, etc.) are allowed
|
||||
# so that users invited to shared calendars can still use them.
|
||||
if channel:
|
||||
# Enforce role-based method restrictions
|
||||
allowed = self.ROLE_METHODS.get(channel.role, self.READER_METHODS)
|
||||
if request.method not in allowed:
|
||||
return HttpResponse(
|
||||
status=403, content="Method not allowed for this role"
|
||||
)
|
||||
|
||||
# Check entitlements for calendar creation (all auth methods)
|
||||
if request.method in ("MKCALENDAR", "MKCOL"):
|
||||
if denied := self._check_entitlements_for_creation(request.user):
|
||||
if denied := self._check_entitlements_for_creation(effective_user):
|
||||
return denied
|
||||
|
||||
# Build the CalDAV server URL
|
||||
@@ -81,8 +167,9 @@ class CalDAVProxyView(View):
|
||||
if not validate_caldav_proxy_path(path):
|
||||
return HttpResponse(status=400, content="Invalid path")
|
||||
|
||||
# Use user email as the principal (CalDAV server uses email as username)
|
||||
user_principal = request.user.email
|
||||
# Enforce channel path scope
|
||||
if channel and not self._check_channel_path_access(channel, path):
|
||||
return HttpResponse(status=403, content="Path not allowed for this channel")
|
||||
|
||||
http = CalDAVHTTPClient()
|
||||
|
||||
@@ -95,7 +182,7 @@ class CalDAVProxyView(View):
|
||||
|
||||
# Prepare headers — start with shared auth headers, add proxy-specific ones
|
||||
try:
|
||||
headers = CalDAVHTTPClient.build_base_headers(user_principal)
|
||||
headers = CalDAVHTTPClient.build_base_headers(effective_user)
|
||||
except ValueError:
|
||||
logger.error("CALDAV_OUTBOUND_API_KEY is not configured")
|
||||
return HttpResponse(
|
||||
@@ -112,7 +199,7 @@ class CalDAVProxyView(View):
|
||||
# Use CALDAV_CALLBACK_BASE_URL if configured (for Docker environments where
|
||||
# the CalDAV container needs to reach Django via internal network)
|
||||
callback_path = reverse("caldav-scheduling-callback")
|
||||
callback_base_url = getattr(settings, "CALDAV_CALLBACK_BASE_URL", None)
|
||||
callback_base_url = settings.CALDAV_CALLBACK_BASE_URL
|
||||
if callback_base_url:
|
||||
# Use configured internal URL (e.g., http://backend:8000)
|
||||
headers["X-CalDAV-Callback-URL"] = (
|
||||
@@ -145,7 +232,7 @@ class CalDAVProxyView(View):
|
||||
"Forwarding %s request to CalDAV server: %s (user: %s)",
|
||||
request.method,
|
||||
target_url,
|
||||
user_principal,
|
||||
effective_user.email,
|
||||
)
|
||||
response = requests.request(
|
||||
method=request.method,
|
||||
@@ -161,7 +248,7 @@ class CalDAVProxyView(View):
|
||||
if response.status_code == 401:
|
||||
logger.warning(
|
||||
"CalDAV server returned 401 for user %s at %s",
|
||||
user_principal,
|
||||
effective_user.email,
|
||||
target_url,
|
||||
)
|
||||
|
||||
@@ -182,7 +269,7 @@ class CalDAVProxyView(View):
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error("CalDAV server proxy error: %s", str(e))
|
||||
return HttpResponse(
|
||||
content=f"CalDAV server error: {str(e)}",
|
||||
content="CalDAV server is unavailable",
|
||||
status=502,
|
||||
content_type="text/plain",
|
||||
)
|
||||
@@ -216,9 +303,8 @@ class CalDAVDiscoveryView(View):
|
||||
# Clients need to discover the CalDAV URL before authenticating
|
||||
|
||||
# Return redirect to CalDAV server base URL
|
||||
caldav_base_url = f"/api/{settings.API_VERSION}/caldav/"
|
||||
response = HttpResponse(status=301)
|
||||
response["Location"] = caldav_base_url
|
||||
response["Location"] = "/caldav/"
|
||||
return response
|
||||
|
||||
|
||||
@@ -239,24 +325,25 @@ class CalDAVSchedulingCallbackView(View):
|
||||
See: https://sabre.io/dav/scheduling/
|
||||
"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
http_method_names = ["post"]
|
||||
|
||||
def post(self, request, *args, **kwargs): # noqa: PLR0911 # pylint: disable=too-many-return-statements
|
||||
"""Handle scheduling messages from CalDAV server."""
|
||||
# Authenticate via API key
|
||||
api_key = request.headers.get("X-Api-Key", "").strip()
|
||||
expected_key = settings.CALDAV_INBOUND_API_KEY
|
||||
|
||||
if not expected_key or not secrets.compare_digest(api_key, expected_key):
|
||||
logger.warning(
|
||||
"CalDAV scheduling callback request with invalid API key. "
|
||||
"Expected: %s..., Got: %s...",
|
||||
expected_key[:10] if expected_key else "None",
|
||||
api_key[:10] if api_key else "None",
|
||||
)
|
||||
logger.warning("CalDAV scheduling callback request with invalid API key.")
|
||||
return HttpResponse(status=401)
|
||||
|
||||
# Extract headers
|
||||
sender = request.headers.get("X-CalDAV-Sender", "")
|
||||
recipient = request.headers.get("X-CalDAV-Recipient", "")
|
||||
# Extract and validate sender/recipient emails
|
||||
sender = re.sub(
|
||||
r"^mailto:", "", request.headers.get("X-CalDAV-Sender", "")
|
||||
).strip()
|
||||
recipient = re.sub(
|
||||
r"^mailto:", "", request.headers.get("X-CalDAV-Recipient", "")
|
||||
).strip()
|
||||
method = request.headers.get("X-CalDAV-Method", "").upper()
|
||||
|
||||
# Validate required fields
|
||||
@@ -275,6 +362,22 @@ class CalDAVSchedulingCallbackView(View):
|
||||
content_type="text/plain",
|
||||
)
|
||||
|
||||
# Validate email format
|
||||
try:
|
||||
validate_email(sender)
|
||||
validate_email(recipient)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
logger.warning(
|
||||
"CalDAV scheduling callback with invalid email: sender=%s, recipient=%s",
|
||||
sender,
|
||||
recipient,
|
||||
)
|
||||
return HttpResponse(
|
||||
status=400,
|
||||
content="Invalid sender or recipient email",
|
||||
content_type="text/plain",
|
||||
)
|
||||
|
||||
# Get iCalendar data from request body
|
||||
icalendar_data = (
|
||||
request.body.decode("utf-8", errors="replace") if request.body else ""
|
||||
@@ -332,6 +435,6 @@ class CalDAVSchedulingCallbackView(View):
|
||||
logger.exception("Error processing CalDAV scheduling callback: %s", e)
|
||||
return HttpResponse(
|
||||
status=500,
|
||||
content=f"Internal error: {str(e)}",
|
||||
content="Internal server error",
|
||||
content_type="text/plain",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user