feat(addresses): add governed DSAR coverage
This commit is contained in:
@@ -104,6 +104,17 @@ The module exposes core-mediated capabilities for:
|
|||||||
pickers.
|
pickers.
|
||||||
- `distribution.recipient_channel_facts`: current channel, governance, and
|
- `distribution.recipient_channel_facts`: current channel, governance, and
|
||||||
quality facts for distribution and Policy consumers.
|
quality facts for distribution and Policy consumers.
|
||||||
|
- `privacy.dsar.addresses`: tenant-bounded, minimized data-subject discovery
|
||||||
|
across contacts, contact points, list use, governance, provenance,
|
||||||
|
synchronization evidence, and operator attribution.
|
||||||
|
|
||||||
|
The DSAR provider accepts corroborated email/account selectors and namespaced
|
||||||
|
Addresses references. It does not export connector state, raw import or sync
|
||||||
|
payloads, opaque metadata, snapshot payloads, or merge before/after payloads.
|
||||||
|
Reusable contacts are never deleted automatically: shared/synchronized contact
|
||||||
|
changes require an authorized dependency review through the ordinary Addresses
|
||||||
|
workflows, while governance, quality, merge, sync, import, and attribution
|
||||||
|
evidence is retained with an explicit reason.
|
||||||
|
|
||||||
`addresses.recipient_source` returns:
|
`addresses.recipient_source` returns:
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,12 +11,21 @@ from govoplan_addresses.backend.capabilities import (
|
|||||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
|
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
|
||||||
)
|
)
|
||||||
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 (
|
||||||
from govoplan_core.core.contact_points import CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.contact_points import (
|
||||||
|
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
|
||||||
|
)
|
||||||
|
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.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH
|
||||||
from govoplan_core.core.distribution_lists import CAPABILITY_RECIPIENT_CHANNEL_FACTS
|
from govoplan_core.core.distribution_lists import CAPABILITY_RECIPIENT_CHANNEL_FACTS
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
FrontendRoute,
|
FrontendRoute,
|
||||||
@@ -38,6 +47,7 @@ from govoplan_core.core.provider_governance import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_addresses.backend.dsar_provider import ADDRESSES_DSAR_CAPABILITY
|
||||||
from govoplan_addresses.backend.provider_state import (
|
from govoplan_addresses.backend.provider_state import (
|
||||||
CARDDAV_PROVIDER_ID,
|
CARDDAV_PROVIDER_ID,
|
||||||
LDAP_PROVIDER_ID,
|
LDAP_PROVIDER_ID,
|
||||||
@@ -46,6 +56,13 @@ from govoplan_addresses.backend.provider_state import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _addresses_dsar_provider(context: ModuleContext) -> object:
|
||||||
|
del context
|
||||||
|
from govoplan_addresses.backend.dsar_provider import AddressesDsarProvider
|
||||||
|
|
||||||
|
return AddressesDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
_addresses_table_retirement_provider = drop_table_retirement_provider(
|
_addresses_table_retirement_provider = drop_table_retirement_provider(
|
||||||
addresses_models.AddressImportRun,
|
addresses_models.AddressImportRun,
|
||||||
addresses_models.AddressImportProfile,
|
addresses_models.AddressImportProfile,
|
||||||
@@ -77,10 +94,18 @@ def _addresses_retirement_provider(session: object | None, module_id: str):
|
|||||||
return plan
|
return plan
|
||||||
|
|
||||||
def executor(execute_session: object, execute_module_id: str) -> None:
|
def executor(execute_session: object, execute_module_id: str) -> None:
|
||||||
if not hasattr(execute_session, "get_bind") or not hasattr(execute_session, "query"):
|
if not hasattr(execute_session, "get_bind") or not hasattr(
|
||||||
raise RuntimeError("No database session is available for Addresses credential retirement.")
|
execute_session, "query"
|
||||||
if inspect(execute_session.get_bind()).has_table(addresses_models.AddressSyncSource.__tablename__):
|
):
|
||||||
from govoplan_addresses.backend.service import audit_address_credentials_for_retirement
|
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)
|
audit_address_credentials_for_retirement(execute_session)
|
||||||
base_executor(execute_session, execute_module_id)
|
base_executor(execute_session, execute_module_id)
|
||||||
@@ -110,21 +135,77 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
|||||||
|
|
||||||
|
|
||||||
PERMISSIONS = (
|
PERMISSIONS = (
|
||||||
_permission("addresses:address_book:read", "View address books", "List address books visible to the current principal."),
|
_permission(
|
||||||
_permission("addresses:address_book:write", "Manage address books", "Create and edit local address books."),
|
"addresses:address_book:read",
|
||||||
_permission("addresses:address_book:delete", "Delete address books", "Soft-delete local address books."),
|
"View address books",
|
||||||
_permission("addresses:address_book:admin", "Administer address books", "Manage system-scoped address books and future sync sources."),
|
"List address books visible to the current principal.",
|
||||||
_permission("addresses:address_list:read", "View address lists", "List reusable address lists and their entries."),
|
),
|
||||||
_permission("addresses:address_list:write", "Manage address lists", "Create and edit reusable address lists."),
|
_permission(
|
||||||
_permission("addresses:address_list:delete", "Delete address lists", "Soft-delete reusable address lists."),
|
"addresses:address_book:write",
|
||||||
_permission("addresses:contact:read", "View contacts", "List and lookup contacts in visible address books."),
|
"Manage address books",
|
||||||
_permission("addresses:contact:write", "Manage contacts", "Create and edit local contacts."),
|
"Create and edit local address books.",
|
||||||
_permission("addresses:contact:delete", "Delete contacts", "Soft-delete local contacts."),
|
),
|
||||||
_permission("addresses:governance:read", "View communication governance", "Inspect effective-dated consent, suppression, and channel-preference facts."),
|
_permission(
|
||||||
_permission("addresses:governance:write", "Manage communication governance", "Record and end consent, suppression, and channel-preference facts."),
|
"addresses:address_book:delete",
|
||||||
_permission("addresses:sync:read", "View address sync", "Inspect address sync sources, conflicts, tombstones, and diagnostics."),
|
"Delete address books",
|
||||||
_permission("addresses:sync:write", "Manage address sync", "Bind address books to external sources and record sync state."),
|
"Soft-delete local address books.",
|
||||||
_permission("addresses:sync:admin", "Administer address sync", "Administer address sync connectors and future destructive sync operations."),
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:address_book:admin",
|
||||||
|
"Administer address books",
|
||||||
|
"Manage system-scoped address books and future sync sources.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:address_list:read",
|
||||||
|
"View address lists",
|
||||||
|
"List reusable address lists and their entries.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:address_list:write",
|
||||||
|
"Manage address lists",
|
||||||
|
"Create and edit reusable address lists.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:address_list:delete",
|
||||||
|
"Delete address lists",
|
||||||
|
"Soft-delete reusable address lists.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:contact:read",
|
||||||
|
"View contacts",
|
||||||
|
"List and lookup contacts in visible address books.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:contact:write", "Manage contacts", "Create and edit local contacts."
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:contact:delete", "Delete contacts", "Soft-delete local contacts."
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:governance:read",
|
||||||
|
"View communication governance",
|
||||||
|
"Inspect effective-dated consent, suppression, and channel-preference facts.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:governance:write",
|
||||||
|
"Manage communication governance",
|
||||||
|
"Record and end consent, suppression, and channel-preference facts.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:sync:read",
|
||||||
|
"View address sync",
|
||||||
|
"Inspect address sync sources, conflicts, tombstones, and diagnostics.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:sync:write",
|
||||||
|
"Manage address sync",
|
||||||
|
"Bind address books to external sources and record sync state.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:sync:admin",
|
||||||
|
"Administer address sync",
|
||||||
|
"Administer address sync connectors and future destructive sync operations.",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -153,7 +234,13 @@ ROLE_TEMPLATES = (
|
|||||||
slug="address_book_reader",
|
slug="address_book_reader",
|
||||||
name="Address book reader",
|
name="Address book reader",
|
||||||
description="Read visible address books and contacts.",
|
description="Read visible address books and contacts.",
|
||||||
permissions=("addresses:address_book:read", "addresses:address_list:read", "addresses:contact:read", "addresses:governance:read", "addresses:sync:read"),
|
permissions=(
|
||||||
|
"addresses:address_book:read",
|
||||||
|
"addresses:address_list:read",
|
||||||
|
"addresses:contact:read",
|
||||||
|
"addresses:governance:read",
|
||||||
|
"addresses:sync:read",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -172,15 +259,42 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"address_books": session.query(AddressBook).filter(AddressBook.tenant_id == tenant_id, AddressBook.deleted_at.is_(None)).count(),
|
"address_books": session.query(AddressBook)
|
||||||
"address_lists": session.query(AddressList).filter(AddressList.tenant_id == tenant_id, AddressList.deleted_at.is_(None)).count(),
|
.filter(AddressBook.tenant_id == tenant_id, AddressBook.deleted_at.is_(None))
|
||||||
"contacts": session.query(Contact).filter(Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None)).count(),
|
.count(),
|
||||||
"active_contact_merges": session.query(ContactMergeRecord).filter(ContactMergeRecord.tenant_id == tenant_id, ContactMergeRecord.status == "active").count(),
|
"address_lists": session.query(AddressList)
|
||||||
"contact_quality_decisions": session.query(ContactPointQualityDecision).filter(ContactPointQualityDecision.tenant_id == tenant_id).count(),
|
.filter(AddressList.tenant_id == tenant_id, AddressList.deleted_at.is_(None))
|
||||||
"contact_point_snapshots": session.query(ContactPointSnapshot).filter(ContactPointSnapshot.tenant_id == tenant_id).count(),
|
.count(),
|
||||||
"sync_sources": session.query(AddressSyncSource).filter(AddressSyncSource.tenant_id == tenant_id, AddressSyncSource.enabled.is_(True)).count(),
|
"contacts": session.query(Contact)
|
||||||
"address_import_profiles": session.query(AddressImportProfile).filter(AddressImportProfile.tenant_id == tenant_id, AddressImportProfile.is_current.is_(True)).count(),
|
.filter(Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None))
|
||||||
"address_import_runs": session.query(AddressImportRun).filter(AddressImportRun.tenant_id == tenant_id).count(),
|
.count(),
|
||||||
|
"active_contact_merges": session.query(ContactMergeRecord)
|
||||||
|
.filter(
|
||||||
|
ContactMergeRecord.tenant_id == tenant_id,
|
||||||
|
ContactMergeRecord.status == "active",
|
||||||
|
)
|
||||||
|
.count(),
|
||||||
|
"contact_quality_decisions": session.query(ContactPointQualityDecision)
|
||||||
|
.filter(ContactPointQualityDecision.tenant_id == tenant_id)
|
||||||
|
.count(),
|
||||||
|
"contact_point_snapshots": session.query(ContactPointSnapshot)
|
||||||
|
.filter(ContactPointSnapshot.tenant_id == tenant_id)
|
||||||
|
.count(),
|
||||||
|
"sync_sources": session.query(AddressSyncSource)
|
||||||
|
.filter(
|
||||||
|
AddressSyncSource.tenant_id == tenant_id,
|
||||||
|
AddressSyncSource.enabled.is_(True),
|
||||||
|
)
|
||||||
|
.count(),
|
||||||
|
"address_import_profiles": session.query(AddressImportProfile)
|
||||||
|
.filter(
|
||||||
|
AddressImportProfile.tenant_id == tenant_id,
|
||||||
|
AddressImportProfile.is_current.is_(True),
|
||||||
|
)
|
||||||
|
.count(),
|
||||||
|
"address_import_runs": session.query(AddressImportRun)
|
||||||
|
.filter(AddressImportRun.tenant_id == tenant_id)
|
||||||
|
.count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -200,13 +314,28 @@ CARDDAV_PROVIDER = ExternalProviderDeclaration(
|
|||||||
ProviderObjectDeclaration(
|
ProviderObjectDeclaration(
|
||||||
object_type="address_book",
|
object_type="address_book",
|
||||||
field_groups=("identity", "display", "sync_state"),
|
field_groups=("identity", "display", "sync_state"),
|
||||||
authority_modes=("external_authoritative", "external_mirror", "governed_sync"),
|
authority_modes=(
|
||||||
|
"external_authoritative",
|
||||||
|
"external_mirror",
|
||||||
|
"governed_sync",
|
||||||
|
),
|
||||||
default_authority_mode="external_mirror",
|
default_authority_mode="external_mirror",
|
||||||
),
|
),
|
||||||
ProviderObjectDeclaration(
|
ProviderObjectDeclaration(
|
||||||
object_type="contact",
|
object_type="contact",
|
||||||
field_groups=("identity", "name", "postal", "email", "phone", "source_metadata"),
|
field_groups=(
|
||||||
authority_modes=("external_authoritative", "external_mirror", "governed_sync"),
|
"identity",
|
||||||
|
"name",
|
||||||
|
"postal",
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"source_metadata",
|
||||||
|
),
|
||||||
|
authority_modes=(
|
||||||
|
"external_authoritative",
|
||||||
|
"external_mirror",
|
||||||
|
"governed_sync",
|
||||||
|
),
|
||||||
default_authority_mode="governed_sync",
|
default_authority_mode="governed_sync",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -252,7 +381,15 @@ LDAP_PROVIDER = ExternalProviderDeclaration(
|
|||||||
objects=(
|
objects=(
|
||||||
ProviderObjectDeclaration(
|
ProviderObjectDeclaration(
|
||||||
object_type="contact",
|
object_type="contact",
|
||||||
field_groups=("identity", "name", "organization", "postal", "email", "phone", "source_metadata"),
|
field_groups=(
|
||||||
|
"identity",
|
||||||
|
"name",
|
||||||
|
"organization",
|
||||||
|
"postal",
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"source_metadata",
|
||||||
|
),
|
||||||
authority_modes=("external_authoritative", "external_mirror"),
|
authority_modes=("external_authoritative", "external_mirror"),
|
||||||
default_authority_mode="external_authoritative",
|
default_authority_mode="external_authoritative",
|
||||||
),
|
),
|
||||||
@@ -281,7 +418,11 @@ LDAP_PROVIDER = ExternalProviderDeclaration(
|
|||||||
reconciliation="Only a complete paged search may infer an absent source object and create a local tombstone.",
|
reconciliation="Only a complete paged search may infer an absent source object and create a local tombstone.",
|
||||||
outage="Existing contacts remain available and visibly stale; an unavailable directory never causes deletes.",
|
outage="Existing contacts remain available and visibly stale; an unavailable directory never causes deletes.",
|
||||||
classifications=("personal", "confidential", "restricted"),
|
classifications=("personal", "confidential", "restricted"),
|
||||||
purposes=("directory projection", "recipient resolution", "identity-linked contact discovery"),
|
purposes=(
|
||||||
|
"directory projection",
|
||||||
|
"recipient resolution",
|
||||||
|
"identity-linked contact discovery",
|
||||||
|
),
|
||||||
retention="Address, audit, and records policies govern local projections and tombstone evidence.",
|
retention="Address, audit, and records policies govern local projections and tombstone evidence.",
|
||||||
secret_handling="Bind secrets remain in reusable credential envelopes; URLs, previews, and diagnostics contain no credentials.",
|
secret_handling="Bind secrets remain in reusable credential envelopes; URLs, previews, and diagnostics contain no credentials.",
|
||||||
),
|
),
|
||||||
@@ -294,26 +435,71 @@ manifest = ModuleManifest(
|
|||||||
id="addresses",
|
id="addresses",
|
||||||
name="Addresses",
|
name="Addresses",
|
||||||
version="0.1.18",
|
version="0.1.18",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(
|
||||||
optional_dependencies=("campaigns", "mail", "forms", "reporting", "portal", "postbox", "connectors"),
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
),
|
||||||
|
optional_dependencies=(
|
||||||
|
"campaigns",
|
||||||
|
"mail",
|
||||||
|
"forms",
|
||||||
|
"reporting",
|
||||||
|
"portal",
|
||||||
|
"postbox",
|
||||||
|
"connectors",
|
||||||
|
),
|
||||||
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(
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.9"),
|
name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, version="1.0.0"),
|
),
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"),
|
ModuleInterfaceProvider(
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_RECIPIENT_CHANNEL_FACTS, version="0.1.0"),
|
name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.9"
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, version="1.0.0"
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=CAPABILITY_RECIPIENT_CHANNEL_FACTS, version="0.1.0"
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(name=ADDRESSES_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
route_factory=_addresses_router,
|
route_factory=_addresses_router,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
nav_items=(NavItem(path="/address-book", label="Address Book", icon="book-user", required_any=("addresses:contact:read",), order=80),),
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/address-book",
|
||||||
|
label="Address Book",
|
||||||
|
icon="book-user",
|
||||||
|
required_any=("addresses:contact:read",),
|
||||||
|
order=80,
|
||||||
|
),
|
||||||
|
),
|
||||||
frontend=FrontendModule(
|
frontend=FrontendModule(
|
||||||
module_id="addresses",
|
module_id="addresses",
|
||||||
package_name="@govoplan/addresses-webui",
|
package_name="@govoplan/addresses-webui",
|
||||||
routes=(FrontendRoute(path="/address-book", component="AddressBookPage", required_any=("addresses:contact:read",), order=80),),
|
routes=(
|
||||||
nav_items=(NavItem(path="/address-book", label="Address Book", icon="book-user", required_any=("addresses:contact:read",), order=80),),
|
FrontendRoute(
|
||||||
|
path="/address-book",
|
||||||
|
component="AddressBookPage",
|
||||||
|
required_any=("addresses:contact:read",),
|
||||||
|
order=80,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/address-book",
|
||||||
|
label="Address Book",
|
||||||
|
icon="book-user",
|
||||||
|
required_any=("addresses:contact:read",),
|
||||||
|
order=80,
|
||||||
|
),
|
||||||
|
),
|
||||||
product_areas=(
|
product_areas=(
|
||||||
ProductAreaContribution(
|
ProductAreaContribution(
|
||||||
id="people-responsibility",
|
id="people-responsibility",
|
||||||
@@ -321,17 +507,56 @@ manifest = ModuleManifest(
|
|||||||
label="i18n:govoplan-core.product_area.people_responsibility",
|
label="i18n:govoplan-core.product_area.people_responsibility",
|
||||||
icon="users",
|
icon="users",
|
||||||
description="i18n:govoplan-core.product_area.people_responsibility_description",
|
description="i18n:govoplan-core.product_area.people_responsibility_description",
|
||||||
surface_ids=("addresses.nav.address.book", "addresses.route.address.book"),
|
surface_ids=(
|
||||||
|
"addresses.nav.address.book",
|
||||||
|
"addresses.route.address.book",
|
||||||
|
),
|
||||||
order=70,
|
order=70,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
view_surfaces=(
|
view_surfaces=(
|
||||||
ViewSurface(id="addresses.page", module_id="addresses", kind="route", label="Address Book", order=80),
|
ViewSurface(
|
||||||
ViewSurface(id="addresses.sources", module_id="addresses", kind="section", label="Address sources", order=10),
|
id="addresses.page",
|
||||||
ViewSurface(id="addresses.contacts", module_id="addresses", kind="section", label="Contacts", order=20),
|
module_id="addresses",
|
||||||
ViewSurface(id="addresses.detail", module_id="addresses", kind="section", label="Contact detail", order=30),
|
kind="route",
|
||||||
ViewSurface(id="addresses.governance", module_id="addresses", kind="action", label="Communication governance", order=40),
|
label="Address Book",
|
||||||
ViewSurface(id="addresses.sync", module_id="addresses", kind="action", label="Address synchronization", order=50),
|
order=80,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="addresses.sources",
|
||||||
|
module_id="addresses",
|
||||||
|
kind="section",
|
||||||
|
label="Address sources",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="addresses.contacts",
|
||||||
|
module_id="addresses",
|
||||||
|
kind="section",
|
||||||
|
label="Contacts",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="addresses.detail",
|
||||||
|
module_id="addresses",
|
||||||
|
kind="section",
|
||||||
|
label="Contact detail",
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="addresses.governance",
|
||||||
|
module_id="addresses",
|
||||||
|
kind="action",
|
||||||
|
label="Communication governance",
|
||||||
|
order=40,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="addresses.sync",
|
||||||
|
module_id="addresses",
|
||||||
|
kind="action",
|
||||||
|
label="Address synchronization",
|
||||||
|
order=50,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
@@ -343,8 +568,13 @@ manifest = ModuleManifest(
|
|||||||
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__(
|
||||||
CAPABILITY_ADDRESSES_PEOPLE_SEARCH: lambda context: __import__("govoplan_addresses.backend.capabilities", fromlist=["people_search_capability"]).people_search_capability(context),
|
"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"],
|
||||||
@@ -361,6 +591,20 @@ manifest = ModuleManifest(
|
|||||||
"govoplan_addresses.backend.capabilities",
|
"govoplan_addresses.backend.capabilities",
|
||||||
fromlist=["contact_point_resolution_capability"],
|
fromlist=["contact_point_resolution_capability"],
|
||||||
).contact_point_resolution_capability(context),
|
).contact_point_resolution_capability(context),
|
||||||
|
ADDRESSES_DSAR_CAPABILITY: _addresses_dsar_provider,
|
||||||
|
},
|
||||||
|
capability_documentation={
|
||||||
|
ADDRESSES_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Addresses data-subject request provider",
|
||||||
|
summary=(
|
||||||
|
"Finds bounded contact, contact-point, address-list, governance, "
|
||||||
|
"provenance, synchronization, and operator-attribution data without "
|
||||||
|
"exporting raw source payloads, connector state, or opaque evidence."
|
||||||
|
),
|
||||||
|
contract_version="0.1.0",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("privacy_officer", "addresses_admin", "records_manager"),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
uninstall_guard_providers=(
|
uninstall_guard_providers=(
|
||||||
persistent_table_uninstall_guard(
|
persistent_table_uninstall_guard(
|
||||||
@@ -387,6 +631,57 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="addresses.privacy.data-subject-requests",
|
||||||
|
title="Review Addresses data in a data-subject request",
|
||||||
|
summary=(
|
||||||
|
"Collect tenant-scoped contact data while preserving shared address, "
|
||||||
|
"recipient, synchronization, and provenance evidence."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"Addresses searches corroborated email and account selectors plus "
|
||||||
|
"namespaced contact and contact-point references. A matching contact "
|
||||||
|
"exports bounded identity, email, telephone, and postal values together "
|
||||||
|
"with its address-list use and minimized governance, quality, provenance, "
|
||||||
|
"merge, redirect, and synchronization evidence. Account matches add only "
|
||||||
|
"minimized operator attribution for governed configuration and evidence. "
|
||||||
|
"The provider excludes raw imported or synchronized source payloads, "
|
||||||
|
"connector tokens and revisions that could act as credentials, opaque "
|
||||||
|
"metadata, snapshot request and resolution payloads, import plans, merge "
|
||||||
|
"before/after payloads, unrelated contacts, and other tenants. Quality, "
|
||||||
|
"governance, provenance, merge, redirect, synchronization, import, "
|
||||||
|
"snapshot, and operator evidence is retained with an explicit reason. "
|
||||||
|
"Because reusable contacts can be shared, synchronized, merged, or "
|
||||||
|
"referenced by immutable recipient snapshots, the DSAR provider never "
|
||||||
|
"deletes them automatically. An authorized operator must review "
|
||||||
|
"dependencies and use the normal Addresses correction, archive, source, "
|
||||||
|
"merge, or governance workflow."
|
||||||
|
),
|
||||||
|
layer="static",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=(
|
||||||
|
"privacy_officer",
|
||||||
|
"addresses_admin",
|
||||||
|
"records_manager",
|
||||||
|
"operator",
|
||||||
|
),
|
||||||
|
related_modules=(
|
||||||
|
"access",
|
||||||
|
"audit",
|
||||||
|
"campaigns",
|
||||||
|
"dist_lists",
|
||||||
|
"records",
|
||||||
|
),
|
||||||
|
order=29,
|
||||||
|
metadata={
|
||||||
|
"seed": True,
|
||||||
|
"help_contexts": [
|
||||||
|
"addresses.contacts",
|
||||||
|
"addresses.governance",
|
||||||
|
"addresses.action.archive",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="addresses.boundary",
|
id="addresses.boundary",
|
||||||
title="Reusable address ownership",
|
title="Reusable address ownership",
|
||||||
@@ -399,7 +694,14 @@ manifest = ModuleManifest(
|
|||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("tenant_admin", "operator", "module_admin"),
|
audience=("tenant_admin", "operator", "module_admin"),
|
||||||
related_modules=("campaigns", "mail", "forms", "reporting", "portal", "postbox"),
|
related_modules=(
|
||||||
|
"campaigns",
|
||||||
|
"mail",
|
||||||
|
"forms",
|
||||||
|
"reporting",
|
||||||
|
"portal",
|
||||||
|
"postbox",
|
||||||
|
),
|
||||||
order=30,
|
order=30,
|
||||||
metadata={
|
metadata={
|
||||||
"seed": True,
|
"seed": True,
|
||||||
@@ -482,7 +784,11 @@ manifest = ModuleManifest(
|
|||||||
order=34,
|
order=34,
|
||||||
metadata={
|
metadata={
|
||||||
"seed": True,
|
"seed": True,
|
||||||
"help_contexts": ["addresses.action.import", "addresses.contacts", "addresses.sources"],
|
"help_contexts": [
|
||||||
|
"addresses.action.import",
|
||||||
|
"addresses.contacts",
|
||||||
|
"addresses.sources",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
@@ -537,7 +843,14 @@ manifest = ModuleManifest(
|
|||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("tenant_admin", "operator", "module_admin", "power_user"),
|
audience=("tenant_admin", "operator", "module_admin", "power_user"),
|
||||||
related_modules=("dist_lists", "connectors", "datasources", "campaigns", "policy", "audit"),
|
related_modules=(
|
||||||
|
"dist_lists",
|
||||||
|
"connectors",
|
||||||
|
"datasources",
|
||||||
|
"campaigns",
|
||||||
|
"policy",
|
||||||
|
"audit",
|
||||||
|
),
|
||||||
order=36,
|
order=36,
|
||||||
metadata={
|
metadata={
|
||||||
"seed": True,
|
"seed": True,
|
||||||
@@ -579,15 +892,27 @@ manifest = ModuleManifest(
|
|||||||
maturity="vertical_slice",
|
maturity="vertical_slice",
|
||||||
documentation_ref="docs/ADDRESS_MODULE_ARCHITECTURE.md",
|
documentation_ref="docs/ADDRESS_MODULE_ARCHITECTURE.md",
|
||||||
test_ref="tests/test_addresses_service.py",
|
test_ref="tests/test_addresses_service.py",
|
||||||
known_limits=("External address-book synchronization remains a bounded connector slice rather than a supported provider profile.",),
|
known_limits=(
|
||||||
|
"External address-book synchronization remains a bounded connector slice rather than a supported provider profile.",
|
||||||
|
),
|
||||||
supported_authority_modes=(
|
supported_authority_modes=(
|
||||||
"native_authoritative",
|
"native_authoritative",
|
||||||
"external_authoritative",
|
"external_authoritative",
|
||||||
"external_mirror",
|
"external_mirror",
|
||||||
"governed_sync",
|
"governed_sync",
|
||||||
),
|
),
|
||||||
owned_concepts=("contact point", "address book", "contact consent", "recipient source"),
|
owned_concepts=(
|
||||||
non_owned_concepts=("identity", "organization", "campaign recipient snapshot", "procedure party"),
|
"contact point",
|
||||||
|
"address book",
|
||||||
|
"contact consent",
|
||||||
|
"recipient source",
|
||||||
|
),
|
||||||
|
non_owned_concepts=(
|
||||||
|
"identity",
|
||||||
|
"organization",
|
||||||
|
"campaign recipient snapshot",
|
||||||
|
"procedure party",
|
||||||
|
),
|
||||||
target_tested_providers=(CARDDAV_PROVIDER_ID,),
|
target_tested_providers=(CARDDAV_PROVIDER_ID,),
|
||||||
security_docs=("docs/ADDRESS_MODULE_ARCHITECTURE.md",),
|
security_docs=("docs/ADDRESS_MODULE_ARCHITECTURE.md",),
|
||||||
operations_docs=("README.md",),
|
operations_docs=("README.md",),
|
||||||
|
|||||||
@@ -0,0 +1,588 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import Account, User
|
||||||
|
from govoplan_addresses.backend.db.models import (
|
||||||
|
AddressBook,
|
||||||
|
AddressImportRun,
|
||||||
|
AddressList,
|
||||||
|
AddressListEntry,
|
||||||
|
AddressSyncConflict,
|
||||||
|
AddressSyncSource,
|
||||||
|
AddressSyncTombstone,
|
||||||
|
Contact,
|
||||||
|
ContactChannelRule,
|
||||||
|
ContactEmail,
|
||||||
|
ContactFieldProvenance,
|
||||||
|
ContactMergeRecord,
|
||||||
|
ContactPhone,
|
||||||
|
ContactPointQualityDecision,
|
||||||
|
ContactPostalAddress,
|
||||||
|
ContactRedirect,
|
||||||
|
)
|
||||||
|
from govoplan_addresses.backend.dsar_provider import (
|
||||||
|
ADDRESSES_DSAR_CAPABILITY,
|
||||||
|
AddressesDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_addresses.backend.manifest import manifest
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarProvider,
|
||||||
|
DsarSubjectRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.privacy.dsar_workflow import (
|
||||||
|
create_data_subject_request,
|
||||||
|
search_data_subject_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
provider: AddressesDsarProvider,
|
||||||
|
*,
|
||||||
|
addresses_active: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
self.addresses_active = addresses_active
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (ADDRESSES_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
self._assert_capability(name)
|
||||||
|
return "addresses"
|
||||||
|
|
||||||
|
def tenant_entitlement_resolver(self):
|
||||||
|
addresses_active = self.addresses_active
|
||||||
|
|
||||||
|
class _Resolver:
|
||||||
|
@staticmethod
|
||||||
|
def resolve(session, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return type(
|
||||||
|
"State",
|
||||||
|
(),
|
||||||
|
{"effective_modules": (("addresses",) if addresses_active else ())},
|
||||||
|
)()
|
||||||
|
|
||||||
|
return _Resolver()
|
||||||
|
|
||||||
|
def require_tenant_capability(self, name, session, **kwargs):
|
||||||
|
del session, kwargs
|
||||||
|
self._assert_capability(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (type("Manifest", (), {"id": "addresses"})(),)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_capability(name: str) -> None:
|
||||||
|
if name != ADDRESSES_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressesDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:", future=True)
|
||||||
|
Base.metadata.create_all(bind=self.engine)
|
||||||
|
self.session = sessionmaker(bind=self.engine, future=True)()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
self.account = Account(
|
||||||
|
id="account-1",
|
||||||
|
email="subject@example.test",
|
||||||
|
normalized_email="subject@example.test",
|
||||||
|
display_name="Subject",
|
||||||
|
password_hash="password-secret-do-not-export",
|
||||||
|
)
|
||||||
|
self.user = User(
|
||||||
|
id="membership-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=self.account.id,
|
||||||
|
email=self.account.email,
|
||||||
|
display_name="Subject",
|
||||||
|
)
|
||||||
|
self.book = AddressBook(
|
||||||
|
id="book-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
name="Residents",
|
||||||
|
created_by_account_id=self.account.id,
|
||||||
|
metadata_={"secret": "book-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
self.contact = Contact(
|
||||||
|
id="contact-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
address_book_id=self.book.id,
|
||||||
|
display_name="Subject Person",
|
||||||
|
given_name="Subject",
|
||||||
|
family_name="Person",
|
||||||
|
organization="Example household",
|
||||||
|
note="A bounded subject note",
|
||||||
|
tags=["resident"],
|
||||||
|
source_kind="carddav",
|
||||||
|
source_ref="https://source.invalid/private/contact.vcf",
|
||||||
|
source_payload_kind="vcard",
|
||||||
|
source_payload_raw="raw-source-payload-do-not-export",
|
||||||
|
source_revision="revision-7",
|
||||||
|
provenance={"secret": "contact-provenance-do-not-export"},
|
||||||
|
created_by_account_id=self.account.id,
|
||||||
|
metadata_={"secret": "contact-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
self.email = ContactEmail(
|
||||||
|
id="email-1",
|
||||||
|
contact_id=self.contact.id,
|
||||||
|
label="private",
|
||||||
|
email="Subject@Example.test",
|
||||||
|
original_email="Subject@Example.test",
|
||||||
|
normalized_email="subject@example.test",
|
||||||
|
provenance={"secret": "email-provenance-do-not-export"},
|
||||||
|
is_primary=True,
|
||||||
|
)
|
||||||
|
self.phone = ContactPhone(
|
||||||
|
id="phone-1",
|
||||||
|
contact_id=self.contact.id,
|
||||||
|
label="mobile",
|
||||||
|
phone="+49 30 123456",
|
||||||
|
original_phone="030 123456",
|
||||||
|
normalized_phone="+4930123456",
|
||||||
|
provenance={"secret": "phone-provenance-do-not-export"},
|
||||||
|
is_primary=True,
|
||||||
|
)
|
||||||
|
self.postal = ContactPostalAddress(
|
||||||
|
id="postal-1",
|
||||||
|
contact_id=self.contact.id,
|
||||||
|
label="home",
|
||||||
|
street="Example Street 1",
|
||||||
|
postal_code="10115",
|
||||||
|
locality="Berlin",
|
||||||
|
country="DE",
|
||||||
|
original_value={"secret": "postal-original-do-not-export"},
|
||||||
|
normalized_value={"secret": "postal-normalized-do-not-export"},
|
||||||
|
provenance={"secret": "postal-provenance-do-not-export"},
|
||||||
|
is_primary=True,
|
||||||
|
)
|
||||||
|
self.address_list = AddressList(
|
||||||
|
id="list-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
address_book_id=self.book.id,
|
||||||
|
name="District residents",
|
||||||
|
created_by_account_id="another-account",
|
||||||
|
)
|
||||||
|
self.list_entry = AddressListEntry(
|
||||||
|
id="entry-1",
|
||||||
|
address_list_id=self.address_list.id,
|
||||||
|
contact_id=self.contact.id,
|
||||||
|
contact_email_id=self.email.id,
|
||||||
|
target_kind="email",
|
||||||
|
metadata_={"secret": "list-entry-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
self.channel_rule = ContactChannelRule(
|
||||||
|
id="rule-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
contact_id=self.contact.id,
|
||||||
|
channel="email",
|
||||||
|
purpose="resident-notice",
|
||||||
|
contact_point_id=self.email.id,
|
||||||
|
decision="allow",
|
||||||
|
legal_basis="public task",
|
||||||
|
evidence_ref="records://consent/evidence-1",
|
||||||
|
reason="Current resident preference",
|
||||||
|
effective_from=now,
|
||||||
|
created_by_account_id="another-account",
|
||||||
|
metadata_={"secret": "rule-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
self.quality = ContactPointQualityDecision(
|
||||||
|
id="quality-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
contact_id=self.contact.id,
|
||||||
|
channel="email",
|
||||||
|
contact_point_id=self.email.id,
|
||||||
|
state="valid",
|
||||||
|
reason_code="verified",
|
||||||
|
reason="Verified by operator",
|
||||||
|
evidence_ref="files://private/evidence",
|
||||||
|
effective_from=now,
|
||||||
|
created_by_account_id="another-account",
|
||||||
|
metadata_={"secret": "quality-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
self.provenance = ContactFieldProvenance(
|
||||||
|
id="provenance-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
contact_id=self.contact.id,
|
||||||
|
field_path="emails[0].email",
|
||||||
|
value={"secret": "field-value-do-not-export"},
|
||||||
|
source_kind="carddav",
|
||||||
|
source_ref="https://source.invalid/private",
|
||||||
|
source_revision="revision-7",
|
||||||
|
precedence=10,
|
||||||
|
selected=True,
|
||||||
|
reason_code="source_authority",
|
||||||
|
explanation="Selected from the authoritative source",
|
||||||
|
visibility="operator",
|
||||||
|
created_by_account_id="another-account",
|
||||||
|
metadata_={"secret": "field-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
self.sync_source = AddressSyncSource(
|
||||||
|
id="source-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
address_book_id=self.book.id,
|
||||||
|
connector_type="carddav",
|
||||||
|
display_name="Residents CardDAV",
|
||||||
|
external_account_ref="private-account-ref-do-not-export",
|
||||||
|
external_address_book_ref="private-book-ref-do-not-export",
|
||||||
|
sync_token="sync-token-do-not-export",
|
||||||
|
etag="private-etag-do-not-export",
|
||||||
|
remote_revision="private-remote-revision-do-not-export",
|
||||||
|
last_diagnostic={"secret": "diagnostic-do-not-export"},
|
||||||
|
created_by_account_id=self.account.id,
|
||||||
|
metadata_={"secret": "source-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
self.tombstone = AddressSyncTombstone(
|
||||||
|
id="tombstone-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
sync_source_id=self.sync_source.id,
|
||||||
|
address_book_id=self.book.id,
|
||||||
|
contact_id=self.contact.id,
|
||||||
|
remote_uid="private-uid-do-not-export",
|
||||||
|
resource_href="private-href-do-not-export",
|
||||||
|
synced_at=now,
|
||||||
|
metadata_={"secret": "tombstone-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
self.conflict = AddressSyncConflict(
|
||||||
|
id="conflict-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
sync_source_id=self.sync_source.id,
|
||||||
|
address_book_id=self.book.id,
|
||||||
|
contact_id=self.contact.id,
|
||||||
|
remote_uid="private-conflict-uid-do-not-export",
|
||||||
|
resource_href="private-conflict-href-do-not-export",
|
||||||
|
field_path="family_name",
|
||||||
|
local_value={"secret": "local-value-do-not-export"},
|
||||||
|
remote_value={"secret": "remote-value-do-not-export"},
|
||||||
|
status="resolved",
|
||||||
|
resolution="local",
|
||||||
|
resolved_at=now,
|
||||||
|
resolved_by_account_id=self.account.id,
|
||||||
|
metadata_={"secret": "conflict-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
self.import_run = AddressImportRun(
|
||||||
|
id="import-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
address_book_id=self.book.id,
|
||||||
|
source_filename="contacts.csv",
|
||||||
|
source_format="csv",
|
||||||
|
input_hash="a" * 64,
|
||||||
|
plan_hash="b" * 64,
|
||||||
|
status="applied",
|
||||||
|
row_count=1,
|
||||||
|
statistics={"secret": "statistics-do-not-export"},
|
||||||
|
diagnostics=[{"secret": "import-diagnostic-do-not-export"}],
|
||||||
|
plan_data=[{"secret": "import-plan-do-not-export"}],
|
||||||
|
result_evidence={"secret": "import-result-do-not-export"},
|
||||||
|
created_by_account_id=self.account.id,
|
||||||
|
applied_at=now,
|
||||||
|
)
|
||||||
|
self.merge = ContactMergeRecord(
|
||||||
|
id="merge-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
address_book_id=self.book.id,
|
||||||
|
winner_contact_id=self.contact.id,
|
||||||
|
loser_contact_ids=["old-contact-1"],
|
||||||
|
status="active",
|
||||||
|
reason="Duplicate contact",
|
||||||
|
survivorship={"secret": "survivorship-do-not-export"},
|
||||||
|
decisions=[{"secret": "merge-decisions-do-not-export"}],
|
||||||
|
before_payload={"secret": "merge-before-do-not-export"},
|
||||||
|
after_payload={"secret": "merge-after-do-not-export"},
|
||||||
|
before_hash="c" * 64,
|
||||||
|
after_hash="d" * 64,
|
||||||
|
created_by_account_id="another-account",
|
||||||
|
provenance={"secret": "merge-provenance-do-not-export"},
|
||||||
|
)
|
||||||
|
self.redirect = ContactRedirect(
|
||||||
|
id="redirect-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
source_contact_id="old-contact-1",
|
||||||
|
target_contact_id=self.contact.id,
|
||||||
|
merge_record_id=self.merge.id,
|
||||||
|
)
|
||||||
|
self.unrelated = Contact(
|
||||||
|
id="contact-unrelated",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
address_book_id=self.book.id,
|
||||||
|
display_name="Unrelated Person",
|
||||||
|
note="unrelated-person-do-not-export",
|
||||||
|
)
|
||||||
|
unrelated_email = ContactEmail(
|
||||||
|
id="email-unrelated",
|
||||||
|
contact_id=self.unrelated.id,
|
||||||
|
email="unrelated@example.test",
|
||||||
|
original_email="unrelated@example.test",
|
||||||
|
normalized_email="unrelated@example.test",
|
||||||
|
)
|
||||||
|
tenant_two_book = AddressBook(
|
||||||
|
id="book-tenant-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-2",
|
||||||
|
name="Other tenant",
|
||||||
|
)
|
||||||
|
tenant_two_contact = Contact(
|
||||||
|
id="contact-tenant-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
address_book_id=tenant_two_book.id,
|
||||||
|
display_name="Other Tenant Subject",
|
||||||
|
note="other-tenant-do-not-export",
|
||||||
|
)
|
||||||
|
tenant_two_email = ContactEmail(
|
||||||
|
id="email-tenant-2",
|
||||||
|
contact_id=tenant_two_contact.id,
|
||||||
|
email="subject@example.test",
|
||||||
|
original_email="subject@example.test",
|
||||||
|
normalized_email="subject@example.test",
|
||||||
|
)
|
||||||
|
self.session.add_all(
|
||||||
|
[
|
||||||
|
self.account,
|
||||||
|
self.user,
|
||||||
|
self.book,
|
||||||
|
self.contact,
|
||||||
|
self.email,
|
||||||
|
self.phone,
|
||||||
|
self.postal,
|
||||||
|
self.address_list,
|
||||||
|
self.list_entry,
|
||||||
|
self.channel_rule,
|
||||||
|
self.quality,
|
||||||
|
self.provenance,
|
||||||
|
self.sync_source,
|
||||||
|
self.tombstone,
|
||||||
|
self.conflict,
|
||||||
|
self.import_run,
|
||||||
|
self.merge,
|
||||||
|
self.redirect,
|
||||||
|
self.unrelated,
|
||||||
|
unrelated_email,
|
||||||
|
tenant_two_book,
|
||||||
|
tenant_two_contact,
|
||||||
|
tenant_two_email,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.provider = AddressesDsarProvider()
|
||||||
|
self.subject = DsarSubjectRef(
|
||||||
|
account_id=self.account.id,
|
||||||
|
email=self.account.email,
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_manifest_publishes_protocol_conforming_provider(self) -> None:
|
||||||
|
provided_names = {item.name for item in manifest.provides_interfaces}
|
||||||
|
self.assertIn(ADDRESSES_DSAR_CAPABILITY, provided_names)
|
||||||
|
provider = manifest.capability_factories[ADDRESSES_DSAR_CAPABILITY](None)
|
||||||
|
self.assertIsInstance(provider, DsarProvider)
|
||||||
|
|
||||||
|
def test_search_is_tenant_scoped_related_and_minimized(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
)
|
||||||
|
|
||||||
|
resource_types = {record.resource_type for record in records}
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"addresses_contact",
|
||||||
|
"addresses_contact_email",
|
||||||
|
"addresses_contact_phone",
|
||||||
|
"addresses_contact_postal_address",
|
||||||
|
"addresses_list_membership",
|
||||||
|
"addresses_channel_rule",
|
||||||
|
"addresses_quality_decision",
|
||||||
|
"addresses_field_provenance",
|
||||||
|
"addresses_merge_record",
|
||||||
|
"addresses_contact_redirect",
|
||||||
|
"addresses_sync_tombstone",
|
||||||
|
"addresses_sync_conflict",
|
||||||
|
"addresses_address_book_attribution",
|
||||||
|
"addresses_sync_source_attribution",
|
||||||
|
"addresses_import_run_attribution",
|
||||||
|
}.issubset(resource_types)
|
||||||
|
)
|
||||||
|
serialized = repr([record.to_dict() for record in records])
|
||||||
|
excluded_values = (
|
||||||
|
"password-secret-do-not-export",
|
||||||
|
"raw-source-payload-do-not-export",
|
||||||
|
"contact-provenance-do-not-export",
|
||||||
|
"book-metadata-do-not-export",
|
||||||
|
"field-value-do-not-export",
|
||||||
|
"sync-token-do-not-export",
|
||||||
|
"private-account-ref-do-not-export",
|
||||||
|
"private-remote-revision-do-not-export",
|
||||||
|
"local-value-do-not-export",
|
||||||
|
"remote-value-do-not-export",
|
||||||
|
"import-plan-do-not-export",
|
||||||
|
"merge-before-do-not-export",
|
||||||
|
"merge-after-do-not-export",
|
||||||
|
"unrelated-person-do-not-export",
|
||||||
|
"other-tenant-do-not-export",
|
||||||
|
)
|
||||||
|
for value in excluded_values:
|
||||||
|
self.assertNotIn(value, serialized)
|
||||||
|
|
||||||
|
def test_conflicting_selectors_and_uncorroborated_reference_fail_closed(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
conflict = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
email="subject@example.test",
|
||||||
|
external_references={"addresses.email": "other@example.test"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
uncorroborated = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
email="subject@example.test",
|
||||||
|
external_references={"addresses.contact": self.unrelated.id},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual((), conflict)
|
||||||
|
self.assertEqual((), uncorroborated)
|
||||||
|
|
||||||
|
def test_plan_retains_evidence_and_routes_contact_data_to_review(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
|
||||||
|
kinds = {action.kind for action in actions}
|
||||||
|
self.assertEqual({"manual_review", "retain"}, kinds)
|
||||||
|
self.assertFalse(any(action.executable for action in actions))
|
||||||
|
retained = [action for action in actions if action.kind == "retain"]
|
||||||
|
self.assertTrue(retained)
|
||||||
|
self.assertTrue(all(action.rationale for action in retained))
|
||||||
|
|
||||||
|
results = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-addresses-1",
|
||||||
|
)
|
||||||
|
self.assertEqual({"blocked"}, {result.status for result in results})
|
||||||
|
self.assertIsNotNone(self.session.get(Contact, self.contact.id))
|
||||||
|
|
||||||
|
def test_execution_rejects_foreign_or_forged_executable_actions(self) -> None:
|
||||||
|
foreign = DsarErasureActionRef(
|
||||||
|
action_id="mail:delete:contact:contact-1",
|
||||||
|
provider_id="mail",
|
||||||
|
module_id="mail",
|
||||||
|
kind="delete",
|
||||||
|
resource_type="addresses_contact",
|
||||||
|
resource_id=self.contact.id,
|
||||||
|
title="Foreign delete",
|
||||||
|
rationale="Must be rejected",
|
||||||
|
executable=True,
|
||||||
|
)
|
||||||
|
forged = DsarErasureActionRef(
|
||||||
|
action_id="addresses:delete:addresses_contact:contact-1",
|
||||||
|
provider_id="addresses",
|
||||||
|
module_id="addresses",
|
||||||
|
kind="delete",
|
||||||
|
resource_type="addresses_contact",
|
||||||
|
resource_id=self.contact.id,
|
||||||
|
title="Forged delete",
|
||||||
|
rationale="Must be rejected",
|
||||||
|
executable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
actions=(foreign,),
|
||||||
|
request_id="dsar-addresses-2",
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
actions=(forged,),
|
||||||
|
request_id="dsar-addresses-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_workflow_discovers_active_and_inactive_provider(self) -> None:
|
||||||
|
request = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-ADDRESSES-1",
|
||||||
|
request_kind="access",
|
||||||
|
subject=self.subject,
|
||||||
|
purpose="Respond to an authorized privacy request.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=request,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual("searched", request.status)
|
||||||
|
self.assertEqual(["addresses"], request.coverage["covered_modules"])
|
||||||
|
self.assertEqual([], request.coverage["modules_without_provider"])
|
||||||
|
|
||||||
|
disabled = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-ADDRESSES-DISABLED",
|
||||||
|
request_kind="access",
|
||||||
|
subject=self.subject,
|
||||||
|
purpose="Verify disabled-module coverage.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider, addresses_active=False),
|
||||||
|
row=disabled,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, disabled.search_result["record_count"])
|
||||||
|
self.assertEqual(
|
||||||
|
[ADDRESSES_DSAR_CAPABILITY],
|
||||||
|
disabled.coverage["inactive_provider_capabilities"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user