Add governed tabular and LDAP address sources

This commit is contained in:
2026-08-02 15:39:34 +02:00
parent e60339a5bf
commit 2c421022d4
19 changed files with 3846 additions and 78 deletions
+98 -2
View File
@@ -38,11 +38,15 @@ from govoplan_core.core.provider_governance import (
from govoplan_core.db.base import Base
from govoplan_addresses.backend.provider_state import (
CARDDAV_PROVIDER_ID,
LDAP_PROVIDER_ID,
carddav_provider_states,
ldap_provider_states,
)
_addresses_table_retirement_provider = drop_table_retirement_provider(
addresses_models.AddressImportRun,
addresses_models.AddressImportProfile,
addresses_models.ContactFieldProvenance,
addresses_models.ContactRedirect,
addresses_models.ContactMergeRecord,
@@ -155,6 +159,8 @@ ROLE_TEMPLATES = (
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_addresses.backend.db.models import (
AddressBook,
AddressImportProfile,
AddressImportRun,
AddressList,
AddressSyncSource,
Contact,
@@ -171,6 +177,8 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
"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(),
}
@@ -233,12 +241,59 @@ CARDDAV_PROVIDER = ExternalProviderDeclaration(
)
LDAP_PROVIDER = ExternalProviderDeclaration(
id=LDAP_PROVIDER_ID,
module_id="addresses",
label="Read-only LDAP and Active Directory contacts",
maturity="synchronize",
operations=("discover", "read", "preview", "synchronize"),
objects=(
ProviderObjectDeclaration(
object_type="contact",
field_groups=("identity", "name", "organization", "postal", "email", "phone", "source_metadata"),
authority_modes=("external_authoritative", "external_mirror"),
default_authority_mode="external_authoritative",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens="Stable LDAP source keys plus modifyTimestamp, uSNChanged, entryCSN, or a deterministic attribute digest are retained.",
concurrency="LDAP is authoritative and read-only; local projections are replaced only from a complete reviewed plan.",
freshness="Last attempt, last success, remote revision, and stale provider health remain visible.",
health="TLS, bind, discovery, paging, mapping, truncation, and malformed-entry failures are separate diagnostics.",
max_read_items=10000,
idempotency="The source binding, stable key, and revision prevent duplicate contact projections.",
retry="Failed reads are retried only by a new operator or scheduled sync attempt with bounded timeouts.",
timeout_seconds=120,
conflicts="Duplicate source keys, malformed mappings, and locally changed projections block or require a fresh plan.",
outcome_unknown="Read failures never infer external deletions and retain prior local projections as stale.",
outcome_unknown_supported=True,
evidence="Source keys, revisions, mapping configuration, diagnostics, tombstones, and normalized field provenance are retained.",
audit_event_types=(
"addresses.sync_source_created",
"addresses.sync_previewed",
"addresses.sync_completed",
),
correction="Correct the directory or mapping, then run a new full preview and synchronization.",
rollback="Prior projections remain reconstructable from source revision and contact change evidence; external LDAP is never mutated.",
compensation="A later authoritative refresh restores corrected projections.",
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.",
classifications=("personal", "confidential", "restricted"),
purposes=("directory projection", "recipient resolution", "identity-linked contact discovery"),
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.",
),
capability_names=(CAPABILITY_ADDRESSES_LOOKUP, CAPABILITY_ADDRESSES_CONTACT_WRITER),
documentation_topic_ids=("addresses.ldap-directory",),
)
manifest = ModuleManifest(
id="addresses",
name="Addresses",
version="0.1.9",
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", "connectors"),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"),
@@ -288,6 +343,8 @@ manifest = ModuleManifest(
},
uninstall_guard_providers=(
persistent_table_uninstall_guard(
addresses_models.AddressImportRun,
addresses_models.AddressImportProfile,
addresses_models.AddressSyncDiagnostic,
addresses_models.AddressSyncConflict,
addresses_models.AddressSyncTombstone,
@@ -340,6 +397,40 @@ manifest = ModuleManifest(
related_modules=("dist_lists", "campaigns", "policy", "templates"),
order=31,
),
DocumentationTopic(
id="addresses.tabular-imports",
title="CSV and XLSX contact imports",
summary="Preview and apply reusable, versioned contact mappings without silent row loss.",
body=(
"CSV and XLSX files can be mapped with scoped, reusable profile versions. Each preview validates headers, "
"encodings, source keys, duplicates, blank values, workbook limits, and contact identity before any mutation. "
"The reviewed input hash and plan hash are retained with row-level effects and diagnostics. Apply is idempotent, "
"rejects contacts changed after preview, and records sufficient evidence for a guarded rollback. XLSX formulas, "
"macros, and legacy workbook formats are never executed or imported."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "module_admin", "power_user"),
related_modules=("connectors", "datasources", "dataflow", "files", "audit"),
order=33,
),
DocumentationTopic(
id="addresses.ldap-directory",
title="LDAP and Active Directory address sources",
summary="Project authoritative directory contacts through a bounded, read-only synchronization source.",
body=(
"LDAP sources use LDAPS or StartTLS and reusable credential envelopes. Discovery finds available base DNs; "
"the source profile then controls a bounded paged filter and explicit attribute mapping. Preview never mutates "
"contacts. A complete successful read may create, update, or tombstone local projections; truncated or failed "
"reads suppress absence-based deletes and mark the source stale. Stable source keys, revisions, normalized fields, "
"and provenance remain attached to every retained contact."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "operator", "module_admin"),
related_modules=("connectors", "idm", "access", "policy", "audit"),
order=34,
),
DocumentationTopic(
id="addresses.quality-and-merge",
title="Contact quality, duplicates, and reversible merges",
@@ -360,13 +451,18 @@ manifest = ModuleManifest(
order=32,
),
),
external_providers=(CARDDAV_PROVIDER,),
external_providers=(CARDDAV_PROVIDER, LDAP_PROVIDER),
external_provider_state_providers=(
ExternalProviderStateProviderRegistration(
module_id="addresses",
provider_id=CARDDAV_PROVIDER_ID,
provider=carddav_provider_states,
),
ExternalProviderStateProviderRegistration(
module_id="addresses",
provider_id=LDAP_PROVIDER_ID,
provider=ldap_provider_states,
),
),
architecture=declared_module_architecture(
layer="communication_participation",