fix(secrets): fail closed on connector credential deletion

This commit is contained in:
2026-07-21 15:45:43 +02:00
parent 74495e56cb
commit 39b199d7e6
3 changed files with 565 additions and 32 deletions

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 as exc:
raise CalendarError("Stored sync credential could not be written to its secret provider") from exc
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 as exc:
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 exc
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,73 @@ 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"
provider.delete_secret(provider_ref) 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)
except Exception as exc:
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 exc
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 resolve_caldav_secret( def resolve_caldav_secret(

View File

@@ -27,6 +27,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 +366,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 +439,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, [])
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id) with patch("govoplan_calendar.backend.service.audit_event") as audit:
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 +532,241 @@ 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)
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_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"))