fix(secrets): fail closed on connector credential deletion
This commit is contained in:
@@ -27,6 +27,7 @@ from govoplan_calendar.backend.service import (
|
||||
create_calendar,
|
||||
create_caldav_source,
|
||||
create_event,
|
||||
delete_caldav_source,
|
||||
delete_calendar,
|
||||
delete_event,
|
||||
discover_caldav_calendars,
|
||||
@@ -365,6 +366,7 @@ class CalDAVSyncTests(unittest.TestCase):
|
||||
|
||||
def delete_secret(self, secret_ref: str) -> None:
|
||||
self.deleted.append(secret_ref)
|
||||
self.values.pop(secret_ref, None)
|
||||
|
||||
provider = Provider()
|
||||
session = self.session()
|
||||
@@ -437,9 +439,23 @@ class CalDAVSyncTests(unittest.TestCase):
|
||||
delete_calendar(session, tenant_id="tenant-1", calendar_id=other_calendar.id)
|
||||
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.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:
|
||||
class Provider:
|
||||
@@ -516,9 +532,241 @@ class CalDAVSyncTests(unittest.TestCase):
|
||||
|
||||
credential = session.query(CalendarSyncCredential).one()
|
||||
self.assertIsNotNone(source.deleted_at)
|
||||
self.assertIsNone(source.credential_ref)
|
||||
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"), [])
|
||||
|
||||
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:
|
||||
session = self.session()
|
||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||
|
||||
Reference in New Issue
Block a user