Integrate calendar sources with credential envelopes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user