Integrate calendar sources with credential envelopes

This commit is contained in:
2026-07-28 19:33:04 +02:00
parent bf59cd830c
commit 202eb78ae4
6 changed files with 414 additions and 24 deletions

View File

@@ -28,6 +28,8 @@ from govoplan_calendar.backend.schemas import (
CalendarCollectionListResponse,
CalendarCollectionResponse,
CalendarCollectionUpdateRequest,
CalendarCredentialEnvelopeListResponse,
CalendarCredentialEnvelopeResponse,
CalendarEventCreateRequest,
CalendarEventDeltaResponse,
CalendarEventListResponse,
@@ -61,6 +63,7 @@ from govoplan_calendar.backend.service import (
CALENDAR_EVENT_RESOURCE,
CALENDAR_MODULE_ID,
CalendarError,
available_calendar_credentials,
caldav_source_response,
caldav_sync_response,
calendar_response,
@@ -129,6 +132,25 @@ def _parse_payload_datetime(value) -> datetime | None:
return None
@router.get("/credentials", response_model=CalendarCredentialEnvelopeListResponse)
def api_list_calendar_credentials(
source_id: str | None = None,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
return CalendarCredentialEnvelopeListResponse(
credentials=[
CalendarCredentialEnvelopeResponse.model_validate(item)
for item in available_calendar_credentials(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
)
]
)
def _event_interval_overlaps(payload: dict, *, prefix: str, start_at: datetime | None, end_at: datetime | None) -> bool:
event_start = _parse_payload_datetime(payload.get(f"{prefix}start_at"))
event_end = _parse_payload_datetime(payload.get(f"{prefix}end_at")) or event_start

View File

@@ -136,6 +136,7 @@ class CalendarSyncSourceResponse(BaseModel):
auth_type: str
username: str | None = None
credential_ref: str | None = None
credential_envelope_id: str | None = None
has_credential: bool = False
sync_enabled: bool
sync_interval_seconds: int
@@ -165,6 +166,26 @@ class CalendarCalDavSourceListResponse(BaseModel):
sources: list[CalendarCalDavSourceResponse] = Field(default_factory=list)
class CalendarCredentialEnvelopeResponse(BaseModel):
id: str
scope_type: str
scope_id: str | None = None
name: str
description: str | None = None
credential_kind: str
public_data: dict[str, Any] = Field(default_factory=dict)
secret_keys: list[str] = Field(default_factory=list)
secret_configured: bool = False
allowed_modules: list[str] = Field(default_factory=list)
inherit_to_lower_scopes: bool = False
is_active: bool = True
revision: str
class CalendarCredentialEnvelopeListResponse(BaseModel):
credentials: list[CalendarCredentialEnvelopeResponse] = Field(default_factory=list)
class CalendarCalDavDiscoveryRequest(BaseModel):
model_config = ConfigDict(extra="forbid")

View File

@@ -23,6 +23,15 @@ from govoplan_core.security.outbound_http import (
build_outbound_http_opener,
validate_outbound_http_url,
)
from govoplan_core.security.credential_envelopes import (
CredentialAccessContext,
CredentialEnvelopeError,
ResolvedCredentialEnvelope,
credential_envelope_summary,
get_credential_envelope,
list_credential_envelopes,
resolve_credential_envelope,
)
from govoplan_core.audit.logging import audit_event
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
@@ -52,6 +61,7 @@ class CalendarError(ValueError):
CALDAV_INTERNAL_CREDENTIAL_PREFIX = "calendar-sync-credential:"
CORE_CREDENTIAL_ENVELOPE_PREFIX = "credential-envelope:"
CALDAV_ENV_CREDENTIAL_PREFIX = "env:"
CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS = 900
SYNC_SOURCE_KINDS = {"caldav", "ics", "webcal", "graph", "ews"}
@@ -66,6 +76,33 @@ CALENDAR_EVENT_RESOURCE = "calendar_event"
SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500
def calendar_credential_context(
*,
tenant_id: str,
source_id: str | None = None,
) -> CredentialAccessContext:
return CredentialAccessContext(
tenant_id=tenant_id,
target_scope_type="tenant",
target_scope_id=tenant_id,
module_id=CALENDAR_MODULE_ID,
server_ref=f"calendar:{source_id}" if source_id else None,
)
def available_calendar_credentials(
session: Session,
*,
tenant_id: str,
source_id: str | None = None,
) -> list[dict[str, Any]]:
context = calendar_credential_context(tenant_id=tenant_id, source_id=source_id)
return [
credential_envelope_summary(row)
for row in list_credential_envelopes(session, context=context)
]
def calendar_event_change_payload(event: CalendarEvent, *, prefix: str = "") -> dict[str, Any]:
metadata = event.metadata_ or {}
caldav = metadata.get("caldav") if isinstance(metadata, dict) else None
@@ -829,13 +866,24 @@ def discover_caldav_calendars(session: Session, *, tenant_id: str, payload: Cale
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)
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):
resolved_envelope = _resolve_core_calendar_credential(
session,
tenant_id=tenant_id,
source_id=source.id if source else None,
credential_ref=credential_ref,
)
if payload.credential_ref is not None and resolved_envelope is 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"
"Caller-supplied credential references are accepted only for visible reusable credential envelopes"
)
if not username and resolved_envelope is not None:
username = _credential_username(resolved_envelope)
secret = caldav_secret_from_payload(auth_type=auth_type, password=payload.password, bearer_token=payload.bearer_token)
if secret is None and source is not None and credential_ref == source.credential_ref:
if secret is None and resolved_envelope is not None:
secret = _credential_secret(resolved_envelope, auth_type=auth_type)
elif secret is None and source is not None and credential_ref == source.credential_ref:
secret = resolve_caldav_secret(session, source=source)
if auth_type == "basic":
@@ -865,10 +913,14 @@ 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:
if payload.credential_ref is not None:
if payload.credential_ref is not None and not _core_credential_id(payload.credential_ref):
raise CalendarError(
"Caller-supplied credential references are not accepted; provide a password or bearer token"
"Caller-supplied credential references are accepted only for visible reusable credential envelopes"
)
if payload.credential_ref is not None and (
payload.password is not None or payload.bearer_token is not None
):
raise CalendarError("Select a reusable credential or enter a new secret, not both")
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
calendar = (
session.query(CalendarCollection)
@@ -926,6 +978,21 @@ def create_sync_source(session: Session, *, tenant_id: str, user_id: str | None,
)
session.add(source)
session.flush()
if source.credential_ref:
_require_visible_core_calendar_credential(
session,
tenant_id=tenant_id,
source_id=source.id,
credential_ref=source.credential_ref,
)
if not source.username:
resolved = _resolve_core_calendar_credential(
session,
tenant_id=tenant_id,
source_id=source.id,
credential_ref=source.credential_ref,
)
source.username = _credential_username(resolved) if resolved is not None else 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:
source.credential_ref = store_caldav_credential(session, tenant_id=tenant_id, user_id=user_id, source=source, secret=credential_value)
@@ -1153,6 +1220,7 @@ def _update_sync_source_credential_and_schedule(
source: CalendarSyncSource,
payload: CalendarSyncSourceUpdateRequest,
previous_auth_type: str,
previous_credential_ref: str | None,
user_id: str | None,
api_key_id: str | None,
) -> None:
@@ -1162,6 +1230,16 @@ def _update_sync_source_credential_and_schedule(
bearer_token=payload.bearer_token,
)
if credential_value is not None:
if previous_credential_ref and previous_credential_ref != source.credential_ref:
delete_caldav_credential(
session,
tenant_id=tenant_id,
source_id=source.id,
credential_ref=previous_credential_ref,
deletion_reason="credential_replaced",
user_id=user_id,
api_key_id=api_key_id,
)
source.credential_ref = store_caldav_credential(
session,
tenant_id=tenant_id,
@@ -1170,6 +1248,16 @@ def _update_sync_source_credential_and_schedule(
secret=credential_value,
api_key_id=api_key_id,
)
elif "credential_ref" in payload.model_fields_set and source.credential_ref != previous_credential_ref:
delete_caldav_credential(
session,
tenant_id=tenant_id,
source_id=source.id,
credential_ref=previous_credential_ref,
deletion_reason="credential_replaced",
user_id=user_id,
api_key_id=api_key_id,
)
elif payload.auth_type is not None and source.auth_type != previous_auth_type:
delete_caldav_credential(
session,
@@ -1205,10 +1293,18 @@ def update_sync_source(
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"
_require_visible_core_calendar_credential(
session,
tenant_id=tenant_id,
source_id=source.id,
credential_ref=payload.credential_ref,
)
if payload.credential_ref is not None and (
payload.password is not None or payload.bearer_token is not None
):
raise CalendarError("Select a reusable credential or enter a replacement secret, not both")
previous_auth_type = source.auth_type
previous_credential_ref = source.credential_ref
(
normalized_collection_url,
materially_reconfigured,
@@ -1233,6 +1329,14 @@ def update_sync_source(
payload=payload,
normalized_collection_url=normalized_collection_url,
)
if payload.credential_ref is not None and payload.username is None:
reusable = _resolve_core_calendar_credential(
session,
tenant_id=tenant_id,
source_id=source.id,
credential_ref=payload.credential_ref,
)
source.username = _credential_username(reusable) or source.username
_validate_sync_source_auth(source)
_update_sync_source_credential_and_schedule(
session,
@@ -1240,6 +1344,7 @@ def update_sync_source(
source=source,
payload=payload,
previous_auth_type=previous_auth_type,
previous_credential_ref=previous_credential_ref,
user_id=user_id,
api_key_id=api_key_id,
)
@@ -1694,6 +1799,14 @@ def resolve_caldav_secret(
return bearer_token
if not source.credential_ref:
return None
reusable = _resolve_core_calendar_credential(
session,
tenant_id=source.tenant_id,
source_id=source.id,
credential_ref=source.credential_ref,
)
if reusable is not None:
return _credential_secret(reusable, auth_type=source.auth_type)
credential = internal_caldav_credential(
session,
tenant_id=source.tenant_id,
@@ -1713,6 +1826,75 @@ def resolve_caldav_secret(
return decrypt_secret(credential.secret_encrypted) if credential.secret_encrypted else None
def _core_credential_id(credential_ref: str | None) -> str | None:
if not credential_ref or not credential_ref.startswith(CORE_CREDENTIAL_ENVELOPE_PREFIX):
return None
value = credential_ref.removeprefix(CORE_CREDENTIAL_ENVELOPE_PREFIX).strip()
return value or None
def _resolve_core_calendar_credential(
session: Session,
*,
tenant_id: str,
source_id: str | None,
credential_ref: str | None,
) -> ResolvedCredentialEnvelope | None:
credential_id = _core_credential_id(credential_ref)
if credential_id is None:
return None
try:
return resolve_credential_envelope(
session,
credential_id=credential_id,
context=calendar_credential_context(tenant_id=tenant_id, source_id=source_id),
)
except CredentialEnvelopeError as exc:
raise CalendarError("The selected reusable credential is unavailable to this calendar source") from exc
def _require_visible_core_calendar_credential(
session: Session,
*,
tenant_id: str,
source_id: str,
credential_ref: str,
) -> None:
credential_id = _core_credential_id(credential_ref)
if credential_id is None:
raise CalendarError(
"Caller-supplied credential references are accepted only for visible reusable credential envelopes"
)
try:
get_credential_envelope(
session,
credential_id=credential_id,
context=calendar_credential_context(tenant_id=tenant_id, source_id=source_id),
)
except CredentialEnvelopeError as exc:
raise CalendarError("The selected reusable credential is unavailable to this calendar source") from exc
def _credential_username(credential: ResolvedCredentialEnvelope | None) -> str | None:
if credential is None:
return None
value = credential.public_data.get("username")
return str(value).strip() if value is not None and str(value).strip() else None
def _credential_secret(credential: ResolvedCredentialEnvelope, *, auth_type: str) -> str | None:
keys = (
("password", "secret", "token")
if auth_type == "basic"
else ("access_token", "bearer_token", "token", "password", "secret")
)
for key in keys:
value = credential.secret_data.get(key)
if value is not None and str(value):
return str(value)
return None
def resolve_caldav_credential_ref(
session: Session,
*,
@@ -1757,12 +1939,13 @@ def caldav_client_for_source(
bearer_token: str | None = None,
) -> CalDAVClient:
secret = resolve_caldav_secret(session, source=source, password=password, bearer_token=bearer_token)
username = _source_credential_username(session, source)
if source.auth_type == "basic":
if not source.username:
if not username:
raise CalendarError("CalDAV basic auth requires a username")
if not secret:
raise CalendarError("CalDAV basic auth requires a stored or transient password")
return CalDAVClient(collection_url=source.collection_url, username=source.username, password=secret)
return CalDAVClient(collection_url=source.collection_url, username=username, password=secret)
if source.auth_type == "bearer":
if not secret:
raise CalendarError("CalDAV bearer auth requires a stored or transient token")
@@ -1865,14 +2048,15 @@ def source_auth_headers(
bearer_token: str | None = None,
) -> dict[str, str]:
secret = resolve_caldav_secret(session, source=source, password=password, bearer_token=bearer_token)
username = _source_credential_username(session, source)
if source.auth_type == "basic":
if not source.username:
if not username:
raise CalendarError(f"{sync_source_label(source.source_kind)} basic auth requires a username")
if not secret:
raise CalendarError(f"{sync_source_label(source.source_kind)} basic auth requires a stored or transient password")
import base64
token = base64.b64encode(f"{source.username}:{secret}".encode("utf-8")).decode("ascii")
token = base64.b64encode(f"{username}:{secret}".encode("utf-8")).decode("ascii")
return {"Authorization": f"Basic {token}"}
if source.auth_type == "bearer":
if not secret:
@@ -1881,6 +2065,16 @@ def source_auth_headers(
return {}
def _source_credential_username(session: Session, source: CalendarSyncSource) -> str | None:
reusable = _resolve_core_calendar_credential(
session,
tenant_id=source.tenant_id,
source_id=source.id,
credential_ref=source.credential_ref,
)
return _credential_username(reusable) or source.username
def internal_caldav_credential(
session: Session,
*,
@@ -3001,7 +3195,11 @@ def mark_calendar_sync_source(calendar: CalendarCollection, source: CalendarSync
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)
source.credential_ref
and (
source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX)
or source.credential_ref.startswith(CORE_CREDENTIAL_ENVELOPE_PREFIX)
)
)
return {
"id": source.id,
@@ -3013,6 +3211,7 @@ def caldav_source_response(source: CalendarSyncSource) -> dict[str, Any]:
"auth_type": source.auth_type,
"username": source.username,
"credential_ref": None,
"credential_envelope_id": _core_credential_id(source.credential_ref),
"has_credential": has_server_owned_credential,
"sync_enabled": bool(source.sync_enabled),
"sync_interval_seconds": int(source.sync_interval_seconds or CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS),