diff --git a/src/govoplan_calendar/backend/manifest.py b/src/govoplan_calendar/backend/manifest.py index 6c0a84b..e658fc5 100644 --- a/src/govoplan_calendar/backend/manifest.py +++ b/src/govoplan_calendar/backend/manifest.py @@ -1,7 +1,10 @@ from __future__ import annotations +from dataclasses import replace 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_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.calendar import ( @@ -24,6 +27,41 @@ from govoplan_core.core.modules import ( 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: module_id, resource, action = scope.split(":", 2) return PermissionDefinition( @@ -163,14 +201,7 @@ manifest = ModuleManifest( metadata=Base.metadata, script_location=str(Path(__file__).with_name("migrations") / "versions"), retirement_supported=True, - retirement_provider=drop_table_retirement_provider( - calendar_models.CalendarCollection, - calendar_models.CalendarEvent, - calendar_models.CalendarOutboxOperation, - calendar_models.CalendarSyncCredential, - calendar_models.CalendarSyncSource, - label="Calendar", - ), + retirement_provider=_calendar_retirement_provider, retirement_notes="Destructive retirement drops calendar-owned database tables after the installer captures a database snapshot.", ), uninstall_guard_providers=( diff --git a/src/govoplan_calendar/backend/service.py b/src/govoplan_calendar/backend/service.py index 7479540..33dfd36 100644 --- a/src/govoplan_calendar/backend/service.py +++ b/src/govoplan_calendar/backend/service.py @@ -1655,6 +1655,30 @@ def _provider_secret_is_absent(provider: Any, provider_ref: str) -> bool: 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( session: Session, *, diff --git a/tests/test_caldav.py b/tests/test_caldav.py index 1bd6fda..3b4879d 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -4,12 +4,13 @@ import unittest from datetime import datetime, timezone from unittest.mock import patch -from sqlalchemy import create_engine +from sqlalchemy import create_engine, inspect from sqlalchemy.orm import sessionmaker 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.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.schemas import ( CalendarCalDavDiscoveryRequest, @@ -767,6 +768,65 @@ class CalDAVSyncTests(unittest.TestCase): 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: session = self.session() session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))