Compare commits
5 Commits
5dc9392290
...
v0.1.9
| Author | SHA1 | Date | |
|---|---|---|---|
| 93dddbb8c5 | |||
| 04accaa206 | |||
| 75c9ece709 | |||
| d005040a8e | |||
| 613fb15a80 |
@@ -43,6 +43,13 @@ inspection UI are implemented. The conflict review UI compares stored local and
|
|||||||
remote field payloads, can apply a stored remote vCard payload, and supports
|
remote field payloads, can apply a stored remote vCard payload, and supports
|
||||||
manual per-field local/remote merge choices.
|
manual per-field local/remote merge choices.
|
||||||
|
|
||||||
|
API-managed CardDAV credentials are encrypted inside the source record. Source
|
||||||
|
deletion physically removes that credential material and records a non-secret
|
||||||
|
audit event in the same database transaction; destructive module retirement
|
||||||
|
audits every remaining credential before the owning tables are dropped. Legacy
|
||||||
|
external references are detached but are never sent to a secret provider for
|
||||||
|
deletion because Addresses cannot prove that it owns them.
|
||||||
|
|
||||||
## Boundary
|
## Boundary
|
||||||
|
|
||||||
`govoplan-addresses` owns:
|
`govoplan-addresses` owns:
|
||||||
|
|||||||
@@ -148,7 +148,12 @@ a conflict instead of silently overwriting remote data. The first conflict
|
|||||||
review UI compares stored local and remote field payloads and can apply a
|
review UI compares stored local and remote field payloads and can apply a
|
||||||
stored remote vCard payload or a manual per-field local/remote merge payload.
|
stored remote vCard payload or a manual per-field local/remote merge payload.
|
||||||
Source disconnect/delete removes the source binding and related sync records
|
Source disconnect/delete removes the source binding and related sync records
|
||||||
while keeping local contacts.
|
while keeping local contacts. Because API-managed CardDAV credentials are
|
||||||
|
encrypted in the source row, the same transaction physically removes their
|
||||||
|
ciphertext and emits non-secret credential-deletion audit evidence. Destructive
|
||||||
|
module retirement audits all remaining owned credential material before table
|
||||||
|
removal. An unowned legacy reference is detached rather than passed to an
|
||||||
|
external secret provider.
|
||||||
|
|
||||||
## Connector Direction
|
## Connector Direction
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/addresses-webui",
|
"name": "@govoplan/addresses-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.9",
|
"@govoplan/core-webui": "^0.1.11",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-addresses"
|
name = "govoplan-addresses"
|
||||||
version = "0.1.8"
|
version = "0.1.9"
|
||||||
description = "GovOPlaN reusable address and recipient-source module."
|
description = "GovOPlaN reusable address and recipient-source module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"defusedxml>=0.7.1",
|
"defusedxml>=0.7.1",
|
||||||
"govoplan-core>=0.1.9",
|
"govoplan-core>=0.1.11",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ from sqlalchemy import func
|
|||||||
|
|
||||||
from govoplan_core.auth import ApiPrincipal
|
from govoplan_core.auth import ApiPrincipal
|
||||||
from govoplan_core.db.base import utcnow
|
from govoplan_core.db.base import utcnow
|
||||||
|
from govoplan_core.core.people import (
|
||||||
|
PeopleSearchGroup,
|
||||||
|
PersonSearchCandidate,
|
||||||
|
person_selection_key,
|
||||||
|
)
|
||||||
from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressListEntry, Contact, ContactEmail, ContactPhone
|
from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressListEntry, Contact, ContactEmail, ContactPhone
|
||||||
from govoplan_addresses.backend.schemas import ContactCreateRequest
|
from govoplan_addresses.backend.schemas import ContactCreateRequest
|
||||||
from govoplan_addresses.backend.service import (
|
from govoplan_addresses.backend.service import (
|
||||||
@@ -137,6 +142,60 @@ class AddressesLookupCapability:
|
|||||||
return tuple(candidates)
|
return tuple(candidates)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressesPeopleSearchProvider:
|
||||||
|
"""Adapt visible address-book contacts to the shared people-search contract."""
|
||||||
|
|
||||||
|
def __init__(self, lookup: AddressesLookupCapability | None = None) -> None:
|
||||||
|
self._lookup = lookup or AddressesLookupCapability()
|
||||||
|
|
||||||
|
def search_people(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
limit: int = 25,
|
||||||
|
) -> tuple[PeopleSearchGroup, ...]:
|
||||||
|
if not hasattr(principal, "account_id") or not hasattr(principal, "tenant_id") or not hasattr(principal, "group_ids"):
|
||||||
|
raise AddressBookError("Address contact search requires an authenticated tenant principal.")
|
||||||
|
candidates = self._lookup.lookup(
|
||||||
|
session,
|
||||||
|
principal, # type: ignore[arg-type] - validated principal contract
|
||||||
|
query=query,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
PeopleSearchGroup(
|
||||||
|
key="contacts",
|
||||||
|
label="Contacts",
|
||||||
|
candidates=tuple(
|
||||||
|
PersonSearchCandidate(
|
||||||
|
selection_key=person_selection_key("contact", item.contact_id, email=item.email),
|
||||||
|
kind="contact",
|
||||||
|
reference_id=item.contact_id,
|
||||||
|
display_name=item.display_name,
|
||||||
|
email=item.email,
|
||||||
|
source_module="addresses",
|
||||||
|
source_label="Contacts",
|
||||||
|
source_ref=item.source_ref or f"addresses:contact:{item.contact_id}",
|
||||||
|
source_revision=item.source_revision,
|
||||||
|
description=" · ".join(part for part in (item.organization, item.role_title) if part) or None,
|
||||||
|
provenance=dict(item.provenance),
|
||||||
|
metadata={
|
||||||
|
"address_book_id": item.address_book_id,
|
||||||
|
"email_label": item.email_label,
|
||||||
|
"organization": item.organization,
|
||||||
|
"role_title": item.role_title,
|
||||||
|
"tags": tuple(item.tags),
|
||||||
|
"source_kind": item.source_kind,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for item in candidates
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AddressesContactWriterCapability:
|
class AddressesContactWriterCapability:
|
||||||
def list_write_targets(
|
def list_write_targets(
|
||||||
self,
|
self,
|
||||||
@@ -301,6 +360,10 @@ def lookup_capability(_context: Any) -> AddressesLookupCapability:
|
|||||||
return AddressesLookupCapability()
|
return AddressesLookupCapability()
|
||||||
|
|
||||||
|
|
||||||
|
def people_search_capability(_context: Any) -> AddressesPeopleSearchProvider:
|
||||||
|
return AddressesPeopleSearchProvider()
|
||||||
|
|
||||||
|
|
||||||
def recipient_source_capability(_context: Any) -> AddressesRecipientSourceCapability:
|
def recipient_source_capability(_context: Any) -> AddressesRecipientSourceCapability:
|
||||||
return AddressesRecipientSourceCapability()
|
return AddressesRecipientSourceCapability()
|
||||||
|
|
||||||
|
|||||||
@@ -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_addresses.backend.capabilities import (
|
from govoplan_addresses.backend.capabilities import (
|
||||||
CAPABILITY_ADDRESSES_CONTACT_WRITER,
|
CAPABILITY_ADDRESSES_CONTACT_WRITER,
|
||||||
CAPABILITY_ADDRESSES_LOOKUP,
|
CAPABILITY_ADDRESSES_LOOKUP,
|
||||||
@@ -10,6 +13,7 @@ from govoplan_addresses.backend.capabilities import (
|
|||||||
from govoplan_addresses.backend.db import models as addresses_models # noqa: F401 - populate address ORM metadata
|
from govoplan_addresses.backend.db import models as addresses_models # noqa: F401 - populate address 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.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||||
|
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
@@ -25,6 +29,47 @@ from govoplan_core.core.modules import (
|
|||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
_addresses_table_retirement_provider = drop_table_retirement_provider(
|
||||||
|
addresses_models.AddressSyncDiagnostic,
|
||||||
|
addresses_models.AddressSyncConflict,
|
||||||
|
addresses_models.AddressSyncTombstone,
|
||||||
|
addresses_models.AddressSyncSource,
|
||||||
|
addresses_models.AddressListEntry,
|
||||||
|
addresses_models.AddressList,
|
||||||
|
addresses_models.ContactPostalAddress,
|
||||||
|
addresses_models.ContactPhone,
|
||||||
|
addresses_models.ContactEmail,
|
||||||
|
addresses_models.Contact,
|
||||||
|
addresses_models.AddressBook,
|
||||||
|
label="Addresses",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _addresses_retirement_provider(session: object | None, module_id: str):
|
||||||
|
plan = _addresses_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 Addresses credential retirement.")
|
||||||
|
if inspect(execute_session.get_bind()).has_table(addresses_models.AddressSyncSource.__tablename__):
|
||||||
|
from govoplan_addresses.backend.service import audit_address_credentials_for_retirement
|
||||||
|
|
||||||
|
audit_address_credentials_for_retirement(execute_session)
|
||||||
|
base_executor(execute_session, execute_module_id)
|
||||||
|
|
||||||
|
return replace(
|
||||||
|
plan,
|
||||||
|
destroy_data_warnings=(
|
||||||
|
*plan.destroy_data_warnings,
|
||||||
|
"Addresses-owned encrypted connector credentials are audited and deleted with the sync-source table.",
|
||||||
|
),
|
||||||
|
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(
|
||||||
@@ -104,11 +149,12 @@ def _addresses_router(_context: ModuleContext):
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="addresses",
|
id="addresses",
|
||||||
name="Addresses",
|
name="Addresses",
|
||||||
version="0.1.8",
|
version="0.1.9",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
optional_dependencies=("campaigns", "mail", "forms", "reporting", "portal", "postbox"),
|
optional_dependencies=("campaigns", "mail", "forms", "reporting", "portal", "postbox"),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"),
|
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"),
|
||||||
|
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"),
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.8"),
|
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.8"),
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"),
|
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"),
|
||||||
),
|
),
|
||||||
@@ -128,24 +174,12 @@ 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=_addresses_retirement_provider,
|
||||||
addresses_models.AddressSyncDiagnostic,
|
|
||||||
addresses_models.AddressSyncConflict,
|
|
||||||
addresses_models.AddressSyncTombstone,
|
|
||||||
addresses_models.AddressSyncSource,
|
|
||||||
addresses_models.AddressListEntry,
|
|
||||||
addresses_models.AddressList,
|
|
||||||
addresses_models.ContactPostalAddress,
|
|
||||||
addresses_models.ContactPhone,
|
|
||||||
addresses_models.ContactEmail,
|
|
||||||
addresses_models.Contact,
|
|
||||||
addresses_models.AddressBook,
|
|
||||||
label="Addresses",
|
|
||||||
),
|
|
||||||
retirement_notes="Destructive retirement drops address-owned database tables after the installer captures a database snapshot.",
|
retirement_notes="Destructive retirement drops address-owned database tables after the installer captures a database snapshot.",
|
||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_ADDRESSES_LOOKUP: lambda context: __import__("govoplan_addresses.backend.capabilities", fromlist=["lookup_capability"]).lookup_capability(context),
|
CAPABILITY_ADDRESSES_LOOKUP: lambda context: __import__("govoplan_addresses.backend.capabilities", fromlist=["lookup_capability"]).lookup_capability(context),
|
||||||
|
CAPABILITY_ADDRESSES_PEOPLE_SEARCH: lambda context: __import__("govoplan_addresses.backend.capabilities", fromlist=["people_search_capability"]).people_search_capability(context),
|
||||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE: lambda context: __import__(
|
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE: lambda context: __import__(
|
||||||
"govoplan_addresses.backend.capabilities",
|
"govoplan_addresses.backend.capabilities",
|
||||||
fromlist=["recipient_source_capability"],
|
fromlist=["recipient_source_capability"],
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from sqlalchemy import and_, false, func, or_
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.auth import ApiPrincipal
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.audit.logging import audit_event
|
||||||
from govoplan_core.core.change_sequence import record_change
|
from govoplan_core.core.change_sequence import record_change
|
||||||
from govoplan_core.db.base import utcnow
|
from govoplan_core.db.base import utcnow
|
||||||
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
||||||
@@ -352,9 +353,89 @@ def delete_sync_source(session: Session, principal: ApiPrincipal, sync_source_id
|
|||||||
book.sync_status = None
|
book.sync_status = None
|
||||||
book.sync_error = None
|
book.sync_error = None
|
||||||
book.updated_by_account_id = _account_id(principal)
|
book.updated_by_account_id = _account_id(principal)
|
||||||
|
_audit_sync_credential_deletion(session, principal, sync_source)
|
||||||
session.delete(sync_source)
|
session.delete(sync_source)
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_sync_credential_deletion(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
sync_source: AddressSyncSource,
|
||||||
|
) -> None:
|
||||||
|
"""Audit removal of source-owned credential material in the DB transaction.
|
||||||
|
|
||||||
|
CardDAV credentials are encrypted inside the sync-source row. Deleting that
|
||||||
|
row therefore deletes the credential atomically with the connector. Legacy
|
||||||
|
credential references are detached from the source, but are never resolved
|
||||||
|
or sent to an external provider because ownership cannot be proven.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tenant_id = _tenant_id_or_none(principal)
|
||||||
|
user = getattr(principal, "user", None)
|
||||||
|
_record_sync_credential_deletion_audit(
|
||||||
|
session,
|
||||||
|
sync_source=sync_source,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=getattr(user, "id", None),
|
||||||
|
api_key_id=getattr(principal, "api_key_id", None),
|
||||||
|
deletion_reason="sync_source_deleted",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_sync_credential_deletion_audit(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
sync_source: AddressSyncSource,
|
||||||
|
tenant_id: str | None,
|
||||||
|
user_id: str | None,
|
||||||
|
api_key_id: str | None,
|
||||||
|
deletion_reason: str,
|
||||||
|
) -> bool:
|
||||||
|
auth = _carddav_auth_metadata(sync_source.metadata_)
|
||||||
|
if auth.get("secret_encrypted"):
|
||||||
|
storage_backend = "encrypted_database"
|
||||||
|
elif auth.get("credential_ref"):
|
||||||
|
storage_backend = "legacy_reference"
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
api_key_id=api_key_id,
|
||||||
|
action="addresses.sync_credential_deleted",
|
||||||
|
scope="tenant" if tenant_id is not None else "system",
|
||||||
|
object_type="address_sync_credential",
|
||||||
|
object_id=sync_source.id,
|
||||||
|
details={
|
||||||
|
"sync_source_id": sync_source.id,
|
||||||
|
"connector_type": sync_source.connector_type,
|
||||||
|
"storage_backend": storage_backend,
|
||||||
|
"deletion_reason": deletion_reason,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def audit_address_credentials_for_retirement(session: Session) -> int:
|
||||||
|
"""Audit credentials that the ensuing destructive table drop deletes."""
|
||||||
|
|
||||||
|
sources = session.query(AddressSyncSource).order_by(AddressSyncSource.id.asc()).all()
|
||||||
|
audited = 0
|
||||||
|
for source in sources:
|
||||||
|
if _record_sync_credential_deletion_audit(
|
||||||
|
session,
|
||||||
|
sync_source=source,
|
||||||
|
tenant_id=source.tenant_id,
|
||||||
|
user_id=None,
|
||||||
|
api_key_id=None,
|
||||||
|
deletion_reason="module_data_retired",
|
||||||
|
):
|
||||||
|
audited += 1
|
||||||
|
session.flush()
|
||||||
|
return audited
|
||||||
|
|
||||||
|
|
||||||
def start_sync_attempt(session: Session, principal: ApiPrincipal, sync_source_id: str) -> AddressSyncSource:
|
def start_sync_attempt(session: Session, principal: ApiPrincipal, sync_source_id: str) -> AddressSyncSource:
|
||||||
sync_source = get_visible_sync_source(session, principal, sync_source_id)
|
sync_source = get_visible_sync_source(session, principal, sync_source_id)
|
||||||
if not sync_source.enabled:
|
if not sync_source.enabled:
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
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_core.core.change_sequence import ChangeSequenceEntry
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH, PeopleSearchProvider
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.db.base import utcnow
|
from govoplan_core.db.base import utcnow
|
||||||
from govoplan_addresses.backend.carddav import AddressCardDAVObject, AddressCardDAVReportResult, AddressCardDAVWriteResult
|
from govoplan_addresses.backend.carddav import AddressCardDAVObject, AddressCardDAVReportResult, AddressCardDAVWriteResult
|
||||||
@@ -16,6 +17,7 @@ from govoplan_addresses.backend.capabilities import (
|
|||||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
|
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
|
||||||
AddressesContactWriterCapability,
|
AddressesContactWriterCapability,
|
||||||
AddressesLookupCapability,
|
AddressesLookupCapability,
|
||||||
|
AddressesPeopleSearchProvider,
|
||||||
AddressesRecipientSourceCapability,
|
AddressesRecipientSourceCapability,
|
||||||
AddressWriterError,
|
AddressWriterError,
|
||||||
)
|
)
|
||||||
@@ -345,8 +347,17 @@ END:VCARD
|
|||||||
self.assertEqual(len(lookup), 1)
|
self.assertEqual(len(lookup), 1)
|
||||||
self.assertEqual(lookup[0].email, "ada@example.local")
|
self.assertEqual(lookup[0].email, "ada@example.local")
|
||||||
self.assertEqual(lookup[0].contact_id, contact.id)
|
self.assertEqual(lookup[0].contact_id, contact.id)
|
||||||
|
people_provider = AddressesPeopleSearchProvider()
|
||||||
|
self.assertIsInstance(people_provider, PeopleSearchProvider)
|
||||||
|
people = people_provider.search_people(self.session, self.principal, query="ada")
|
||||||
|
self.assertEqual(people[0].key, "contacts")
|
||||||
|
self.assertEqual(people[0].candidates[0].reference_id, contact.id)
|
||||||
|
self.assertEqual(people[0].candidates[0].source_ref, f"addresses:contact:{contact.id}")
|
||||||
|
self.assertEqual(people[0].candidates[0].metadata["address_book_id"], book.id)
|
||||||
warm_lookup = AddressesLookupCapability().lookup(self.session, self.principal, query="", limit=10)
|
warm_lookup = AddressesLookupCapability().lookup(self.session, self.principal, query="", limit=10)
|
||||||
self.assertEqual([item.email for item in warm_lookup], ["ada@example.local"])
|
self.assertEqual([item.email for item in warm_lookup], ["ada@example.local"])
|
||||||
|
self.assertIn(CAPABILITY_ADDRESSES_PEOPLE_SEARCH, manifest.capability_factories)
|
||||||
|
self.assertIn(CAPABILITY_ADDRESSES_PEOPLE_SEARCH, {item.name for item in manifest.provides_interfaces})
|
||||||
|
|
||||||
recipient_sources = AddressesRecipientSourceCapability().list_sources(self.session, self.principal)
|
recipient_sources = AddressesRecipientSourceCapability().list_sources(self.session, self.principal)
|
||||||
self.assertEqual(len(recipient_sources), 1)
|
self.assertEqual(len(recipient_sources), 1)
|
||||||
@@ -1051,6 +1062,107 @@ END:VCARD
|
|||||||
self.assertFalse(book.read_only)
|
self.assertFalse(book.read_only)
|
||||||
self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id])
|
self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id])
|
||||||
|
|
||||||
|
def test_delete_sync_source_deletes_and_audits_encrypted_credential_atomically(self) -> None:
|
||||||
|
book = create_address_book(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
AddressBookCreateRequest(scope_type="user", name="Audited remote"),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.refresh(book)
|
||||||
|
source = create_carddav_sync_source(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
book.id,
|
||||||
|
AddressCardDavSourceCreateRequest(
|
||||||
|
collection_url="https://carddav.example.local/addressbooks/audited/",
|
||||||
|
auth_type="basic",
|
||||||
|
username="ada",
|
||||||
|
password="do-not-audit-this",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
source_id = source.id
|
||||||
|
|
||||||
|
with patch("govoplan_addresses.backend.service.audit_event") as audit:
|
||||||
|
delete_sync_source(self.session, self.principal, source_id)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
self.assertIsNone(self.session.get(AddressSyncSource, source_id))
|
||||||
|
audit.assert_called_once()
|
||||||
|
audit_call = audit.call_args.kwargs
|
||||||
|
self.assertEqual(audit_call["action"], "addresses.sync_credential_deleted")
|
||||||
|
self.assertEqual(audit_call["object_id"], source_id)
|
||||||
|
self.assertEqual(audit_call["details"]["storage_backend"], "encrypted_database")
|
||||||
|
self.assertNotIn("credential_ref", audit_call["details"])
|
||||||
|
self.assertNotIn("secret_encrypted", audit_call["details"])
|
||||||
|
self.assertNotIn("do-not-audit-this", repr(audit_call))
|
||||||
|
|
||||||
|
def test_delete_sync_source_is_not_staged_when_credential_audit_fails(self) -> None:
|
||||||
|
book = create_address_book(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
AddressBookCreateRequest(scope_type="user", name="Audit failure"),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.refresh(book)
|
||||||
|
source = create_carddav_sync_source(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
book.id,
|
||||||
|
AddressCardDavSourceCreateRequest(
|
||||||
|
collection_url="https://carddav.example.local/addressbooks/audit-failure/",
|
||||||
|
auth_type="bearer",
|
||||||
|
bearer_token="do-not-audit-this",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_addresses.backend.service.audit_event",
|
||||||
|
side_effect=RuntimeError("audit unavailable"),
|
||||||
|
), self.assertRaisesRegex(RuntimeError, "audit unavailable"):
|
||||||
|
delete_sync_source(self.session, self.principal, source.id)
|
||||||
|
|
||||||
|
self.session.rollback()
|
||||||
|
persisted = self.session.get(AddressSyncSource, source.id)
|
||||||
|
self.assertIsNotNone(persisted)
|
||||||
|
self.assertIn("secret_encrypted", (persisted.metadata_ or {})["carddav"])
|
||||||
|
|
||||||
|
def test_destructive_module_retirement_audits_credentials_before_tables_are_dropped(self) -> None:
|
||||||
|
book = create_address_book(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
AddressBookCreateRequest(scope_type="user", name="Retired module"),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.refresh(book)
|
||||||
|
source = create_carddav_sync_source(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
book.id,
|
||||||
|
AddressCardDavSourceCreateRequest(
|
||||||
|
collection_url="https://carddav.example.local/addressbooks/module-retirement/",
|
||||||
|
auth_type="bearer",
|
||||||
|
bearer_token="do-not-audit-this",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
source_id = source.id
|
||||||
|
retirement_provider = manifest.migration_spec.retirement_provider
|
||||||
|
assert retirement_provider is not None
|
||||||
|
plan = retirement_provider(self.session, "addresses")
|
||||||
|
assert plan.destroy_data_executor is not None
|
||||||
|
|
||||||
|
with patch("govoplan_addresses.backend.service.audit_event") as audit:
|
||||||
|
plan.destroy_data_executor(self.session, "addresses")
|
||||||
|
|
||||||
|
audit.assert_called_once()
|
||||||
|
audit_call = audit.call_args.kwargs
|
||||||
|
self.assertEqual(audit_call["object_id"], source_id)
|
||||||
|
self.assertEqual(audit_call["details"]["deletion_reason"], "module_data_retired")
|
||||||
|
self.assertNotIn("do-not-audit-this", repr(audit_call))
|
||||||
|
self.assertFalse(inspect(self.engine).has_table("addresses_sync_sources"))
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/addresses-webui",
|
"name": "@govoplan/addresses-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"test:ui-structure": "node scripts/test-selection-list-structure.mjs"
|
"test:ui-structure": "node scripts/test-selection-list-structure.mjs"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.9",
|
"@govoplan/core-webui": "^0.1.11",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const translations = {
|
|||||||
export const addressesModule: PlatformWebModule = {
|
export const addressesModule: PlatformWebModule = {
|
||||||
id: "addresses",
|
id: "addresses",
|
||||||
label: "i18n:govoplan-addresses.address_book.f6327f59",
|
label: "i18n:govoplan-addresses.address_book.f6327f59",
|
||||||
version: "1.0.0",
|
version: "0.1.9",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
optionalDependencies: ["campaigns", "mail", "forms", "reporting", "portal", "postbox"],
|
optionalDependencies: ["campaigns", "mail", "forms", "reporting", "portal", "postbox"],
|
||||||
translations,
|
translations,
|
||||||
|
|||||||
Reference in New Issue
Block a user