fix(installer): delete provider secrets before retirement
This commit is contained in:
@@ -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=(
|
||||||
|
|||||||
@@ -1655,6 +1655,30 @@ def _provider_secret_is_absent(provider: Any, provider_ref: str) -> bool:
|
|||||||
return False
|
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(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -767,6 +768,65 @@ class CalDAVSyncTests(unittest.TestCase):
|
|||||||
self.assertIsNotNone(credential.deleted_at)
|
self.assertIsNotNone(credential.deleted_at)
|
||||||
self.assertEqual(audit.call_count, 1)
|
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"))
|
||||||
|
|||||||
Reference in New Issue
Block a user