Compare commits

...

4 Commits

6 changed files with 715 additions and 41 deletions

View File

@@ -48,6 +48,14 @@ environment and external-provider references are rejected. Trusted deployment
code may resolve an `env:NAME` reference only through the separate deployment code may resolve an `env:NAME` reference only through the separate deployment
configuration helper. configuration helper.
Deleting a source or its calendar immediately scrubs Calendar-owned ciphertext
and provider references and emits non-secret audit evidence. If an external
secret provider cannot confirm deletion, the operation fails closed without
retiring the source or cancelling its queued work; retry is idempotent after a
database rollback. Destructive module retirement first deletes and audits all
active and legacy retained provider secrets and stops before table removal on
provider failure.
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
soft-deletes local events when remote resources disappear. Two-way sources also soft-deletes local events when remote resources disappear. Two-way sources also

View File

@@ -69,6 +69,12 @@ bindings or unresolved delivery. Credential rotation does not discard committed
desired state. Public event mutation cannot set sync-owned source hrefs, kinds, desired state. Public event mutation cannot set sync-owned source hrefs, kinds,
or ETags. Deleting a synchronized collection or retiring its source is a local or ETags. Deleting a synchronized collection or retiring its source is a local
unlink: it never deletes the remote collection or its remaining remote events. unlink: it never deletes the remote collection or its remaining remote events.
The unlink immediately scrubs Calendar-owned credential ciphertext and external
provider references and audits the deletion. External provider failure blocks
retirement before queued work is changed; a later retry tolerates a provider
secret already removed by an earlier attempt whose database transaction rolled
back. Provider errors and audit details never contain credential values or
secret references.
The current singular ownership model permits one active sync source per The current singular ownership model permits one active sync source per
calendar; multi-source fan-in will require per-event source routing. Celery beat calendar; multi-source fan-in will require per-event source routing. Celery beat
triggers recovery every minute, while root-transaction after-commit dispatch triggers recovery every minute, while root-transaction after-commit dispatch

View File

@@ -1,7 +1,10 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import replace
from pathlib import Path from pathlib import Path
from sqlalchemy import inspect
from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.calendar import ( from govoplan_core.core.calendar import (
@@ -24,6 +27,41 @@ from govoplan_core.core.modules import (
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
_calendar_table_retirement_provider = drop_table_retirement_provider(
calendar_models.CalendarCollection,
calendar_models.CalendarEvent,
calendar_models.CalendarOutboxOperation,
calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource,
label="Calendar",
)
def _calendar_retirement_provider(session: object | None, module_id: str):
plan = _calendar_table_retirement_provider(session, module_id)
base_executor = plan.destroy_data_executor
if base_executor is None:
return plan
def executor(execute_session: object, execute_module_id: str) -> None:
if not hasattr(execute_session, "get_bind") or not hasattr(execute_session, "query"):
raise RuntimeError("No database session is available for Calendar credential retirement.")
if inspect(execute_session.get_bind()).has_table(calendar_models.CalendarSyncCredential.__tablename__):
from govoplan_calendar.backend.service import delete_calendar_credentials_for_retirement
delete_calendar_credentials_for_retirement(execute_session)
base_executor(execute_session, execute_module_id)
return replace(
plan,
destroy_data_warnings=(
*plan.destroy_data_warnings,
"Calendar-owned credentials are deleted immediately before tables are dropped; retirement fails if an external secret provider is unavailable.",
),
destroy_data_executor=executor,
)
def _permission(scope: str, label: str, description: str) -> PermissionDefinition: def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2) module_id, resource, action = scope.split(":", 2)
return PermissionDefinition( return PermissionDefinition(
@@ -163,14 +201,7 @@ manifest = ModuleManifest(
metadata=Base.metadata, metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"), script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True, retirement_supported=True,
retirement_provider=drop_table_retirement_provider( retirement_provider=_calendar_retirement_provider,
calendar_models.CalendarCollection,
calendar_models.CalendarEvent,
calendar_models.CalendarOutboxOperation,
calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource,
label="Calendar",
),
retirement_notes="Destructive retirement drops calendar-owned database tables after the installer captures a database snapshot.", retirement_notes="Destructive retirement drops calendar-owned database tables after the installer captures a database snapshot.",
), ),
uninstall_guard_providers=( uninstall_guard_providers=(

View File

@@ -298,7 +298,14 @@ def api_delete_calendar(
): ):
_require_scope(principal, "calendar:calendar:admin") _require_scope(principal, "calendar:calendar:admin")
try: try:
delete_calendar(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, payload=payload) delete_calendar(
session,
tenant_id=principal.tenant_id,
calendar_id=calendar_id,
payload=payload,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit() session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc: except CalendarError as exc:
@@ -344,7 +351,14 @@ def api_update_sync_source(
): ):
_require_scope(principal, "calendar:calendar:admin") _require_scope(principal, "calendar:calendar:admin")
try: try:
source = update_sync_source(session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload) source = update_sync_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
payload=payload,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit() session.commit()
session.refresh(source) session.refresh(source)
return _sync_source_response(source) return _sync_source_response(source)
@@ -361,7 +375,13 @@ def api_delete_sync_source(
): ):
_require_scope(principal, "calendar:calendar:admin") _require_scope(principal, "calendar:calendar:admin")
try: try:
delete_sync_source(session, tenant_id=principal.tenant_id, source_id=source_id) delete_sync_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit() session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc: except CalendarError as exc:
@@ -479,7 +499,14 @@ def api_update_caldav_source(
): ):
_require_scope(principal, "calendar:calendar:admin") _require_scope(principal, "calendar:calendar:admin")
try: try:
source = update_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload) source = update_caldav_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
payload=payload,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit() session.commit()
session.refresh(source) session.refresh(source)
return _caldav_source_response(source) return _caldav_source_response(source)
@@ -496,7 +523,13 @@ def api_delete_caldav_source(
): ):
_require_scope(principal, "calendar:calendar:admin") _require_scope(principal, "calendar:calendar:admin")
try: try:
delete_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id) delete_caldav_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit() session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc: except CalendarError as exc:

View File

@@ -23,6 +23,7 @@ from govoplan_core.security.outbound_http import (
build_outbound_http_opener, build_outbound_http_opener,
validate_outbound_http_url, validate_outbound_http_url,
) )
from govoplan_core.audit.logging import audit_event
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
@@ -710,7 +711,15 @@ def _move_calendar_before_delete(
return calendar return calendar
def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, payload: CalendarCollectionDeleteRequest | None = None) -> None: def delete_calendar(
session: Session,
*,
tenant_id: str,
calendar_id: str,
payload: CalendarCollectionDeleteRequest | None = None,
user_id: str | None = None,
api_key_id: str | None = None,
) -> None:
payload = payload or CalendarCollectionDeleteRequest() payload = payload or CalendarCollectionDeleteRequest()
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id) calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id)
deleted_at = utcnow() deleted_at = utcnow()
@@ -729,7 +738,15 @@ def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo
if calendar.is_default: if calendar.is_default:
calendar.is_default = False calendar.is_default = False
calendar.deleted_at = deleted_at calendar.deleted_at = deleted_at
retire_sync_sources_for_calendar(session, tenant_id=tenant_id, calendar_id=calendar.id, deleted_at=deleted_at) retire_sync_sources_for_calendar(
session,
tenant_id=tenant_id,
calendar_id=calendar.id,
deleted_at=deleted_at,
deletion_reason="calendar_deleted",
user_id=user_id,
api_key_id=api_key_id,
)
for event in calendar.events: for event in calendar.events:
if payload.event_action == "delete" and event.deleted_at is None: if payload.event_action == "delete" and event.deleted_at is None:
event.deleted_at = deleted_at event.deleted_at = deleted_at
@@ -1135,6 +1152,9 @@ def _update_sync_source_credential_and_schedule(
tenant_id: str, tenant_id: str,
source: CalendarSyncSource, source: CalendarSyncSource,
payload: CalendarSyncSourceUpdateRequest, payload: CalendarSyncSourceUpdateRequest,
previous_auth_type: str,
user_id: str | None,
api_key_id: str | None,
) -> None: ) -> None:
credential_value = caldav_secret_from_payload( credential_value = caldav_secret_from_payload(
auth_type=source.auth_type, auth_type=source.auth_type,
@@ -1145,15 +1165,39 @@ def _update_sync_source_credential_and_schedule(
source.credential_ref = store_caldav_credential( source.credential_ref = store_caldav_credential(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
user_id=None, user_id=user_id,
source=source, source=source,
secret=credential_value, secret=credential_value,
api_key_id=api_key_id,
) )
elif payload.auth_type is not None and source.auth_type != previous_auth_type:
delete_caldav_credential(
session,
tenant_id=tenant_id,
source_id=source.id,
credential_ref=source.credential_ref,
deletion_reason=(
"authentication_disabled"
if source.auth_type == "none"
else "authentication_type_changed"
),
user_id=user_id,
api_key_id=api_key_id,
)
source.credential_ref = None
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: def update_sync_source(
session: Session,
*,
tenant_id: str,
source_id: str,
payload: CalendarSyncSourceUpdateRequest,
user_id: str | None = None,
api_key_id: str | None = None,
) -> CalendarSyncSource:
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id) source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id)
source = _lock_sync_source_for_update( source = _lock_sync_source_for_update(
session, session,
@@ -1164,6 +1208,7 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
raise CalendarError( raise CalendarError(
"Caller-supplied credential references are not accepted; provide a replacement password or bearer token" "Caller-supplied credential references are not accepted; provide a replacement password or bearer token"
) )
previous_auth_type = source.auth_type
( (
normalized_collection_url, normalized_collection_url,
materially_reconfigured, materially_reconfigured,
@@ -1194,30 +1239,87 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
tenant_id=tenant_id, tenant_id=tenant_id,
source=source, source=source,
payload=payload, payload=payload,
previous_auth_type=previous_auth_type,
user_id=user_id,
api_key_id=api_key_id,
) )
mark_calendar_sync_source(calendar, source) mark_calendar_sync_source(calendar, source)
session.flush() session.flush()
return source return source
def update_caldav_source(session: Session, *, tenant_id: str, source_id: str, payload: CalendarCalDavSourceUpdateRequest) -> CalendarSyncSource: def update_caldav_source(
session: Session,
*,
tenant_id: str,
source_id: str,
payload: CalendarCalDavSourceUpdateRequest,
user_id: str | None = None,
api_key_id: str | None = None,
) -> CalendarSyncSource:
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id, source_kind="caldav") source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id, source_kind="caldav")
return update_sync_source(session, tenant_id=tenant_id, source_id=source.id, payload=payload) return update_sync_source(
session,
tenant_id=tenant_id,
source_id=source.id,
payload=payload,
user_id=user_id,
api_key_id=api_key_id,
)
def delete_sync_source(session: Session, *, tenant_id: str, source_id: str) -> None: def delete_sync_source(
session: Session,
*,
tenant_id: str,
source_id: str,
user_id: str | None = None,
api_key_id: str | None = None,
) -> None:
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id) source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id)
retire_sync_source(session, tenant_id=tenant_id, source=source, deleted_at=utcnow()) retire_sync_source(
session,
tenant_id=tenant_id,
source=source,
deleted_at=utcnow(),
deletion_reason="sync_source_deleted",
user_id=user_id,
api_key_id=api_key_id,
)
session.flush() session.flush()
def delete_caldav_source(session: Session, *, tenant_id: str, source_id: str) -> None: def delete_caldav_source(
session: Session,
*,
tenant_id: str,
source_id: str,
user_id: str | None = None,
api_key_id: str | None = None,
) -> None:
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id, source_kind="caldav") source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id, source_kind="caldav")
retire_sync_source(session, tenant_id=tenant_id, source=source, deleted_at=utcnow()) retire_sync_source(
session,
tenant_id=tenant_id,
source=source,
deleted_at=utcnow(),
deletion_reason="sync_source_deleted",
user_id=user_id,
api_key_id=api_key_id,
)
session.flush() session.flush()
def retire_sync_sources_for_calendar(session: Session, *, tenant_id: str, calendar_id: str, deleted_at: datetime) -> None: def retire_sync_sources_for_calendar(
session: Session,
*,
tenant_id: str,
calendar_id: str,
deleted_at: datetime,
deletion_reason: str = "calendar_deleted",
user_id: str | None = None,
api_key_id: str | None = None,
) -> None:
if not hasattr(session, "query"): if not hasattr(session, "query"):
return return
sources = ( sources = (
@@ -1230,7 +1332,15 @@ def retire_sync_sources_for_calendar(session: Session, *, tenant_id: str, calend
.all() .all()
) )
for source in sources: for source in sources:
retire_sync_source(session, tenant_id=tenant_id, source=source, deleted_at=deleted_at) retire_sync_source(
session,
tenant_id=tenant_id,
source=source,
deleted_at=deleted_at,
deletion_reason=deletion_reason,
user_id=user_id,
api_key_id=api_key_id,
)
def retire_caldav_sources_for_calendar(session: Session, *, tenant_id: str, calendar_id: str, deleted_at: datetime) -> None: def retire_caldav_sources_for_calendar(session: Session, *, tenant_id: str, calendar_id: str, deleted_at: datetime) -> None:
@@ -1252,7 +1362,13 @@ def retire_stale_sync_sources_for_url(session: Session, *, tenant_id: str, sourc
now = utcnow() now = utcnow()
for source in sources: for source in sources:
if source.calendar is None or source.calendar.deleted_at is not None: if source.calendar is None or source.calendar.deleted_at is not None:
retire_sync_source(session, tenant_id=tenant_id, source=source, deleted_at=now) retire_sync_source(
session,
tenant_id=tenant_id,
source=source,
deleted_at=now,
deletion_reason="stale_sync_source_retired",
)
session.flush() session.flush()
@@ -1281,7 +1397,16 @@ def active_caldav_source_for_url(session: Session, *, tenant_id: str, collection
return active_sync_source_for_url(session, tenant_id=tenant_id, source_kind="caldav", collection_url=collection_url) return active_sync_source_for_url(session, tenant_id=tenant_id, source_kind="caldav", collection_url=collection_url)
def retire_sync_source(session: Session, *, tenant_id: str, source: CalendarSyncSource, deleted_at: datetime) -> None: def retire_sync_source(
session: Session,
*,
tenant_id: str,
source: CalendarSyncSource,
deleted_at: datetime,
deletion_reason: str = "sync_source_retired",
user_id: str | None = None,
api_key_id: str | None = None,
) -> None:
from govoplan_calendar.backend.outbox import ( from govoplan_calendar.backend.outbox import (
calendar_outbox_has_live_lease, calendar_outbox_has_live_lease,
cancel_calendar_outbox_for_source, cancel_calendar_outbox_for_source,
@@ -1302,18 +1427,26 @@ def retire_sync_source(session: Session, *, tenant_id: str, source: CalendarSync
"CalDAV source cannot be retired while an outbound operation has an active lease" "CalDAV source cannot be retired while an outbound operation has an active lease"
) )
# Delete credentials first. An external secret provider cannot participate
# in the SQL transaction, so provider failure must leave the source and its
# queued work untouched. If the later SQL commit fails after provider
# success, the source fails closed and the idempotent delete can be retried.
delete_caldav_credential(
session,
tenant_id=tenant_id,
source_id=source.id,
credential_ref=source.credential_ref,
deletion_reason=deletion_reason,
user_id=user_id,
api_key_id=api_key_id,
)
source.credential_ref = None
cancel_calendar_outbox_for_source( cancel_calendar_outbox_for_source(
session, session,
source_id=source.id, source_id=source.id,
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,
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:
@@ -1343,6 +1476,7 @@ def store_caldav_credential(
user_id: str | None, user_id: str | None,
source: CalendarSyncSource, source: CalendarSyncSource,
secret: str, secret: str,
api_key_id: str | None = None,
) -> str: ) -> str:
existing = internal_caldav_credential( existing = internal_caldav_credential(
session, session,
@@ -1350,6 +1484,7 @@ def store_caldav_credential(
source_id=source.id, source_id=source.id,
credential_ref=source.credential_ref, credential_ref=source.credential_ref,
) )
replacing_existing = existing is not None
if existing is None: if existing is None:
existing = CalendarSyncCredential( existing = CalendarSyncCredential(
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -1361,10 +1496,45 @@ def store_caldav_credential(
session.add(existing) session.add(existing)
session.flush() session.flush()
old_provider_ref = _credential_provider_ref(existing)
provider = secret_provider() provider = secret_provider()
name = f"caldav:{source.id}:{source.auth_type}" name = f"caldav:{source.id}:{source.auth_type}"
if old_provider_ref and provider is None:
raise CalendarError(
"Stored sync credential cannot be replaced while its secret provider is unavailable"
)
if provider is not None: if provider is not None:
provider_ref = str(provider.store_secret(scope=f"calendar:{tenant_id}", name=name, value=secret)) try:
provider_ref = str(
provider.store_secret(
scope=f"calendar:{tenant_id}",
name=name,
value=secret,
)
).strip()
except Exception:
raise CalendarError("Stored sync credential could not be written to its secret provider") from None
if not provider_ref:
raise CalendarError("Secret provider returned an empty credential reference")
if old_provider_ref and old_provider_ref != provider_ref:
try:
provider.delete_secret(old_provider_ref)
except Exception:
if not _provider_secret_is_absent(provider, old_provider_ref):
compensation_failed = False
try:
provider.delete_secret(provider_ref)
except Exception:
compensation_failed = not _provider_secret_is_absent(provider, provider_ref)
compensation_detail = (
"; replacement cleanup also failed and requires provider-side reconciliation"
if compensation_failed
else ""
)
raise CalendarError(
"Previous sync credential could not be deleted from its secret provider; "
f"the replacement was not activated{compensation_detail}"
) from None
existing.secret_encrypted = None existing.secret_encrypted = None
existing.metadata_ = {"source_id": source.id, "provider_ref": provider_ref} existing.metadata_ = {"source_id": source.id, "provider_ref": provider_ref}
else: else:
@@ -1373,6 +1543,24 @@ def store_caldav_credential(
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.deleted_at = None existing.deleted_at = None
if replacing_existing:
audit_event(
session,
tenant_id=tenant_id,
user_id=user_id,
api_key_id=api_key_id,
action="calendar.sync_credential_rotated",
object_type="calendar_sync_credential",
object_id=existing.id,
details={
"sync_source_id": source.id,
"storage_backend": (
"external_secret_provider"
if provider is not None
else "encrypted_database"
),
},
)
session.flush() session.flush()
return f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{existing.id}" return f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{existing.id}"
@@ -1383,9 +1571,12 @@ def delete_caldav_credential(
tenant_id: str, tenant_id: str,
source_id: str, source_id: str,
credential_ref: str | None, credential_ref: str | None,
) -> None: deletion_reason: str = "sync_source_retired",
user_id: str | None = None,
api_key_id: str | None = None,
) -> bool:
if not credential_ref: if not credential_ref:
return return False
credential = internal_caldav_credential( credential = internal_caldav_credential(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -1395,12 +1586,97 @@ def delete_caldav_credential(
if credential is None: if credential is None:
# Legacy external/env references have no locally provable ownership and # Legacy external/env references have no locally provable ownership and
# must never be passed to a provider delete operation. # must never be passed to a provider delete operation.
return return False
return _delete_calendar_owned_credential(
session,
credential=credential,
source_id=source_id,
deletion_reason=deletion_reason,
user_id=user_id,
api_key_id=api_key_id,
)
def _delete_calendar_owned_credential(
session: Session,
*,
credential: CalendarSyncCredential,
source_id: str | None,
deletion_reason: str,
user_id: str | None = None,
api_key_id: str | None = None,
) -> bool:
provider_ref = _credential_provider_ref(credential) provider_ref = _credential_provider_ref(credential)
if not credential.secret_encrypted and not provider_ref:
return False
provider = secret_provider() provider = secret_provider()
if provider is not None and provider_ref: storage_backend = "encrypted_database"
if provider_ref:
if provider is None:
raise CalendarError(
"Stored sync credential cannot be deleted while its secret provider is unavailable; "
"the requested deletion was not completed"
)
try:
provider.delete_secret(provider_ref) provider.delete_secret(provider_ref)
except Exception:
if not _provider_secret_is_absent(provider, provider_ref):
raise CalendarError(
"Stored sync credential could not be deleted from its secret provider; "
"the requested deletion was not completed"
) from None
storage_backend = "external_secret_provider"
credential.secret_encrypted = None
credential.metadata_ = {"source_id": source_id} if source_id else {}
credential.deleted_at = utcnow() credential.deleted_at = utcnow()
audit_event(
session,
tenant_id=credential.tenant_id,
user_id=user_id,
api_key_id=api_key_id,
action="calendar.sync_credential_deleted",
object_type="calendar_sync_credential",
object_id=credential.id,
details={
"sync_source_id": source_id,
"storage_backend": storage_backend,
"deletion_reason": deletion_reason,
},
)
return True
def _provider_secret_is_absent(provider: Any, provider_ref: str) -> bool:
"""Confirm an idempotent delete when a provider reports missing/error."""
try:
return provider.read_secret(provider_ref) is None
except Exception:
return False
def delete_calendar_credentials_for_retirement(session: Session) -> int:
"""Delete all Calendar-owned secrets before destructive module retirement.
This includes pre-hardening soft-deleted rows whose provider references or
ciphertext were retained. Provider deletion is intentionally fail-closed;
the caller must not drop Calendar tables if any external deletion fails.
"""
credentials = session.query(CalendarSyncCredential).order_by(CalendarSyncCredential.id.asc()).all()
deleted = 0
for credential in credentials:
metadata = credential.metadata_ if isinstance(credential.metadata_, dict) else {}
source_id = metadata.get("source_id")
if _delete_calendar_owned_credential(
session,
credential=credential,
source_id=str(source_id) if source_id else None,
deletion_reason="module_data_retired",
):
deleted += 1
session.flush()
return deleted
def resolve_caldav_secret( def resolve_caldav_secret(

View File

@@ -4,12 +4,13 @@ import unittest
from datetime import datetime, timezone from datetime import datetime, timezone
from unittest.mock import patch from unittest.mock import patch
from sqlalchemy import create_engine from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables
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.manifest import manifest
from govoplan_calendar.backend.outbox import dispatch_calendar_outbox from govoplan_calendar.backend.outbox import dispatch_calendar_outbox
from govoplan_calendar.backend.schemas import ( from govoplan_calendar.backend.schemas import (
CalendarCalDavDiscoveryRequest, CalendarCalDavDiscoveryRequest,
@@ -27,6 +28,7 @@ from govoplan_calendar.backend.service import (
create_calendar, create_calendar,
create_caldav_source, create_caldav_source,
create_event, create_event,
delete_caldav_source,
delete_calendar, delete_calendar,
delete_event, delete_event,
discover_caldav_calendars, discover_caldav_calendars,
@@ -365,6 +367,7 @@ class CalDAVSyncTests(unittest.TestCase):
def delete_secret(self, secret_ref: str) -> None: def delete_secret(self, secret_ref: str) -> None:
self.deleted.append(secret_ref) self.deleted.append(secret_ref)
self.values.pop(secret_ref, None)
provider = Provider() provider = Provider()
session = self.session() session = self.session()
@@ -437,9 +440,23 @@ class CalDAVSyncTests(unittest.TestCase):
delete_calendar(session, tenant_id="tenant-1", calendar_id=other_calendar.id) delete_calendar(session, tenant_id="tenant-1", calendar_id=other_calendar.id)
self.assertEqual(provider.deleted, []) self.assertEqual(provider.deleted, [])
with patch("govoplan_calendar.backend.service.audit_event") as audit:
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id) delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
self.assertEqual(provider.deleted, [provider_ref]) self.assertEqual(provider.deleted, [provider_ref])
self.assertNotIn(provider_ref, provider.values)
self.assertIsNone(source.credential_ref)
self.assertIsNone(credential.secret_encrypted)
self.assertNotIn("provider_ref", credential.metadata_ or {})
audit.assert_called_once()
audit_call = audit.call_args.kwargs
self.assertEqual(audit_call["action"], "calendar.sync_credential_deleted")
self.assertEqual(audit_call["object_id"], credential.id)
self.assertEqual(audit_call["details"]["storage_backend"], "external_secret_provider")
self.assertEqual(audit_call["details"]["deletion_reason"], "calendar_deleted")
self.assertNotIn("credential_ref", audit_call["details"])
self.assertNotIn("provider_ref", audit_call["details"])
self.assertNotIn(provider_ref, repr(audit_call["details"]))
def test_legacy_unowned_refs_are_neither_read_nor_deleted(self) -> None: def test_legacy_unowned_refs_are_neither_read_nor_deleted(self) -> None:
class Provider: class Provider:
@@ -516,9 +533,312 @@ class CalDAVSyncTests(unittest.TestCase):
credential = session.query(CalendarSyncCredential).one() credential = session.query(CalendarSyncCredential).one()
self.assertIsNotNone(source.deleted_at) self.assertIsNotNone(source.deleted_at)
self.assertIsNone(source.credential_ref)
self.assertIsNotNone(credential.deleted_at) self.assertIsNotNone(credential.deleted_at)
self.assertIsNone(credential.secret_encrypted)
self.assertEqual(credential.metadata_, {"source_id": source.id})
self.assertEqual(list_caldav_sources(session, tenant_id="tenant-1"), []) self.assertEqual(list_caldav_sources(session, tenant_id="tenant-1"), [])
def test_external_credential_deletion_fails_closed_when_provider_is_unavailable(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, 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.values.pop(secret_ref, None)
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/provider-down",
auth_type="bearer",
bearer_token="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
with patch("govoplan_calendar.backend.service.secret_provider", return_value=None), self.assertRaisesRegex(
CalendarError,
"secret provider is unavailable",
):
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
session.rollback()
self.assertIsNone(source.deleted_at)
self.assertEqual(source.credential_ref, f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{credential.id}")
self.assertIsNone(credential.deleted_at)
self.assertEqual((credential.metadata_ or {})["provider_ref"], provider_ref)
self.assertIn(provider_ref, provider.values)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider), patch.object(
provider,
"delete_secret",
side_effect=RuntimeError(f"provider failed for {provider_ref}"),
):
with self.assertRaises(CalendarError) as raised:
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
self.assertNotIn(provider_ref, str(raised.exception))
session.rollback()
self.assertIsNone(source.deleted_at)
self.assertIsNone(credential.deleted_at)
def test_disabling_auth_immediately_scrubs_and_audits_database_credential(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"),
)
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/disable-auth",
auth_type="basic",
username="ada",
password="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
self.assertIsNotNone(credential.secret_encrypted)
with patch("govoplan_calendar.backend.service.audit_event") as audit:
update_caldav_source(
session,
tenant_id="tenant-1",
source_id=source.id,
payload=CalendarCalDavSourceUpdateRequest(auth_type="none"),
)
session.commit()
self.assertIsNone(source.credential_ref)
self.assertIsNone(credential.secret_encrypted)
self.assertIsNotNone(credential.deleted_at)
audit.assert_called_once()
self.assertEqual(
audit.call_args.kwargs["details"]["deletion_reason"],
"authentication_disabled",
)
self.assertNotIn("do-not-log-this", repr(audit.call_args))
def test_external_credential_replacement_fails_closed_when_provider_is_unavailable(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, 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.values.pop(secret_ref, None)
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/replacement-provider-down",
auth_type="bearer",
bearer_token="old-secret",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
with patch("govoplan_calendar.backend.service.secret_provider", return_value=None), self.assertRaisesRegex(
CalendarError,
"cannot be replaced.*provider is unavailable",
):
update_caldav_source(
session,
tenant_id="tenant-1",
source_id=source.id,
payload=CalendarCalDavSourceUpdateRequest(bearer_token="new-secret"),
)
session.rollback()
self.assertEqual(source.credential_ref, f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{credential.id}")
self.assertEqual((credential.metadata_ or {})["provider_ref"], provider_ref)
self.assertEqual(provider.values[provider_ref], "old-secret")
def test_external_credential_delete_is_retryable_after_database_rollback(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
self.deletes: 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.deletes.append(secret_ref)
if secret_ref not in self.values:
raise KeyError("already deleted")
self.values.pop(secret_ref, None)
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/retry-delete",
auth_type="basic",
username="ada",
password="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
with patch("govoplan_calendar.backend.service.audit_event"):
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
self.assertNotIn(provider_ref, provider.values)
self.assertIsNotNone(credential.deleted_at)
self.assertIsNone(source.credential_ref)
session.rollback()
self.assertIsNone(source.deleted_at)
session.refresh(credential)
self.assertIsNone(credential.deleted_at)
self.assertEqual((credential.metadata_ or {})["provider_ref"], provider_ref)
self.assertIsNone(resolve_caldav_credential_ref(
session,
tenant_id="tenant-1",
source_id=source.id,
credential_ref=source.credential_ref,
))
with patch("govoplan_calendar.backend.service.audit_event") as audit:
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
session.commit()
self.assertEqual(provider.deletes, [provider_ref, provider_ref])
self.assertIsNotNone(source.deleted_at)
self.assertIsNone(source.credential_ref)
self.assertIsNotNone(credential.deleted_at)
self.assertEqual(audit.call_count, 1)
def test_destructive_module_retirement_deletes_external_credentials_before_tables(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)
self.values.pop(secret_ref, None)
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):
create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/module-retirement",
auth_type="bearer",
bearer_token="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
retirement_provider = manifest.migration_spec.retirement_provider
assert retirement_provider is not None
plan = retirement_provider(session, "calendar")
assert plan.destroy_data_executor is not None
with patch("govoplan_calendar.backend.service.audit_event") as audit:
plan.destroy_data_executor(session, "calendar")
self.assertEqual(provider.deleted, [provider_ref])
self.assertEqual(provider.values, {})
self.assertEqual(audit.call_count, 1)
self.assertEqual(
audit.call_args.kwargs["details"]["deletion_reason"],
"module_data_retired",
)
self.assertFalse(inspect(self.engine).has_table("calendar_sync_credentials"))
self.assertFalse(inspect(self.engine).has_table("calendar_sync_sources"))
def test_create_source_retires_orphaned_source_for_deleted_calendar(self) -> None: def test_create_source_retires_orphaned_source_for_deleted_calendar(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"))