Compare commits

...

10 Commits

14 changed files with 1276 additions and 544 deletions

View File

@@ -40,9 +40,13 @@ The first backend implementation stores VEVENT data in two layers:
CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source
records the remote collection URL, sync token, ETag/ctag state, username, records the remote collection URL, sync token, ETag/ctag state, username,
credential reference, sync interval, sync direction, and conflict policy. credential reference, sync interval, sync direction, and conflict policy.
Credentials can be supplied transiently for manual sync, referenced from Credentials can be supplied transiently for manual sync. Persisted API-managed
environment variables with `env:NAME`, stored through a platform secret provider sources accept only a password or token: Calendar stores an opaque, tenant- and
when one is available, or stored as encrypted calendar-owned credentials. source-bound local reference, backed by the platform secret provider when one is
available or by an encrypted calendar-owned credential otherwise. Caller-selected
environment and external-provider references are rejected. Trusted deployment
code may resolve an `env:NAME` reference only through the separate deployment
configuration helper.
Inbound sync uses CalDAV `calendar-query` for full sync and `sync-collection` Inbound sync uses CalDAV `calendar-query` for full sync and `sync-collection`
when a sync token exists. It imports all VEVENT components in a resource and when a sync token exists. It imports all VEVENT components in a resource and

View File

@@ -19,7 +19,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -11,7 +11,7 @@ requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.8", "govoplan-core>=0.1.9",
"govoplan-access>=0.1.8", "govoplan-access>=0.1.8",
"defusedxml>=0.7,<1", "defusedxml>=0.7,<1",
"icalendar>=7.2", "icalendar>=7.2",

View File

@@ -2,4 +2,4 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "0.1.4" __version__ = "0.1.8"

View File

@@ -9,6 +9,12 @@ from dataclasses import dataclass, field
from typing import Any, Mapping, Protocol from typing import Any, Mapping, Protocol
from defusedxml import ElementTree as SafeElementTree from defusedxml import ElementTree as SafeElementTree
from govoplan_core.security.outbound_http import (
OutboundHttpError,
bounded_response_bytes,
build_outbound_http_opener,
validate_outbound_http_url,
)
class CalDAVError(RuntimeError): class CalDAVError(RuntimeError):
@@ -353,20 +359,31 @@ class CalDAVClient:
def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]: def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
url = validate_http_url(url) url = validate_http_url(url)
try: try:
url = validate_outbound_http_url(url, label="CalDAV URL")
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined. request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
url, url,
data=body, data=body,
headers=dict(headers), headers=dict(headers),
method=method, method=method,
) )
opener = urllib.request.build_opener(_SameOriginRedirectHandler(url)) opener = build_outbound_http_opener(_SameOriginRedirectHandler(url))
with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated CalDAV URL; redirects remain on origin. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated CalDAV URL; redirects remain on origin. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
return response.status, dict(response.headers.items()), response.read() response_headers = dict(response.headers.items())
return response.status, response_headers, bounded_response_bytes(
response,
headers=response_headers,
label="CalDAV response",
)
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
return exc.code, dict(exc.headers.items()), exc.read() response_headers = dict(exc.headers.items())
try:
payload = bounded_response_bytes(exc, headers=response_headers, label="CalDAV error response")
except OutboundHttpError as policy_exc:
raise CalDAVError(f"{method} {url} failed: {policy_exc}") from policy_exc
return exc.code, response_headers, payload
except urllib.error.URLError as exc: except urllib.error.URLError as exc:
raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc
except ValueError as exc: except (OutboundHttpError, ValueError) as exc:
raise CalDAVError(f"{method} {url} failed: {exc}") from exc raise CalDAVError(f"{method} {url} failed: {exc}") from exc
@@ -542,7 +559,8 @@ class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
del fp, msg, headers del fp, msg, headers
try: try:
candidate = validate_http_url(newurl) candidate = validate_http_url(newurl)
except CalDAVError: candidate = validate_outbound_http_url(candidate, label="CalDAV redirect URL")
except (CalDAVError, OutboundHttpError):
return None return None
if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin: if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin:
return None return None

View File

@@ -858,15 +858,18 @@ def _fail_operation(
session.flush() session.flush()
def execute_calendar_outbox_operation( def _lock_leased_outbox_operation(
session: Session, session: Session,
*, *,
operation_id: str, operation_id: str,
lease_token: str, lease_token: str,
client_factory: Callable[[Session, CalendarSyncSource], object] | None = None, ) -> tuple[CalendarOutboxOperation, CalendarSyncSource | None]:
) -> CalendarOutboxOperation:
operation = session.get(CalendarOutboxOperation, operation_id) operation = session.get(CalendarOutboxOperation, operation_id)
if operation is None or operation.status != "in_progress" or operation.lease_token != lease_token: if (
operation is None
or operation.status != "in_progress"
or operation.lease_token != lease_token
):
raise ValueError("Calendar outbox lease is no longer valid") raise ValueError("Calendar outbox lease is no longer valid")
source = ( source = (
session.query(CalendarSyncSource) session.query(CalendarSyncSource)
@@ -882,36 +885,86 @@ def execute_calendar_outbox_operation(
) )
if operation.status != "in_progress" or operation.lease_token != lease_token: if operation.status != "in_progress" or operation.lease_token != lease_token:
raise ValueError("Calendar outbox lease is no longer valid") raise ValueError("Calendar outbox lease is no longer valid")
operation_metadata = operation.metadata_ or {} return operation, source
unavailable_reason = _operation_source_unavailable_reason(operation, source)
if unavailable_reason:
def _cancel_undeliverable_operation(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource | None,
reason: str,
) -> None:
if source is not None and source.tenant_id == operation.tenant_id: if source is not None and source.tenant_id == operation.tenant_id:
_fail_operation( _fail_operation(
session, session,
operation=operation, operation=operation,
source=source, source=source,
error=unavailable_reason, error=reason,
terminal_status="cancelled", terminal_status="cancelled",
) )
else: return
operation.status = "cancelled" operation.status = "cancelled"
operation.completed_at = utcnow() operation.completed_at = utcnow()
operation.last_error = unavailable_reason operation.last_error = reason
operation.lease_token = None operation.lease_token = None
operation.lease_expires_at = None operation.lease_expires_at = None
session.flush() session.flush()
return operation
overwrite = bool(operation_metadata.get("overwrite")) if isinstance(operation_metadata, dict) else False
client: object | None = None def _calendar_outbox_client(
try: session: Session,
if client_factory is None: *,
source: CalendarSyncSource,
client_factory: Callable[[Session, CalendarSyncSource], object] | None,
) -> object:
if client_factory is not None:
return client_factory(session, source)
from govoplan_calendar.backend.service import caldav_client_for_source from govoplan_calendar.backend.service import caldav_client_for_source
client = caldav_client_for_source(session, source) return caldav_client_for_source(session, source)
else:
client = client_factory(session, source)
if operation.operation_kind == "put": def _try_remote_match(
client: object,
operation: CalendarOutboxOperation,
) -> tuple[bool, str | None] | None:
try:
return _remote_matches_operation(client, operation)
except Exception:
return None
def _complete_if_remote_match_is_usable(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
remote_match: tuple[bool, str | None] | None,
) -> bool:
if remote_match is None:
return False
matched, remote_etag = remote_match
if not matched or (operation.operation_kind != "delete" and not remote_etag):
return False
_complete_operation(
session,
operation=operation,
source=source,
remote_etag=remote_etag,
reconciled=True,
)
return True
def _execute_calendar_outbox_put(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
client: object,
overwrite: bool,
) -> None:
result = client.put_object( result = client.put_object(
operation.resource_href, operation.resource_href,
operation.payload_ics or "", operation.payload_ics or "",
@@ -920,41 +973,48 @@ def execute_calendar_outbox_operation(
overwrite=overwrite, overwrite=overwrite,
) )
remote_etag = result.etag remote_etag = result.etag
reconciled = False if remote_etag:
if not remote_etag: _complete_operation(
try: session,
matched, remote_etag = _remote_matches_operation(client, operation) operation=operation,
except Exception: source=source,
matched, remote_etag = False, None remote_etag=remote_etag,
if not matched or not remote_etag: reconciled=False,
)
return
remote_match = _try_remote_match(client, operation)
if _complete_if_remote_match_is_usable(
session,
operation=operation,
source=source,
remote_match=remote_match,
):
return
_fail_operation( _fail_operation(
session, session,
operation=operation, operation=operation,
source=source, source=source,
error="CalDAV PUT succeeded without an ETag and could not be reconciled safely", error="CalDAV PUT succeeded without an ETag and could not be reconciled safely",
) )
return operation
reconciled = True
_complete_operation(
session,
operation=operation,
source=source,
remote_etag=remote_etag,
reconciled=reconciled,
)
return operation
def _execute_calendar_outbox_delete(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
client: object,
overwrite: bool,
) -> None:
if not operation.expected_etag and not overwrite: if not operation.expected_etag and not overwrite:
matched, remote_etag = _remote_matches_operation(client, operation) remote_match = _remote_matches_operation(client, operation)
if matched: if _complete_if_remote_match_is_usable(
_complete_operation(
session, session,
operation=operation, operation=operation,
source=source, source=source,
remote_etag=remote_etag, remote_match=remote_match,
reconciled=True, ):
) return
return operation
_fail_operation( _fail_operation(
session, session,
operation=operation, operation=operation,
@@ -965,7 +1025,7 @@ def execute_calendar_outbox_operation(
), ),
terminal_status="conflict", terminal_status="conflict",
) )
return operation return
result = client.delete_object( result = client.delete_object(
operation.resource_href, operation.resource_href,
etag=operation.expected_etag, etag=operation.expected_etag,
@@ -978,25 +1038,28 @@ def execute_calendar_outbox_operation(
remote_etag=result.etag, remote_etag=result.etag,
reconciled=result.status == 404, reconciled=result.status == 404,
) )
return operation
except CalDAVPreconditionFailed as exc:
if client is None: def _handle_outbox_precondition_failure(
_fail_operation(session, operation=operation, source=source, error=exc) session: Session,
return operation *,
try: operation: CalendarOutboxOperation,
matched, remote_etag = _remote_matches_operation(client, operation) source: CalendarSyncSource,
except Exception: client: object | None,
_fail_operation(session, operation=operation, source=source, error=exc) error: CalDAVPreconditionFailed,
else: ) -> None:
if matched and (operation.operation_kind == "delete" or remote_etag): remote_match = _try_remote_match(client, operation) if client is not None else None
_complete_operation( if _complete_if_remote_match_is_usable(
session, session,
operation=operation, operation=operation,
source=source, source=source,
remote_etag=remote_etag, remote_match=remote_match,
reconciled=True, ):
) return
else: if remote_match is None:
_fail_operation(session, operation=operation, source=source, error=error)
return
matched, _remote_etag = remote_match
_fail_operation( _fail_operation(
session, session,
operation=operation, operation=operation,
@@ -1008,24 +1071,96 @@ def execute_calendar_outbox_operation(
), ),
terminal_status=None if matched else "conflict", terminal_status=None if matched else "conflict",
) )
return operation
except Exception as exc:
matched, remote_etag = False, None def _handle_outbox_delivery_failure(
if client is not None: session: Session,
try: *,
matched, remote_etag = _remote_matches_operation(client, operation) operation: CalendarOutboxOperation,
except Exception: source: CalendarSyncSource,
matched, remote_etag = False, None client: object | None,
if matched and (operation.operation_kind == "delete" or remote_etag): error: Exception,
_complete_operation( ) -> None:
remote_match = _try_remote_match(client, operation) if client is not None else None
if _complete_if_remote_match_is_usable(
session, session,
operation=operation, operation=operation,
source=source, source=source,
remote_etag=remote_etag, remote_match=remote_match,
reconciled=True, ):
return
_fail_operation(session, operation=operation, source=source, error=error)
def execute_calendar_outbox_operation(
session: Session,
*,
operation_id: str,
lease_token: str,
client_factory: Callable[[Session, CalendarSyncSource], object] | None = None,
) -> CalendarOutboxOperation:
operation, source = _lock_leased_outbox_operation(
session,
operation_id=operation_id,
lease_token=lease_token,
)
unavailable_reason = _operation_source_unavailable_reason(operation, source)
if unavailable_reason:
_cancel_undeliverable_operation(
session,
operation=operation,
source=source,
reason=unavailable_reason,
)
return operation
if source is None:
raise ValueError("Calendar outbox source is no longer available")
operation_metadata = operation.metadata_ or {}
overwrite = (
bool(operation_metadata.get("overwrite"))
if isinstance(operation_metadata, dict)
else False
)
client: object | None = None
try:
client = _calendar_outbox_client(
session,
source=source,
client_factory=client_factory,
)
if operation.operation_kind == "put":
_execute_calendar_outbox_put(
session,
operation=operation,
source=source,
client=client,
overwrite=overwrite,
) )
else: else:
_fail_operation(session, operation=operation, source=source, error=exc) _execute_calendar_outbox_delete(
session,
operation=operation,
source=source,
client=client,
overwrite=overwrite,
)
except CalDAVPreconditionFailed as exc:
_handle_outbox_precondition_failure(
session,
operation=operation,
source=source,
client=client,
error=exc,
)
except Exception as exc:
_handle_outbox_delivery_failure(
session,
operation=operation,
source=source,
client=client,
error=exc,
)
return operation return operation

View File

@@ -17,6 +17,13 @@ from defusedxml import ElementTree as SafeElementTree
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.security.outbound_http import (
OutboundHttpError,
bounded_response_bytes,
build_outbound_http_opener,
validate_outbound_http_url,
)
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
from govoplan_calendar.backend.ical import expand_event_occurrences, parse_vevent, parse_vevents from govoplan_calendar.backend.ical import expand_event_occurrences, parse_vevent, parse_vevents
@@ -198,11 +205,12 @@ class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def] def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
try: try:
candidate = validate_http_url(newurl, label="Calendar source redirect URL") candidate = validate_http_url(newurl, label="Calendar source redirect URL")
candidate = validate_outbound_http_url(candidate, label="Calendar source redirect URL")
candidate_origin = _parsed_http_origin( candidate_origin = _parsed_http_origin(
urllib.parse.urlparse(candidate), urllib.parse.urlparse(candidate),
label="Calendar source redirect URL", label="Calendar source redirect URL",
) )
except CalendarError: except (CalendarError, OutboundHttpError):
return None return None
if candidate_origin != self._source_origin: if candidate_origin != self._source_origin:
return None return None
@@ -477,22 +485,23 @@ def update_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo
return calendar return calendar
def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, payload: CalendarCollectionDeleteRequest | None = None) -> None: def _lock_calendar_move_context(
payload = payload or CalendarCollectionDeleteRequest() session: Session,
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id) *,
deleted_at = utcnow() tenant_id: str,
if payload.event_action == "move": calendar: CalendarCollection,
if not payload.target_calendar_id: target_calendar: CalendarCollection,
raise CalendarError("Target calendar is required when moving events") ) -> tuple[
if payload.target_calendar_id == calendar_id: CalendarCollection,
raise CalendarError("Target calendar must be different from the deleted calendar") CalendarCollection,
target_calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.target_calendar_id) CalendarSyncSource | None,
source = None CalendarSyncSource | None,
target_source = None ]:
if hasattr(session, "query"): if not hasattr(session, "query"):
# Source creation takes the collection row lock before linking a return calendar, target_calendar, None, None
# source. Lock both collections in stable order so the mode cannot # Source creation takes the collection row lock before linking a source.
# change between validation and the local/outbox mutation. # Lock both collections in stable order so their modes cannot change
# between validation and the local/outbox mutation.
locked_calendars = ( locked_calendars = (
session.query(CalendarCollection) session.query(CalendarCollection)
.filter( .filter(
@@ -525,7 +534,15 @@ def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo
source = locked_sources[source.id] source = locked_sources[source.id]
if target_source is not None: if target_source is not None:
target_source = locked_sources[target_source.id] target_source = locked_sources[target_source.id]
return calendar, target_calendar, source, target_source
def _validate_calendar_move_source_pair(
payload: CalendarCollectionDeleteRequest,
*,
source: CalendarSyncSource | None,
target_source: CalendarSyncSource | None,
) -> None:
if payload.external_action == "remote_move": if payload.external_action == "remote_move":
raise CalendarError( raise CalendarError(
"external_action='remote_move' is not supported; no destructive remote move was performed" "external_action='remote_move' is not supported; no destructive remote move was performed"
@@ -535,15 +552,17 @@ def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo
"Events cannot be bulk-moved between synchronized calendars; " "Events cannot be bulk-moved between synchronized calendars; "
"external_action='remote_move' remains unsupported" "external_action='remote_move' remains unsupported"
) )
active_events = [event for event in calendar.events if event.deleted_at is None]
previous_event_states = (
{ def _prepare_calendar_move_external_action(
event.id: calendar_event_change_payload(event, prefix="previous_") session: Session,
for event in active_events *,
} tenant_id: str,
if hasattr(session, "query") payload: CalendarCollectionDeleteRequest,
else {} source: CalendarSyncSource | None,
) target_source: CalendarSyncSource | None,
deleted_at: datetime,
) -> None:
if source is not None: if source is not None:
if payload.external_action != "detach_keep_remote": if payload.external_action != "detach_keep_remote":
raise CalendarError( raise CalendarError(
@@ -551,15 +570,16 @@ def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo
"external_action='detach_keep_remote'" "external_action='detach_keep_remote'"
) )
# Retirement rejects a live delivery lease before any local event # Retirement rejects a live delivery lease before any local event
# projection is changed, and cancels all remaining undelivered # projection is changed, and cancels all remaining undelivered desired
# desired state. It never creates a remote DELETE operation. # state. It never creates a remote DELETE operation.
retire_sync_source( retire_sync_source(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
source=source, source=source,
deleted_at=deleted_at, deleted_at=deleted_at,
) )
elif target_source is not None: return
if target_source is not None:
if payload.external_action != "copy_to_remote": if payload.external_action != "copy_to_remote":
raise CalendarError( raise CalendarError(
"Moving local events to a synchronized calendar requires " "Moving local events to a synchronized calendar requires "
@@ -574,11 +594,40 @@ def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo
"external_action='copy_to_remote' requires an active two-way CalDAV target" "external_action='copy_to_remote' requires an active two-way CalDAV target"
) )
assert_sync_mutation_allowed(target_source) assert_sync_mutation_allowed(target_source)
elif payload.external_action is not None: return
if payload.external_action is not None:
raise CalendarError( raise CalendarError(
"external_action is only valid when moving events to or from a synchronized calendar" "external_action is only valid when moving events to or from a synchronized calendar"
) )
def _active_events_with_previous_state(
session: Session,
calendar: CalendarCollection,
) -> tuple[list[CalendarEvent], dict[str, dict[str, Any]]]:
active_events = [event for event in calendar.events if event.deleted_at is None]
previous_event_states = (
{
event.id: calendar_event_change_payload(event, prefix="previous_")
for event in active_events
}
if hasattr(session, "query")
else {}
)
return active_events, previous_event_states
def _move_calendar_events(
session: Session,
*,
tenant_id: str,
payload: CalendarCollectionDeleteRequest,
target_calendar: CalendarCollection,
source: CalendarSyncSource | None,
target_source: CalendarSyncSource | None,
active_events: list[CalendarEvent],
previous_event_states: dict[str, dict[str, Any]],
) -> None:
for event in active_events: for event in active_events:
if source is not None or target_source is not None: if source is not None or target_source is not None:
event.source_kind = "local" event.source_kind = "local"
@@ -606,6 +655,73 @@ def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo
if payload.make_target_default: if payload.make_target_default:
clear_default_calendar(session, tenant_id=tenant_id) clear_default_calendar(session, tenant_id=tenant_id)
target_calendar.is_default = True target_calendar.is_default = True
def _move_calendar_before_delete(
session: Session,
*,
tenant_id: str,
calendar: CalendarCollection,
payload: CalendarCollectionDeleteRequest,
deleted_at: datetime,
) -> CalendarCollection:
if not payload.target_calendar_id:
raise CalendarError("Target calendar is required when moving events")
if payload.target_calendar_id == calendar.id:
raise CalendarError("Target calendar must be different from the deleted calendar")
target_calendar = get_calendar(
session,
tenant_id=tenant_id,
calendar_id=payload.target_calendar_id,
)
calendar, target_calendar, source, target_source = _lock_calendar_move_context(
session,
tenant_id=tenant_id,
calendar=calendar,
target_calendar=target_calendar,
)
_validate_calendar_move_source_pair(
payload,
source=source,
target_source=target_source,
)
active_events, previous_event_states = _active_events_with_previous_state(
session,
calendar,
)
_prepare_calendar_move_external_action(
session,
tenant_id=tenant_id,
payload=payload,
source=source,
target_source=target_source,
deleted_at=deleted_at,
)
_move_calendar_events(
session,
tenant_id=tenant_id,
payload=payload,
target_calendar=target_calendar,
source=source,
target_source=target_source,
active_events=active_events,
previous_event_states=previous_event_states,
)
return calendar
def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, payload: CalendarCollectionDeleteRequest | None = None) -> None:
payload = payload or CalendarCollectionDeleteRequest()
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id)
deleted_at = utcnow()
if payload.event_action == "move":
calendar = _move_calendar_before_delete(
session,
tenant_id=tenant_id,
calendar=calendar,
payload=payload,
deleted_at=deleted_at,
)
elif payload.event_action != "delete": elif payload.event_action != "delete":
raise CalendarError(f"Unsupported calendar delete event action: {payload.event_action}") raise CalendarError(f"Unsupported calendar delete event action: {payload.event_action}")
elif payload.external_action is not None: elif payload.external_action is not None:
@@ -696,10 +812,13 @@ def discover_caldav_calendars(session: Session, *, tenant_id: str, payload: Cale
auth_type = payload.auth_type or (source.auth_type if source else "none") auth_type = payload.auth_type or (source.auth_type if source else "none")
username = payload.username if payload.username is not None else (source.username if source else None) username = payload.username if payload.username is not None else (source.username if source else None)
credential_ref = payload.credential_ref if payload.credential_ref is not None else (source.credential_ref if source else None) credential_ref = payload.credential_ref if payload.credential_ref is not None else (source.credential_ref if source else None)
if payload.credential_ref is not None and (source is None or payload.credential_ref != source.credential_ref):
raise CalendarError(
"Caller-supplied credential references are not accepted; provide a password/token "
"or select an existing sync source"
)
secret = caldav_secret_from_payload(auth_type=auth_type, password=payload.password, bearer_token=payload.bearer_token) secret = caldav_secret_from_payload(auth_type=auth_type, password=payload.password, bearer_token=payload.bearer_token)
if secret is None and credential_ref: if secret is None and source is not None and credential_ref == source.credential_ref:
secret = resolve_caldav_credential_ref(session, tenant_id=tenant_id, credential_ref=credential_ref)
if secret is None and source is not None:
secret = resolve_caldav_secret(session, source=source) secret = resolve_caldav_secret(session, source=source)
if auth_type == "basic": if auth_type == "basic":
@@ -729,6 +848,10 @@ def discover_caldav_calendars(session: Session, *, tenant_id: str, payload: Cale
def create_sync_source(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarSyncSourceCreateRequest) -> CalendarSyncSource: def create_sync_source(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarSyncSourceCreateRequest) -> CalendarSyncSource:
if payload.credential_ref is not None:
raise CalendarError(
"Caller-supplied credential references are not accepted; provide a password or bearer token"
)
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id) calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
calendar = ( calendar = (
session.query(CalendarCollection) session.query(CalendarCollection)
@@ -799,12 +922,16 @@ def create_caldav_source(session: Session, *, tenant_id: str, user_id: str | Non
return create_sync_source(session, tenant_id=tenant_id, user_id=user_id, payload=payload) return create_sync_source(session, tenant_id=tenant_id, user_id=user_id, payload=payload)
def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payload: CalendarSyncSourceUpdateRequest) -> CalendarSyncSource: def _lock_sync_source_for_update(
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id) session: Session,
source = ( *,
tenant_id: str,
source_id: str,
) -> CalendarSyncSource:
return (
session.query(CalendarSyncSource) session.query(CalendarSyncSource)
.filter( .filter(
CalendarSyncSource.id == source.id, CalendarSyncSource.id == source_id,
CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.tenant_id == tenant_id,
CalendarSyncSource.deleted_at.is_(None), CalendarSyncSource.deleted_at.is_(None),
) )
@@ -812,28 +939,68 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
.with_for_update() .with_for_update()
.one() .one()
) )
def _sync_source_reconfiguration(
source: CalendarSyncSource,
payload: CalendarSyncSourceUpdateRequest,
) -> tuple[str, bool, bool]:
normalized_collection_url = ( normalized_collection_url = (
normalize_sync_source_url(source.source_kind, payload.collection_url) normalize_sync_source_url(source.source_kind, payload.collection_url)
if payload.collection_url is not None if payload.collection_url is not None
else source.collection_url else source.collection_url
) )
calendar_changed = bool(
payload.calendar_id is not None and payload.calendar_id != source.calendar_id
)
endpoint_changed = normalized_collection_url != source.collection_url
endpoint_or_calendar_changed = bool(
source.source_kind == "caldav" and (calendar_changed or endpoint_changed)
)
materially_reconfigured = bool( materially_reconfigured = bool(
source.source_kind == "caldav" source.source_kind == "caldav"
and ( and (
(payload.calendar_id is not None and payload.calendar_id != source.calendar_id) calendar_changed
or normalized_collection_url != source.collection_url or endpoint_changed
or payload.sync_enabled is False or payload.sync_enabled is False
or payload.sync_direction == "inbound" or payload.sync_direction == "inbound"
) )
) )
endpoint_or_calendar_changed = bool( return (
source.source_kind == "caldav" normalized_collection_url,
and ( materially_reconfigured,
(payload.calendar_id is not None and payload.calendar_id != source.calendar_id) endpoint_or_calendar_changed,
or normalized_collection_url != source.collection_url
) )
def _caldav_source_has_linked_events(
session: Session,
*,
tenant_id: str,
source: CalendarSyncSource,
) -> bool:
return (
session.query(CalendarEvent.id)
.filter(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.calendar_id == source.calendar_id,
CalendarEvent.source_kind == "caldav",
CalendarEvent.source_href.is_not(None),
CalendarEvent.deleted_at.is_(None),
) )
if materially_reconfigured: .first()
is not None
)
def _guard_sync_source_reconfiguration(
session: Session,
*,
tenant_id: str,
source: CalendarSyncSource,
payload: CalendarSyncSourceUpdateRequest,
endpoint_or_calendar_changed: bool,
) -> None:
from govoplan_calendar.backend.outbox import ( from govoplan_calendar.backend.outbox import (
calendar_outbox_has_live_lease, calendar_outbox_has_live_lease,
calendar_outbox_has_unresolved_desired_state, calendar_outbox_has_unresolved_desired_state,
@@ -844,7 +1011,6 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
raise CalendarError( raise CalendarError(
"CalDAV source cannot be materially changed while an outbound operation has an active lease" "CalDAV source cannot be materially changed while an outbound operation has an active lease"
) )
unresolved_desired_state = calendar_outbox_has_unresolved_desired_state( unresolved_desired_state = calendar_outbox_has_unresolved_desired_state(
session, session,
source_id=source.id, source_id=source.id,
@@ -866,33 +1032,36 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
"CalDAV source endpoint or calendar cannot change while unresolved local desired " "CalDAV source endpoint or calendar cannot change while unresolved local desired "
"changes exist; deliver or explicitly discard them first" "changes exist; deliver or explicitly discard them first"
) )
if endpoint_or_calendar_changed and _caldav_source_has_linked_events(
if endpoint_or_calendar_changed: session,
sourced_event_exists = ( tenant_id=tenant_id,
session.query(CalendarEvent.id) source=source,
.filter( ):
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.calendar_id == source.calendar_id,
CalendarEvent.source_kind == "caldav",
CalendarEvent.source_href.is_not(None),
CalendarEvent.deleted_at.is_(None),
)
.first()
is not None
)
if sourced_event_exists:
raise CalendarError( raise CalendarError(
"CalDAV source endpoint or calendar cannot change while events still reference it; " "CalDAV source endpoint or calendar cannot change while events still reference it; "
"detach or resync those events first" "detach or resync those events first"
) )
cancel_calendar_outbox_for_source( cancel_calendar_outbox_for_source(
session, session,
source_id=source.id, source_id=source.id,
reason="CalDAV source endpoint/calendar changed, was disabled, or was made inbound-only", reason="CalDAV source endpoint/calendar changed, was disabled, or was made inbound-only",
) )
if payload.calendar_id is not None:
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
def _calendar_for_sync_source_update(
session: Session,
*,
tenant_id: str,
source: CalendarSyncSource,
calendar_id: str | None,
) -> CalendarCollection:
if calendar_id is None:
return get_calendar(
session,
tenant_id=tenant_id,
calendar_id=source.calendar_id,
)
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id)
calendar = ( calendar = (
session.query(CalendarCollection) session.query(CalendarCollection)
.filter( .filter(
@@ -915,12 +1084,17 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
.first() .first()
) )
if conflicting_source is not None: if conflicting_source is not None:
raise CalendarError( raise CalendarError("A calendar can have only one active synchronization source")
"A calendar can have only one active synchronization source"
)
source.calendar_id = calendar.id source.calendar_id = calendar.id
else: return calendar
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id)
def _apply_sync_source_values(
source: CalendarSyncSource,
*,
payload: CalendarSyncSourceUpdateRequest,
normalized_collection_url: str,
) -> None:
for field_name in ( for field_name in (
"display_name", "display_name",
"auth_type", "auth_type",
@@ -942,15 +1116,85 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
source.metadata_ = payload.metadata source.metadata_ = payload.metadata
if source.source_kind in READ_ONLY_SYNC_SOURCE_KINDS: if source.source_kind in READ_ONLY_SYNC_SOURCE_KINDS:
source.sync_direction = "inbound" source.sync_direction = "inbound"
def _validate_sync_source_auth(source: CalendarSyncSource) -> None:
if source.source_kind == "graph" and source.auth_type != "bearer": if source.source_kind == "graph" and source.auth_type != "bearer":
raise CalendarError("Microsoft Graph calendar sync requires bearer token authentication") raise CalendarError(
"Microsoft Graph calendar sync requires bearer token authentication"
)
if source.source_kind == "ews" and source.auth_type not in {"basic", "bearer"}: if source.source_kind == "ews" and source.auth_type not in {"basic", "bearer"}:
raise CalendarError("Exchange Web Services sync requires basic or bearer authentication") raise CalendarError(
credential_value = caldav_secret_from_payload(auth_type=source.auth_type, password=payload.password, bearer_token=payload.bearer_token) "Exchange Web Services sync requires basic or bearer authentication"
)
def _update_sync_source_credential_and_schedule(
session: Session,
*,
tenant_id: str,
source: CalendarSyncSource,
payload: CalendarSyncSourceUpdateRequest,
) -> None:
credential_value = caldav_secret_from_payload(
auth_type=source.auth_type,
password=payload.password,
bearer_token=payload.bearer_token,
)
if credential_value is not None: if credential_value is not None:
source.credential_ref = store_caldav_credential(session, tenant_id=tenant_id, user_id=None, source=source, secret=credential_value) source.credential_ref = store_caldav_credential(
session,
tenant_id=tenant_id,
user_id=None,
source=source,
secret=credential_value,
)
if payload.sync_enabled is not None or payload.sync_interval_seconds is not None: if payload.sync_enabled is not None or payload.sync_interval_seconds is not None:
source.next_sync_at = utcnow() if source.sync_enabled else None source.next_sync_at = utcnow() if source.sync_enabled else None
def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payload: CalendarSyncSourceUpdateRequest) -> CalendarSyncSource:
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id)
source = _lock_sync_source_for_update(
session,
tenant_id=tenant_id,
source_id=source.id,
)
if payload.credential_ref is not None and payload.credential_ref != source.credential_ref:
raise CalendarError(
"Caller-supplied credential references are not accepted; provide a replacement password or bearer token"
)
(
normalized_collection_url,
materially_reconfigured,
endpoint_or_calendar_changed,
) = _sync_source_reconfiguration(source, payload)
if materially_reconfigured:
_guard_sync_source_reconfiguration(
session,
tenant_id=tenant_id,
source=source,
payload=payload,
endpoint_or_calendar_changed=endpoint_or_calendar_changed,
)
calendar = _calendar_for_sync_source_update(
session,
tenant_id=tenant_id,
source=source,
calendar_id=payload.calendar_id,
)
_apply_sync_source_values(
source,
payload=payload,
normalized_collection_url=normalized_collection_url,
)
_validate_sync_source_auth(source)
_update_sync_source_credential_and_schedule(
session,
tenant_id=tenant_id,
source=source,
payload=payload,
)
mark_calendar_sync_source(calendar, source) mark_calendar_sync_source(calendar, source)
session.flush() session.flush()
return source return source
@@ -1064,7 +1308,12 @@ def retire_sync_source(session: Session, *, tenant_id: str, source: CalendarSync
reason="CalDAV source was retired before queued external changes were delivered", reason="CalDAV source was retired before queued external changes were delivered",
) )
source.deleted_at = deleted_at source.deleted_at = deleted_at
delete_caldav_credential(session, tenant_id=tenant_id, credential_ref=source.credential_ref) delete_caldav_credential(
session,
tenant_id=tenant_id,
source_id=source.id,
credential_ref=source.credential_ref,
)
def retire_caldav_source(session: Session, *, tenant_id: str, source: CalendarSyncSource, deleted_at: datetime) -> None: def retire_caldav_source(session: Session, *, tenant_id: str, source: CalendarSyncSource, deleted_at: datetime) -> None:
@@ -1095,12 +1344,12 @@ def store_caldav_credential(
source: CalendarSyncSource, source: CalendarSyncSource,
secret: str, secret: str,
) -> str: ) -> str:
provider = secret_provider() existing = internal_caldav_credential(
name = f"caldav:{source.id}:{source.auth_type}" session,
if provider is not None: tenant_id=tenant_id,
return str(provider.store_secret(scope=f"calendar:{tenant_id}", name=name, value=secret)) source_id=source.id,
credential_ref=source.credential_ref,
existing = internal_caldav_credential(session, tenant_id=tenant_id, credential_ref=source.credential_ref) )
if existing is None: if existing is None:
existing = CalendarSyncCredential( existing = CalendarSyncCredential(
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -1111,24 +1360,46 @@ def store_caldav_credential(
) )
session.add(existing) session.add(existing)
session.flush() session.flush()
provider = secret_provider()
name = f"caldav:{source.id}:{source.auth_type}"
if provider is not None:
provider_ref = str(provider.store_secret(scope=f"calendar:{tenant_id}", name=name, value=secret))
existing.secret_encrypted = None
existing.metadata_ = {"source_id": source.id, "provider_ref": provider_ref}
else:
existing.secret_encrypted = encrypt_secret(secret)
existing.metadata_ = {"source_id": source.id}
existing.credential_kind = credential_kind_for_auth_type(source.auth_type) existing.credential_kind = credential_kind_for_auth_type(source.auth_type)
existing.label = source.display_name or source.collection_url existing.label = source.display_name or source.collection_url
existing.secret_encrypted = encrypt_secret(secret)
existing.deleted_at = None existing.deleted_at = None
existing.metadata_ = {"source_id": source.id}
session.flush() session.flush()
return f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{existing.id}" return f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{existing.id}"
def delete_caldav_credential(session: Session, *, tenant_id: str, credential_ref: str | None) -> None: def delete_caldav_credential(
session: Session,
*,
tenant_id: str,
source_id: str,
credential_ref: str | None,
) -> None:
if not credential_ref: if not credential_ref:
return return
provider = secret_provider() credential = internal_caldav_credential(
if provider is not None and not credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX) and not credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX): session,
provider.delete_secret(credential_ref) tenant_id=tenant_id,
source_id=source_id,
credential_ref=credential_ref,
)
if credential is None:
# Legacy external/env references have no locally provable ownership and
# must never be passed to a provider delete operation.
return return
credential = internal_caldav_credential(session, tenant_id=tenant_id, credential_ref=credential_ref) provider_ref = _credential_provider_ref(credential)
if credential is not None: provider = secret_provider()
if provider is not None and provider_ref:
provider.delete_secret(provider_ref)
credential.deleted_at = utcnow() credential.deleted_at = utcnow()
@@ -1147,27 +1418,59 @@ def resolve_caldav_secret(
return bearer_token return bearer_token
if not source.credential_ref: if not source.credential_ref:
return None return None
if source.credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX): credential = internal_caldav_credential(
return os.environ.get(source.credential_ref.removeprefix(CALDAV_ENV_CREDENTIAL_PREFIX)) session,
tenant_id=source.tenant_id,
source_id=source.id,
credential_ref=source.credential_ref,
)
if credential is None:
raise CalendarError(
"The sync source credential reference is not a server-owned credential for this tenant/source"
)
provider_ref = _credential_provider_ref(credential)
if provider_ref:
provider = secret_provider() provider = secret_provider()
if provider is not None and not source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX): if provider is None:
secret = provider.read_secret(source.credential_ref) return None
if secret: return provider.read_secret(provider_ref)
return secret return decrypt_secret(credential.secret_encrypted) if credential.secret_encrypted else None
credential = internal_caldav_credential(session, tenant_id=source.tenant_id, credential_ref=source.credential_ref)
return decrypt_secret(credential.secret_encrypted) if credential and credential.secret_encrypted else None
def resolve_caldav_credential_ref(session: Session, *, tenant_id: str, credential_ref: str) -> str | None: def resolve_caldav_credential_ref(
if credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX): session: Session,
return os.environ.get(credential_ref.removeprefix(CALDAV_ENV_CREDENTIAL_PREFIX)) *,
tenant_id: str,
source_id: str,
credential_ref: str,
) -> str | None:
credential = internal_caldav_credential(
session,
tenant_id=tenant_id,
source_id=source_id,
credential_ref=credential_ref,
)
if credential is None:
return None
provider_ref = _credential_provider_ref(credential)
if provider_ref:
provider = secret_provider() provider = secret_provider()
if provider is not None and not credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX): return provider.read_secret(provider_ref) if provider is not None else None
secret = provider.read_secret(credential_ref) return decrypt_secret(credential.secret_encrypted) if credential.secret_encrypted else None
if secret:
return secret
credential = internal_caldav_credential(session, tenant_id=tenant_id, credential_ref=credential_ref) def resolve_trusted_deployment_caldav_credential_ref(credential_ref: str) -> str | None:
return decrypt_secret(credential.secret_encrypted) if credential and credential.secret_encrypted else None """Resolve an env reference only for trusted, out-of-band deployment code.
HTTP/API source configuration deliberately never calls this function.
"""
if not credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX):
raise CalendarError("Trusted deployment credential references must use the env: prefix")
env_name = credential_ref.removeprefix(CALDAV_ENV_CREDENTIAL_PREFIX)
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", env_name):
raise CalendarError("Trusted deployment credential reference contains an invalid environment variable name")
return os.environ.get(env_name)
def caldav_client_for_source( def caldav_client_for_source(
@@ -1242,22 +1545,37 @@ def http_request(
url, url,
label="Calendar source request URL", label="Calendar source request URL",
) )
try:
url = validate_outbound_http_url(url, label="Calendar source request URL")
except OutboundHttpError as exc:
raise CalendarError(str(exc)) from exc
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined. request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
url, url,
data=data, data=data,
method=method, method=method,
headers=headers or {}, headers=headers or {},
) )
opener = urllib.request.build_opener(_SameOriginRedirectHandler(credential_origin)) opener = build_outbound_http_opener(_SameOriginRedirectHandler(credential_origin))
try: try:
with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated, origin-confined Calendar URL. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated, origin-confined Calendar URL. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
response_headers = {key.lower(): value for key, value in response.headers.items()} response_headers = {key.lower(): value for key, value in response.headers.items()}
payload = response.read().decode(response_headers.get("content-charset") or "utf-8", errors="replace") payload = bounded_response_bytes(
response,
headers=response_headers,
label="Calendar source response",
).decode(response_headers.get("content-charset") or "utf-8", errors="replace")
return int(response.status), response_headers, payload return int(response.status), response_headers, payload
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
if exc.code == 304: if exc.code == 304:
return 304, {key.lower(): value for key, value in exc.headers.items()}, "" return 304, {key.lower(): value for key, value in exc.headers.items()}, ""
detail = exc.read().decode("utf-8", errors="replace") try:
detail = bounded_response_bytes(
exc,
headers={key.lower(): value for key, value in exc.headers.items()},
label="Calendar source error response",
).decode("utf-8", errors="replace")
except OutboundHttpError as policy_exc:
raise CalendarError(str(policy_exc)) from policy_exc
raise CalendarError(f"HTTP {exc.code} from calendar source: {detail or exc.reason}") from exc raise CalendarError(f"HTTP {exc.code} from calendar source: {detail or exc.reason}") from exc
except urllib.error.URLError as exc: except urllib.error.URLError as exc:
raise CalendarError(f"Calendar source request failed: {exc.reason}") from exc raise CalendarError(f"Calendar source request failed: {exc.reason}") from exc
@@ -1287,11 +1605,17 @@ def source_auth_headers(
return {} return {}
def internal_caldav_credential(session: Session, *, tenant_id: str, credential_ref: str | None) -> CalendarSyncCredential | None: def internal_caldav_credential(
if not credential_ref: session: Session,
*,
tenant_id: str,
credential_ref: str | None,
source_id: str | None = None,
) -> CalendarSyncCredential | None:
if not credential_ref or not credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX):
return None return None
credential_id = credential_ref.removeprefix(CALDAV_INTERNAL_CREDENTIAL_PREFIX) credential_id = credential_ref.removeprefix(CALDAV_INTERNAL_CREDENTIAL_PREFIX)
return ( credential = (
session.query(CalendarSyncCredential) session.query(CalendarSyncCredential)
.filter( .filter(
CalendarSyncCredential.tenant_id == tenant_id, CalendarSyncCredential.tenant_id == tenant_id,
@@ -1300,6 +1624,16 @@ def internal_caldav_credential(session: Session, *, tenant_id: str, credential_r
) )
.first() .first()
) )
if credential is None or source_id is None:
return credential
metadata = credential.metadata_ if isinstance(credential.metadata_, dict) else {}
return credential if metadata.get("source_id") == source_id else None
def _credential_provider_ref(credential: CalendarSyncCredential) -> str | None:
metadata = credential.metadata_ if isinstance(credential.metadata_, dict) else {}
value = metadata.get("provider_ref")
return str(value) if isinstance(value, str) and value.strip() else None
def credential_kind_for_auth_type(auth_type: str) -> str: def credential_kind_for_auth_type(auth_type: str) -> str:
@@ -2390,6 +2724,9 @@ def mark_calendar_sync_source(calendar: CalendarCollection, source: CalendarSync
def caldav_source_response(source: CalendarSyncSource) -> dict[str, Any]: def caldav_source_response(source: CalendarSyncSource) -> dict[str, Any]:
has_server_owned_credential = bool(
source.credential_ref and source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX)
)
return { return {
"id": source.id, "id": source.id,
"tenant_id": source.tenant_id, "tenant_id": source.tenant_id,
@@ -2399,8 +2736,8 @@ def caldav_source_response(source: CalendarSyncSource) -> dict[str, Any]:
"display_name": source.display_name, "display_name": source.display_name,
"auth_type": source.auth_type, "auth_type": source.auth_type,
"username": source.username, "username": source.username,
"credential_ref": source.credential_ref, "credential_ref": None,
"has_credential": bool(source.credential_ref), "has_credential": has_server_owned_credential,
"sync_enabled": bool(source.sync_enabled), "sync_enabled": bool(source.sync_enabled),
"sync_interval_seconds": int(source.sync_interval_seconds or CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS), "sync_interval_seconds": int(source.sync_interval_seconds or CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS),
"sync_direction": source.sync_direction, "sync_direction": source.sync_direction,
@@ -2581,7 +2918,13 @@ def create_event(session: Session, *, tenant_id: str, user_id: str | None, paylo
return event return event
def update_event(session: Session, *, tenant_id: str, user_id: str | None, event_id: str, payload: CalendarEventUpdateRequest) -> CalendarEvent: def _event_update_calendar_ids(
session: Session,
*,
tenant_id: str,
event_id: str,
payload: CalendarEventUpdateRequest,
) -> tuple[str, str]:
event_locator = ( event_locator = (
session.query(CalendarEvent.calendar_id) session.query(CalendarEvent.calendar_id)
.filter( .filter(
@@ -2597,6 +2940,16 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event
target_calendar_id = payload.calendar_id or original_calendar_id target_calendar_id = payload.calendar_id or original_calendar_id
if payload.calendar_id is not None: if payload.calendar_id is not None:
get_calendar(session, tenant_id=tenant_id, calendar_id=target_calendar_id) get_calendar(session, tenant_id=tenant_id, calendar_id=target_calendar_id)
return original_calendar_id, target_calendar_id
def _lock_event_update_sources(
session: Session,
*,
tenant_id: str,
original_calendar_id: str,
target_calendar_id: str,
) -> tuple[CalendarSyncSource | None, CalendarSyncSource | None]:
original_source = active_sync_source_for_calendar( original_source = active_sync_source_for_calendar(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -2616,16 +2969,35 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event
original_source = locked_sources[original_source.id] original_source = locked_sources[original_source.id]
if target_source is not None: if target_source is not None:
target_source = locked_sources[target_source.id] target_source = locked_sources[target_source.id]
event = get_event(session, tenant_id=tenant_id, event_id=event_id) return original_source, target_source
previous = calendar_event_change_payload(event, prefix="previous_")
original_caldav = event.source_kind == "caldav" and bool(event.source_href)
def _assert_event_update_sync_fields(
payload: CalendarEventUpdateRequest,
*,
original_source: CalendarSyncSource | None,
target_source: CalendarSyncSource | None,
) -> None:
supplied_sync_fields = {"source_kind", "source_href", "etag"} & payload.model_fields_set supplied_sync_fields = {"source_kind", "source_href", "etag"} & payload.model_fields_set
if (original_source is not None or target_source is not None) and supplied_sync_fields: if (original_source is not None or target_source is not None) and supplied_sync_fields:
raise CalendarError( raise CalendarError(
"source_kind, source_href, and etag are sync-owned fields on synchronized calendars" "source_kind, source_href, and etag are sync-owned fields on synchronized calendars"
) )
if payload.calendar_id is not None:
def _move_event_for_update(
session: Session,
*,
event: CalendarEvent,
payload: CalendarEventUpdateRequest,
original_source: CalendarSyncSource | None,
target_source: CalendarSyncSource | None,
) -> None:
if payload.calendar_id is None:
assert_sync_mutation_allowed(original_source)
return
assert_sync_mutation_allowed(target_source) assert_sync_mutation_allowed(target_source)
original_caldav = event.source_kind == "caldav" and bool(event.source_href)
if payload.calendar_id != event.calendar_id and original_source and original_caldav: if payload.calendar_id != event.calendar_id and original_source and original_caldav:
assert_sync_mutation_allowed(original_source) assert_sync_mutation_allowed(original_source)
from govoplan_calendar.backend.outbox import enqueue_caldav_delete from govoplan_calendar.backend.outbox import enqueue_caldav_delete
@@ -2635,8 +3007,14 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event
event.source_href = None event.source_href = None
event.etag = None event.etag = None
event.calendar_id = payload.calendar_id event.calendar_id = payload.calendar_id
else:
assert_sync_mutation_allowed(original_source)
def _apply_event_update_values(
event: CalendarEvent,
*,
user_id: str | None,
payload: CalendarEventUpdateRequest,
) -> None:
scalar_fields = ( scalar_fields = (
"sequence", "sequence",
"summary", "summary",
@@ -2665,7 +3043,13 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event
for attr in scalar_fields: for attr in scalar_fields:
value = getattr(payload, attr) value = getattr(payload, attr)
if value is not None: if value is not None:
setattr(event, attr, value.upper() if attr in {"status", "transparency", "classification"} and isinstance(value, str) else value) value = (
value.upper()
if attr in {"status", "transparency", "classification"}
and isinstance(value, str)
else value
)
setattr(event, attr, value)
if payload.start_at is not None: if payload.start_at is not None:
event.start_at = normalize_datetime(payload.start_at) event.start_at = normalize_datetime(payload.start_at)
if "end_at" in payload.model_fields_set: if "end_at" in payload.model_fields_set:
@@ -2675,6 +3059,36 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event
event.updated_by_user_id = user_id event.updated_by_user_id = user_id
if payload.sequence is None: if payload.sequence is None:
event.sequence += 1 event.sequence += 1
def update_event(session: Session, *, tenant_id: str, user_id: str | None, event_id: str, payload: CalendarEventUpdateRequest) -> CalendarEvent:
original_calendar_id, target_calendar_id = _event_update_calendar_ids(
session,
tenant_id=tenant_id,
event_id=event_id,
payload=payload,
)
original_source, target_source = _lock_event_update_sources(
session,
tenant_id=tenant_id,
original_calendar_id=original_calendar_id,
target_calendar_id=target_calendar_id,
)
event = get_event(session, tenant_id=tenant_id, event_id=event_id)
previous = calendar_event_change_payload(event, prefix="previous_")
_assert_event_update_sync_fields(
payload,
original_source=original_source,
target_source=target_source,
)
_move_event_for_update(
session,
event=event,
payload=payload,
original_source=original_source,
target_source=target_source,
)
_apply_event_update_values(event, user_id=user_id, payload=payload)
validate_event_time(event) validate_event_time(event)
event.raw_ics = None event.raw_ics = None
session.flush() session.flush()

View File

@@ -11,20 +11,32 @@ from govoplan_access.backend.db import models as access_models # noqa: F401 - p
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarOutboxOperation, CalendarSyncCredential from govoplan_calendar.backend.db.models import CalendarEvent, CalendarOutboxOperation, CalendarSyncCredential
from govoplan_calendar.backend.outbox import dispatch_calendar_outbox from govoplan_calendar.backend.outbox import dispatch_calendar_outbox
from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest from govoplan_calendar.backend.schemas import (
CalendarCalDavDiscoveryRequest,
CalendarCalDavSourceCreateRequest,
CalendarCalDavSourceUpdateRequest,
CalendarCollectionCreateRequest,
CalendarEventCreateRequest,
CalendarEventUpdateRequest,
)
from govoplan_calendar.backend.service import ( from govoplan_calendar.backend.service import (
CALDAV_INTERNAL_CREDENTIAL_PREFIX, CALDAV_INTERNAL_CREDENTIAL_PREFIX,
CalendarError, CalendarError,
caldav_client_for_source, caldav_client_for_source,
caldav_source_response,
create_calendar, create_calendar,
create_caldav_source, create_caldav_source,
create_event, create_event,
delete_calendar, delete_calendar,
delete_event, delete_event,
discover_caldav_calendars,
list_caldav_sources, list_caldav_sources,
list_freebusy, list_freebusy,
resolve_caldav_credential_ref,
resolve_trusted_deployment_caldav_credential_ref,
sync_caldav_source, sync_caldav_source,
sync_due_caldav_sources, sync_due_caldav_sources,
update_caldav_source,
update_event, update_event,
) )
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
@@ -286,6 +298,201 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(client.username, "ada") self.assertEqual(client.username, "ada")
self.assertEqual(client.password, "secret") self.assertEqual(client.password, "secret")
def test_api_source_and_discovery_reject_caller_selected_credential_references(self) -> None:
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://attacker.example.test/cal",
auth_type="bearer",
credential_ref="env:MASTER_KEY_B64",
),
)
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
discover_caldav_calendars(
session,
tenant_id="tenant-1",
payload=CalendarCalDavDiscoveryRequest(
url="https://attacker.example.test/cal",
auth_type="bearer",
credential_ref="vault:another-tenant",
),
)
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
),
)
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
update_caldav_source(
session,
tenant_id="tenant-1",
source_id=source.id,
payload=CalendarCalDavSourceUpdateRequest(
credential_ref="env:MASTER_KEY_B64",
),
)
def test_provider_credentials_are_wrapped_in_tenant_and_source_owned_rows(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
self.deleted: list[str] = []
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.deleted.append(secret_ref)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
auth_type="basic",
username="ada",
password="secret",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
self.assertTrue(source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX))
self.assertNotEqual(source.credential_ref, provider_ref)
self.assertEqual(caldav_client_for_source(session, source).password, "secret")
self.assertIsNone(caldav_source_response(source)["credential_ref"])
self.assertTrue(caldav_source_response(source)["has_credential"])
self.assertIsNone(
resolve_caldav_credential_ref(
session,
tenant_id="another-tenant",
source_id=source.id,
credential_ref=source.credential_ref,
)
)
self.assertIsNone(
resolve_caldav_credential_ref(
session,
tenant_id="tenant-1",
source_id="another-source",
credential_ref=source.credential_ref,
)
)
other_calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Other remote"),
)
other_source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=other_calendar.id,
collection_url="https://dav.example.test/other-cal",
),
)
other_source.auth_type = "basic"
other_source.username = "mallory"
other_source.credential_ref = source.credential_ref
with self.assertRaisesRegex(CalendarError, "not a server-owned credential"):
caldav_client_for_source(session, other_source)
delete_calendar(session, tenant_id="tenant-1", calendar_id=other_calendar.id)
self.assertEqual(provider.deleted, [])
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
self.assertEqual(provider.deleted, [provider_ref])
def test_legacy_unowned_refs_are_neither_read_nor_deleted(self) -> None:
class Provider:
def __init__(self) -> None:
self.read: list[str] = []
self.deleted: list[str] = []
def read_secret(self, secret_ref: str) -> str | None:
self.read.append(secret_ref)
return "other-tenant-secret"
def delete_secret(self, secret_ref: str) -> None:
self.deleted.append(secret_ref)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
),
)
source.auth_type = "bearer"
source.credential_ref = "vault:another-tenant"
session.commit()
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
with self.assertRaisesRegex(CalendarError, "not a server-owned credential"):
caldav_client_for_source(session, source)
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
self.assertEqual(provider.read, [])
self.assertEqual(provider.deleted, [])
def test_trusted_deployment_env_resolution_is_explicit_and_separate(self) -> None:
with patch.dict("os.environ", {"CALDAV_DEPLOYMENT_TOKEN": "trusted-token"}):
self.assertEqual(
resolve_trusted_deployment_caldav_credential_ref("env:CALDAV_DEPLOYMENT_TOKEN"),
"trusted-token",
)
with self.assertRaisesRegex(CalendarError, "must use the env: prefix"):
resolve_trusted_deployment_caldav_credential_ref("vault:token")
def test_delete_calendar_retires_caldav_source_and_credential(self) -> None: def test_delete_calendar_retires_caldav_source_and_credential(self) -> None:
session = self.session() session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))

View File

@@ -5,6 +5,7 @@ import threading
import unittest import unittest
from collections.abc import Iterator from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest.mock import patch
from govoplan_calendar.backend.caldav import ( from govoplan_calendar.backend.caldav import (
CalDAVClient, CalDAVClient,
@@ -29,6 +30,22 @@ def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
class CalDAVUrlSecurityTests(unittest.TestCase): class CalDAVUrlSecurityTests(unittest.TestCase):
def test_transport_revalidates_dns_at_connection_time(self) -> None:
public = [(2, 1, 6, "", ("93.184.216.34", 443))]
private = [(2, 1, 6, "", ("127.0.0.1", 443))]
with patch.dict(
"os.environ",
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
side_effect=(public, private),
), patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory, self.assertRaisesRegex(
CalDAVError,
"non-public network",
):
urllib_transport("GET", "https://dav.example.test/event.ics", {}, None, 2)
socket_factory.assert_not_called()
def test_discovery_href_must_remain_on_configured_origin(self) -> None: def test_discovery_href_must_remain_on_configured_origin(self) -> None:
base_url = "https://dav.example.test/calendars/ada/" base_url = "https://dav.example.test/calendars/ada/"

View File

@@ -17,7 +17,7 @@
"test:calendar-picker": "rm -rf .calendar-picker-test-build && mkdir -p .calendar-picker-test-build && printf '{\"type\":\"commonjs\"}\\n' > .calendar-picker-test-build/package.json && tsc -p tsconfig.calendar-picker-tests.json && node .calendar-picker-test-build/tests/calendar-picker.test.js && node tests/calendar-picker-structure.test.mjs" "test:calendar-picker": "rm -rf .calendar-picker-test-build && mkdir -p .calendar-picker-test-build && printf '{\"type\":\"commonjs\"}\\n' > .calendar-picker-test-build/package.json && tsc -p tsconfig.calendar-picker-tests.json && node .calendar-picker-test-build/tests/calendar-picker.test.js && node tests/calendar-picker-structure.test.mjs"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -78,7 +78,6 @@ export type CalendarSyncSource = {
display_name?: string | null; display_name?: string | null;
auth_type: CalendarCalDavAuthType; auth_type: CalendarCalDavAuthType;
username?: string | null; username?: string | null;
credential_ref?: string | null;
has_credential: boolean; has_credential: boolean;
sync_enabled: boolean; sync_enabled: boolean;
sync_interval_seconds: number; sync_interval_seconds: number;
@@ -114,7 +113,6 @@ export type CalendarCalDavDiscoveryPayload = {
source_id?: string | null; source_id?: string | null;
auth_type?: CalendarCalDavAuthType | null; auth_type?: CalendarCalDavAuthType | null;
username?: string | null; username?: string | null;
credential_ref?: string | null;
password?: string | null; password?: string | null;
bearer_token?: string | null; bearer_token?: string | null;
}; };
@@ -128,7 +126,6 @@ export type CalendarSyncSourceCreatePayload = {
display_name?: string | null; display_name?: string | null;
auth_type?: CalendarCalDavAuthType; auth_type?: CalendarCalDavAuthType;
username?: string | null; username?: string | null;
credential_ref?: string | null;
password?: string | null; password?: string | null;
bearer_token?: string | null; bearer_token?: string | null;
sync_enabled?: boolean; sync_enabled?: boolean;

View File

@@ -11,13 +11,16 @@ import {
"react"; "react";
import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { import {
AdminIconButton,
Button, Button,
ColorPickerField, ColorPickerField,
DateField, DateField,
Dialog, Dialog,
DismissibleAlert, DismissibleAlert,
LoadingFrame, LoadingFrame,
PasswordField,
SegmentedControl, SegmentedControl,
TableActionGroup,
TimeField, TimeField,
ToggleSwitch, ToggleSwitch,
hasScope, hasScope,
@@ -84,7 +87,6 @@ type CalendarCalDavFormPayload = {
display_name: string; display_name: string;
auth_type: CalendarCalDavAuthType; auth_type: CalendarCalDavAuthType;
username: string; username: string;
credential_ref: string;
password: string; password: string;
bearer_token: string; bearer_token: string;
sync_enabled: boolean; sync_enabled: boolean;
@@ -684,31 +686,24 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
<button type="button" className="calendar-list-name" onClick={() => setEventCalendarId(calendar.id)}> <button type="button" className="calendar-list-name" onClick={() => setEventCalendarId(calendar.id)}>
{calendar.name} {calendar.name}
</button> </button>
<div className="calendar-list-actions"> <TableActionGroup
{source && canSyncCalendars && className="calendar-list-actions"
<Button actions={[
type="button" source && canSyncCalendars && {
className={syncing ? "calendar-row-icon-button is-syncing" : "calendar-row-icon-button"} id: "sync",
title={syncing ? i18nMessage("i18n:govoplan-calendar.syncing_value.ca80b487", { value0: calendar.name }) : i18nMessage("i18n:govoplan-calendar.sync_value.72e4ba66", { value0: calendar.name })} label: syncing ? i18nMessage("i18n:govoplan-calendar.syncing_value.ca80b487", { value0: calendar.name }) : i18nMessage("i18n:govoplan-calendar.sync_value.72e4ba66", { value0: calendar.name }),
aria-label={syncing ? i18nMessage("i18n:govoplan-calendar.syncing_value.ca80b487", { value0: calendar.name }) : i18nMessage("i18n:govoplan-calendar.sync_value.72e4ba66", { value0: calendar.name })} icon: <RefreshCw size={15} className={syncing ? "calendar-sync-spin" : undefined} />,
onClick={() => void handleSyncSource(source)} onClick: () => void handleSyncSource(source),
disabled={saving || syncing}> disabled: saving || syncing
},
<RefreshCw size={15} className={syncing ? "calendar-sync-spin" : undefined} /> (canManageCalendars || canDeleteCalendars) && {
</Button> id: "edit",
label: i18nMessage("i18n:govoplan-calendar.edit_value.fad75899", { value0: calendar.name }),
icon: <Pencil size={15} />,
onClick: () => openCalendarEditor(calendar)
} }
{(canManageCalendars || canDeleteCalendars) && ]}
<Button />
type="button"
className="calendar-row-icon-button"
title={i18nMessage("i18n:govoplan-calendar.edit_value.fad75899", { value0: calendar.name })}
aria-label={i18nMessage("i18n:govoplan-calendar.edit_value.fad75899", { value0: calendar.name })}
onClick={() => openCalendarEditor(calendar)}>
<Pencil size={15} />
</Button>
}
</div>
</div>); </div>);
})} })}
@@ -764,13 +759,19 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
<div className="calendar-toolbar-center"> <div className="calendar-toolbar-center">
<div className="calendar-icon-group" aria-label="i18n:govoplan-calendar.calendar_navigation.7ba43cd2"> <div className="calendar-icon-group" aria-label="i18n:govoplan-calendar.calendar_navigation.7ba43cd2">
<Button type="button" className="calendar-icon-button" title="i18n:govoplan-calendar.previous.50f94286" aria-label="i18n:govoplan-calendar.previous.50f94286" onClick={() => moveFocus(-1)} disabled={mode === "continuous"}> <AdminIconButton
<ChevronLeft size={18} /> label="i18n:govoplan-calendar.previous.50f94286"
</Button> icon={<ChevronLeft size={18} />}
onClick={() => moveFocus(-1)}
disabled={mode === "continuous"}
/>
<Button type="button" onClick={handleToday}>i18n:govoplan-calendar.today.24345a14</Button> <Button type="button" onClick={handleToday}>i18n:govoplan-calendar.today.24345a14</Button>
<Button type="button" className="calendar-icon-button" title="i18n:govoplan-calendar.next.bc981983" aria-label="i18n:govoplan-calendar.next.bc981983" onClick={() => moveFocus(1)} disabled={mode === "continuous"}> <AdminIconButton
<ChevronRight size={18} /> label="i18n:govoplan-calendar.next.bc981983"
</Button> icon={<ChevronRight size={18} />}
onClick={() => moveFocus(1)}
disabled={mode === "continuous"}
/>
<div className="calendar-range-label"> <div className="calendar-range-label">
<CalendarDays size={18} aria-hidden="true" /> <CalendarDays size={18} aria-hidden="true" />
<strong>{heading}</strong> <strong>{heading}</strong>
@@ -779,9 +780,11 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
</div> </div>
<div className="calendar-toolbar-right"> <div className="calendar-toolbar-right">
<Button type="button" className="calendar-icon-button" title="i18n:govoplan-calendar.refresh.56e3badc" aria-label="i18n:govoplan-calendar.refresh.56e3badc" onClick={() => void loadEvents()}> <AdminIconButton
<RefreshCw size={18} /> label="i18n:govoplan-calendar.refresh.56e3badc"
</Button> icon={<RefreshCw size={18} />}
onClick={() => void loadEvents()}
/>
{canWrite && {canWrite &&
<Button type="button" variant="primary" onClick={() => setEventDialog({ kind: "create" })} disabled={!targetCalendarId}> <Button type="button" variant="primary" onClick={() => setEventDialog({ kind: "create" })} disabled={!targetCalendarId}>
<Plus size={17} /> i18n:govoplan-calendar.new.6403f2b7 <Plus size={17} /> i18n:govoplan-calendar.new.6403f2b7
@@ -1343,7 +1346,6 @@ function CalendarCollectionDialog({
const [displayName, setDisplayName] = useState(source?.display_name ?? ""); const [displayName, setDisplayName] = useState(source?.display_name ?? "");
const [authType, setAuthType] = useState<CalendarCalDavAuthType>(source?.auth_type ?? "basic"); const [authType, setAuthType] = useState<CalendarCalDavAuthType>(source?.auth_type ?? "basic");
const [username, setUsername] = useState(source?.username ?? ""); const [username, setUsername] = useState(source?.username ?? "");
const [credentialRef, setCredentialRef] = useState(source?.credential_ref ?? "");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [bearerToken, setBearerToken] = useState(""); const [bearerToken, setBearerToken] = useState("");
const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true); const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true);
@@ -1361,8 +1363,8 @@ function CalendarCollectionDialog({
const effectiveCollectionUrl = (collectionUrl || davUrl).trim(); const effectiveCollectionUrl = (collectionUrl || davUrl).trim();
const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType; const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType;
const needsSourceSecret = sourceMode !== "local" && ( const needsSourceSecret = sourceMode !== "local" && (
effectiveAuthType === "basic" && !source?.has_credential && !credentialRef.trim() && !password.trim() || effectiveAuthType === "basic" && !source?.has_credential && !password.trim() ||
effectiveAuthType === "bearer" && !source?.has_credential && !credentialRef.trim() && !bearerToken.trim()); effectiveAuthType === "bearer" && !source?.has_credential && !bearerToken.trim());
const sourceDetailsInvalid = sourceMode !== "local" && ( const sourceDetailsInvalid = sourceMode !== "local" && (
!isExistingSyncSource && !canEditSource || !isExistingSyncSource && !canEditSource ||
@@ -1385,7 +1387,6 @@ function CalendarCollectionDialog({
displayName, displayName,
authType, authType,
username, username,
credentialRef,
password, password,
bearerToken, bearerToken,
syncEnabled, syncEnabled,
@@ -1413,7 +1414,6 @@ function CalendarCollectionDialog({
display_name: displayName, display_name: displayName,
auth_type: effectiveAuthType, auth_type: effectiveAuthType,
username, username,
credential_ref: credentialRef,
password, password,
bearer_token: bearerToken, bearer_token: bearerToken,
sync_enabled: syncEnabled, sync_enabled: syncEnabled,
@@ -1494,8 +1494,7 @@ function CalendarCollectionDialog({
url, url,
source_id: source?.id ?? null, source_id: source?.id ?? null,
auth_type: authType, auth_type: authType,
username: authType === "basic" ? username.trim() || null : null, username: authType === "basic" ? username.trim() || null : null
credential_ref: credentialRef.trim() || null
}; };
if (authType === "basic" && password.trim()) payload.password = password; if (authType === "basic" && password.trim()) payload.password = password;
if (authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken; if (authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken;
@@ -1613,15 +1612,15 @@ function CalendarCollectionDialog({
<input value={username} onChange={(item) => setUsername(item.target.value)} required={sourceMode !== "local" && effectiveAuthType === "basic"} maxLength={255} disabled={saving || !canEditSource} /> <input value={username} onChange={(item) => setUsername(item.target.value)} required={sourceMode !== "local" && effectiveAuthType === "basic"} maxLength={255} disabled={saving || !canEditSource} />
</label> </label>
<label> <label>
<span>{source?.has_credential || credentialRef.trim() ? "i18n:govoplan-calendar.replace_password.3f912c9c" : "i18n:govoplan-calendar.password.8be3c943"}</span> <span>{source?.has_credential ? "i18n:govoplan-calendar.replace_password.3f912c9c" : "i18n:govoplan-calendar.password.8be3c943"}</span>
<input type="password" value={password} onChange={(item) => setPassword(item.target.value)} disabled={saving || !canEditSource} autoComplete="new-password" /> <PasswordField value={password} onValueChange={setPassword} disabled={saving || !canEditSource} autoComplete="new-password" />
</label> </label>
</> </>
} }
{effectiveAuthType === "bearer" && {effectiveAuthType === "bearer" &&
<label> <label>
<span>{source?.has_credential || credentialRef.trim() ? "i18n:govoplan-calendar.replace_token.bbeee6a9" : "i18n:govoplan-calendar.bearer_token.ffa64bcf"}</span> <span>{source?.has_credential ? "i18n:govoplan-calendar.replace_token.bbeee6a9" : "i18n:govoplan-calendar.bearer_token.ffa64bcf"}</span>
<input type="password" value={bearerToken} onChange={(item) => setBearerToken(item.target.value)} disabled={saving || !canEditSource} autoComplete="new-password" /> <PasswordField value={bearerToken} onValueChange={setBearerToken} disabled={saving || !canEditSource} autoComplete="new-password" />
</label> </label>
} }
<div className={sourceMode === "caldav" ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}> <div className={sourceMode === "caldav" ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}>
@@ -1653,10 +1652,6 @@ function CalendarCollectionDialog({
<span>i18n:govoplan-calendar.display_name.c7874aaa</span> <span>i18n:govoplan-calendar.display_name.c7874aaa</span>
<input value={displayName} onChange={(item) => setDisplayName(item.target.value)} maxLength={255} disabled={saving || !canEditMutableSourceSettings} /> <input value={displayName} onChange={(item) => setDisplayName(item.target.value)} maxLength={255} disabled={saving || !canEditMutableSourceSettings} />
</label> </label>
<label>
<span>i18n:govoplan-calendar.credential_reference.a7e92de5</span>
<input value={credentialRef} onChange={(item) => setCredentialRef(item.target.value)} placeholder="env:CALDAV_PASSWORD" maxLength={255} disabled={saving || !canEditSource} />
</label>
</div> </div>
<div className="calendar-sync-settings"> <div className="calendar-sync-settings">
<ToggleSwitch label="i18n:govoplan-calendar.automatic_sync.084644b2" checked={syncEnabled} disabled={saving || !canEditMutableSourceSettings} onChange={setSyncEnabled} /> <ToggleSwitch label="i18n:govoplan-calendar.automatic_sync.084644b2" checked={syncEnabled} disabled={saving || !canEditMutableSourceSettings} onChange={setSyncEnabled} />
@@ -1686,7 +1681,7 @@ function CalendarCollectionDialog({
<div><dt>i18n:govoplan-calendar.last_attempt.82aee111</dt><dd>{source.last_attempt_at ? dateTimeLabel(new Date(source.last_attempt_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div> <div><dt>i18n:govoplan-calendar.last_attempt.82aee111</dt><dd>{source.last_attempt_at ? dateTimeLabel(new Date(source.last_attempt_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
<div><dt>i18n:govoplan-calendar.last_sync.ef0ef267</dt><dd>{source.last_synced_at ? dateTimeLabel(new Date(source.last_synced_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div> <div><dt>i18n:govoplan-calendar.last_sync.ef0ef267</dt><dd>{source.last_synced_at ? dateTimeLabel(new Date(source.last_synced_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
<div><dt>i18n:govoplan-calendar.next_sync.88c7af72</dt><dd>{source.next_sync_at && source.sync_enabled ? dateTimeLabel(new Date(source.next_sync_at)) : "i18n:govoplan-calendar.not_scheduled.9c367369"}</dd></div> <div><dt>i18n:govoplan-calendar.next_sync.88c7af72</dt><dd>{source.next_sync_at && source.sync_enabled ? dateTimeLabel(new Date(source.next_sync_at)) : "i18n:govoplan-calendar.not_scheduled.9c367369"}</dd></div>
<div><dt>i18n:govoplan-calendar.credential.8bede3ea</dt><dd>{source.has_credential || source.credential_ref ? "i18n:govoplan-calendar.configured.668c5fff" : "i18n:govoplan-calendar.not_configured.811931bb"}</dd></div> <div><dt>i18n:govoplan-calendar.credential.8bede3ea</dt><dd>{source.has_credential ? "i18n:govoplan-calendar.configured.668c5fff" : "i18n:govoplan-calendar.not_configured.811931bb"}</dd></div>
</dl> </dl>
{source.last_error && <p className="calendar-form-error">{source.last_error}</p>} {source.last_error && <p className="calendar-form-error">{source.last_error}</p>}
<div className="calendar-sync-actions"> <div className="calendar-sync-actions">
@@ -1704,7 +1699,7 @@ function CalendarCollectionDialog({
</div> </div>
</div> </div>
} }
{sourceMode !== "local" && effectiveAuthType !== "none" && !source?.has_credential && !credentialRef.trim() && <p className="calendar-form-note">i18n:govoplan-calendar.enter_a_secret_now_or_use_an_environment_referen.6db0e6ce <code>env:CALENDAR_SYNC_SECRET</code>.</p>} {needsSourceSecret && <p className="calendar-form-note">i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54</p>}
</section> </section>
} }
</form> </form>
@@ -1812,10 +1807,7 @@ function CalendarCollectionDeleteDialog({
</select> </select>
</label> </label>
{calendar.is_default && {calendar.is_default &&
<label className="calendar-checkbox-row"> <ToggleSwitch label="i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b" checked={makeTargetDefault} onChange={setMakeTargetDefault} />
<input type="checkbox" checked={makeTargetDefault} onChange={(item) => setMakeTargetDefault(item.target.checked)} />
<span>i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b</span>
</label>
} }
<p className="calendar-form-note">{calendarBulkMoveConsequence(externalAction)}</p> <p className="calendar-form-note">{calendarBulkMoveConsequence(externalAction)}</p>
</> </>
@@ -2420,7 +2412,6 @@ function syncSourceCreatePayload(sourceMode: CalendarSourceMode, payload: Calend
display_name: payload.display_name.trim() || null, display_name: payload.display_name.trim() || null,
auth_type: sourceKind === "graph" ? "bearer" : payload.auth_type, auth_type: sourceKind === "graph" ? "bearer" : payload.auth_type,
username: sourceKind !== "graph" && payload.auth_type === "basic" ? payload.username.trim() || null : null, username: sourceKind !== "graph" && payload.auth_type === "basic" ? payload.username.trim() || null : null,
credential_ref: payload.credential_ref.trim() || null,
sync_enabled: payload.sync_enabled, sync_enabled: payload.sync_enabled,
sync_interval_seconds: Math.max(60, payload.sync_interval_seconds), sync_interval_seconds: Math.max(60, payload.sync_interval_seconds),
sync_direction: sourceKind === "caldav" ? payload.sync_direction : "inbound", sync_direction: sourceKind === "caldav" ? payload.sync_direction : "inbound",
@@ -2436,8 +2427,7 @@ function syncSourceConnectionUpdatePayload(payload: CalendarCalDavFormPayload):
const result: CalendarSyncSourceUpdatePayload = { const result: CalendarSyncSourceUpdatePayload = {
collection_url: payload.collection_url.trim(), collection_url: payload.collection_url.trim(),
auth_type: payload.auth_type, auth_type: payload.auth_type,
username: payload.auth_type === "basic" ? payload.username.trim() || null : null, username: payload.auth_type === "basic" ? payload.username.trim() || null : null
credential_ref: payload.credential_ref.trim() || null
}; };
if (payload.auth_type === "basic" && payload.password.trim()) result.password = payload.password; if (payload.auth_type === "basic" && payload.password.trim()) result.password = payload.password;
if (payload.auth_type === "bearer" && payload.bearer_token.trim()) result.bearer_token = payload.bearer_token; if (payload.auth_type === "bearer" && payload.bearer_token.trim()) result.bearer_token = payload.bearer_token;

View File

@@ -39,7 +39,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED", "i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy", "i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous", "i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
"i18n:govoplan-calendar.credential_reference.a7e92de5": "Credential reference",
"i18n:govoplan-calendar.credential.8bede3ea": "Credential", "i18n:govoplan-calendar.credential.8bede3ea": "Credential",
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL", "i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
"i18n:govoplan-calendar.day.987b9ced": "Day", "i18n:govoplan-calendar.day.987b9ced": "Day",
@@ -63,7 +62,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.end_date.89d10cd6": "End date", "i18n:govoplan-calendar.end_date.89d10cd6": "End date",
"i18n:govoplan-calendar.end_mode.5a06de37": "End mode", "i18n:govoplan-calendar.end_mode.5a06de37": "End mode",
"i18n:govoplan-calendar.end_time.cd7800da": "End time", "i18n:govoplan-calendar.end_time.cd7800da": "End time",
"i18n:govoplan-calendar.enter_a_secret_now_or_use_an_environment_referen.6db0e6ce": "Enter a secret now or use an environment reference such as", "i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54": "Enter a password or token for this source.",
"i18n:govoplan-calendar.etag.11d00f6e": "ETag", "i18n:govoplan-calendar.etag.11d00f6e": "ETag",
"i18n:govoplan-calendar.event": "event", "i18n:govoplan-calendar.event": "event",
"i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.", "i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.",
@@ -228,7 +227,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED", "i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy", "i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous", "i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
"i18n:govoplan-calendar.credential_reference.a7e92de5": "Credential reference",
"i18n:govoplan-calendar.credential.8bede3ea": "Credential", "i18n:govoplan-calendar.credential.8bede3ea": "Credential",
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL", "i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
"i18n:govoplan-calendar.day.987b9ced": "Tag", "i18n:govoplan-calendar.day.987b9ced": "Tag",
@@ -252,7 +250,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.end_date.89d10cd6": "End date", "i18n:govoplan-calendar.end_date.89d10cd6": "End date",
"i18n:govoplan-calendar.end_mode.5a06de37": "End mode", "i18n:govoplan-calendar.end_mode.5a06de37": "End mode",
"i18n:govoplan-calendar.end_time.cd7800da": "End time", "i18n:govoplan-calendar.end_time.cd7800da": "End time",
"i18n:govoplan-calendar.enter_a_secret_now_or_use_an_environment_referen.6db0e6ce": "Enter a secret now or use an environment reference such as", "i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54": "Geben Sie ein Passwort oder Token für diese Quelle ein.",
"i18n:govoplan-calendar.etag.11d00f6e": "ETag", "i18n:govoplan-calendar.etag.11d00f6e": "ETag",
"i18n:govoplan-calendar.event": "event", "i18n:govoplan-calendar.event": "event",
"i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.", "i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.",

View File

@@ -89,21 +89,6 @@
gap: 6px; gap: 6px;
} }
.calendar-icon-button.btn {
width: 36px;
min-width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.calendar-icon-button.btn:disabled {
opacity: .42;
cursor: default;
}
.calendar-mode-switch { .calendar-mode-switch {
flex: 0 1 520px; flex: 0 1 520px;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -250,40 +235,7 @@
} }
.calendar-list-actions { .calendar-list-actions {
display: flex; width: auto;
align-items: center;
justify-content: flex-end;
gap: 2px;
min-width: 28px;
}
.calendar-row-icon-button.btn {
width: 28px;
min-width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border-color: transparent;
background: transparent;
opacity: .72;
}
.calendar-row-icon-button.btn:hover,
.calendar-row-icon-button.btn:focus-visible {
border-color: var(--line-dark);
background: var(--surface);
opacity: 1;
}
.calendar-row-icon-button.btn.is-syncing,
.calendar-row-icon-button.btn.is-syncing:disabled {
border-color: var(--green);
background: var(--surface);
color: var(--green);
opacity: 1;
cursor: progress;
} }
.calendar-sync-spin { .calendar-sync-spin {